diff --git a/.betterer.results b/.betterer.results index 9aa8cdfa3ec..3ec225ceb44 100644 --- a/.betterer.results +++ b/.betterer.results @@ -2631,6 +2631,11 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"] ], + "public/app/features/correlations/Forms/ConfigureCorrelationTargetForm.tsx:5381": [ + [0, 0, 0, "Styles should be written using objects.", "0"], + [0, 0, 0, "Do not use any type assertions.", "1"], + [0, 0, 0, "Do not use any type assertions.", "2"] + ], "public/app/features/correlations/components/EmptyCorrelationsCTA.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"], [0, 0, 0, "No untranslated strings. Wrap text with ", "1"] @@ -2639,6 +2644,10 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use export all (\`export * from ...\`)", "0"], [0, 0, 0, "Do not use export all (\`export * from ...\`)", "1"] ], + "public/app/features/correlations/types.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"] + ], "public/app/features/dashboard-scene/embedding/EmbeddedDashboardTestPage.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] ], @@ -2656,8 +2665,7 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings. Wrap text with ", "10"] ], "public/app/features/dashboard-scene/inspect/HelpWizard/utils.ts:5381": [ - [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.", "0"] ], "public/app/features/dashboard-scene/inspect/InspectDataTab.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] @@ -2784,9 +2792,7 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"] ], "public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"] + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9858f0a295b..c30c40ee279 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -43,12 +43,12 @@ /docs/sources/dashboards/ @imatwawana /docs/sources/datasources/ @jdbaldry /docs/sources/explore/ @grafana/explore-squad @lwandz13 -/docs/sources/fundamentals @chri2547 -/docs/sources/getting-started/ @chri2547 -/docs/sources/introduction/ @chri2547 +/docs/sources/fundamentals @irenerl24 +/docs/sources/getting-started/ @irenerl24 +/docs/sources/introduction/ @irenerl24 /docs/sources/panels-visualizations/ @imatwawana /docs/sources/release-notes/ @Eve832 @GrafanaWriter -/docs/sources/setup-grafana/ @chri2547 +/docs/sources/setup-grafana/ @irenerl24 /docs/sources/upgrade-guide/ @imatwawana /docs/sources/whatsnew/ @imatwawana diff --git a/.golangci.toml b/.golangci.toml index dc3ebd63afc..693ce6bc26e 100644 --- a/.golangci.toml +++ b/.golangci.toml @@ -1,6 +1,7 @@ [run] timeout = "20m" concurrency = 10 +allow-parallel-runners = true [linters-settings.exhaustive] default-signifies-exhaustive = true diff --git a/CHANGELOG.md b/CHANGELOG.md index 96a46622e06..28a7201ada6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,82 @@ + + +# 11.2.1 (2024-09-26) + +### Features and enhancements + +- **Alerting:** Support for optimistic concurrency in priovisioning Tempate API [#92251](https://github.com/grafana/grafana/pull/92251), [@yuri-tceretian](https://github.com/yuri-tceretian) +- **Logs panel:** Enable displayedFields in dashboards and apps [#92675](https://github.com/grafana/grafana/pull/92675), [@matyax](https://github.com/matyax) +- **State timeline:** Add pagination support [#92257](https://github.com/grafana/grafana/pull/92257), [@kevinputera](https://github.com/kevinputera) + +### Bug fixes + +- **Authn:** No longer hash service account token twice during authentication [#92639](https://github.com/grafana/grafana/pull/92639), [@kalleep](https://github.com/kalleep) +- **CloudMigrations:** Fix snapshot creation on Windows systems [#92981](https://github.com/grafana/grafana/pull/92981), [@macabu](https://github.com/macabu) +- **DashGPT:** Fixes issue with generation on Safari [#92952](https://github.com/grafana/grafana/pull/92952), [@kaydelaney](https://github.com/kaydelaney) +- **Dashboard:** Fix Annotation runtime error when a data source does not support annotations [#92830](https://github.com/grafana/grafana/pull/92830), [@axelavargas](https://github.com/axelavargas) +- **Grafana SQL:** Fix broken import in NumberInput component [#92808](https://github.com/grafana/grafana/pull/92808), [@chessman](https://github.com/chessman) +- **Logs:** Show older logs button when infinite scroll is enabled and sort order is descending [#92867](https://github.com/grafana/grafana/pull/92867), [@matyax](https://github.com/matyax) +- **RBAC:** Fix an issue with server admins not being able to manage users in orgs that they don't belong to [#92274](https://github.com/grafana/grafana/pull/92274), [@IevaVasiljeva](https://github.com/IevaVasiljeva) +- **RBAC:** Fix an issue with server admins not being able to manage users in orgs that they don't belong to (Enterprise) +- **Reporting:** Disable dashboardSceneSolo when rendering PDFs the old way (Enterprise) +- **Templating:** Fix searching non-latin template variables [#92893](https://github.com/grafana/grafana/pull/92893), [@leeoniya](https://github.com/leeoniya) +- **TutorialCard:** Fix link to tutorial not opening [#92647](https://github.com/grafana/grafana/pull/92647), [@eledobleefe](https://github.com/eledobleefe) + +### Plugin development fixes & changes + +- **AutoSizeInput:** Allow to be controlled by value [#92999](https://github.com/grafana/grafana/pull/92999), [@ivanortegaalba](https://github.com/ivanortegaalba) + + + + +# 11.1.6 (2024-09-26) + +### Features and enhancements + +- **Chore:** Update swagger ui (4.3.0 to 5.17.14) [#92341](https://github.com/grafana/grafana/pull/92341), [@ryantxu](https://github.com/ryantxu) + +### Bug fixes + +- **Templating:** Fix searching non-latin template variables [#92892](https://github.com/grafana/grafana/pull/92892), [@leeoniya](https://github.com/leeoniya) +- **TutorialCard:** Fix link to tutorial not opening [#92646](https://github.com/grafana/grafana/pull/92646), [@eledobleefe](https://github.com/eledobleefe) + +### Plugin development fixes & changes + +- **Bugfix:** QueryField typeahead missing background color [#92316](https://github.com/grafana/grafana/pull/92316), [@mckn](https://github.com/mckn) + + + + +# 11.0.5 (2024-09-26) + +### Features and enhancements + +- **Chore:** Update swagger ui (4.3.0 to 5.17.14) [#92345](https://github.com/grafana/grafana/pull/92345), [@ryantxu](https://github.com/ryantxu) + +### Bug fixes + +- **Provisioning:** Prevent provisioning folder errors from failing startup [#92588](https://github.com/grafana/grafana/pull/92588), [@suntala](https://github.com/suntala) +- **TutorialCard:** Fix link to tutorial not opening [#92645](https://github.com/grafana/grafana/pull/92645), [@eledobleefe](https://github.com/eledobleefe) + + + + +# 10.4.9 (2024-09-26) + +### Features and enhancements + +- **Chore:** Update swagger ui (4.3.0 to 5.17.14) [#92344](https://github.com/grafana/grafana/pull/92344), [@ryantxu](https://github.com/ryantxu) + +### Bug fixes + +- **Provisioning:** Prevent provisioning folder errors from failing startup [#92591](https://github.com/grafana/grafana/pull/92591), [@suntala](https://github.com/suntala) + + + + +# 10.3.10 (2024-09-26) + + # 11.2.0 (2024-08-27) diff --git a/apps/alerting/notifications/template.cue b/apps/alerting/notifications/template.cue new file mode 100644 index 00000000000..56c2507cd1e --- /dev/null +++ b/apps/alerting/notifications/template.cue @@ -0,0 +1,25 @@ +package core + +templateGroup: { + kind: "TemplateGroup" + group: "notifications" + apiResource: { + groupOverride: "notifications.alerting.grafana.app" + } + codegen: { + frontend: false + backend: true + } + pluralName: "TemplatesGroups" + current: "v0alpha1" + versions: { + "v0alpha1": { + schema: { + spec: { + title: string + content: string + } + } + } + } +} diff --git a/conf/defaults.ini b/conf/defaults.ini index 79ecacdf39b..3e7fc20ef90 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -1764,6 +1764,9 @@ ha_engine_address = "127.0.0.1:6379" # ha_engine_password allows setting an optional password to authenticate with the engine ha_engine_password = "" +# ha_prefix is a prefix for keys in the HA engine. It's used to separate keys for different Grafana instances. +ha_prefix = + #################################### Grafana Image Renderer Plugin ########################## [plugin.grafana-image-renderer] # Instruct headless browser instance to use a default timezone when not provided by Grafana, e.g. when rendering panel image of alert. diff --git a/conf/sample.ini b/conf/sample.ini index 3368d7e288e..a36d17ff618 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -1718,6 +1718,9 @@ timeout = 30s # ha_engine_password allows setting an optional password to authenticate with the engine ;ha_engine_password = "" +# ha_prefix is a prefix for keys in the HA engine. It's used to separate keys for different Grafana instances. +;ha_prefix = + #################################### Grafana Image Renderer Plugin ########################## [plugin.grafana-image-renderer] # Instruct headless browser instance to use a default timezone when not provided by Grafana, e.g. when rendering panel image of alert. diff --git a/devenv/dev-dashboards/migrations/migrations.json b/devenv/dev-dashboards/migrations/migrations.json index 3d13fe327f6..5abfe8be420 100644 --- a/devenv/dev-dashboards/migrations/migrations.json +++ b/devenv/dev-dashboards/migrations/migrations.json @@ -389,9 +389,9 @@ "type": "testdata", "uid": "PD8C576611E62080A" }, + "rawFrameContent": "[\n {\n \"schema\": {\n \"refId\": \"A\",\n \"meta\": {\n \"typeVersion\": [\n 0,\n 0\n ],\n \"custom\": {\n \"customStat\": 10\n }\n },\n \"fields\": [\n {\n \"name\": \"time\",\n \"type\": \"time\",\n \"typeInfo\": {\n \"frame\": \"time.Time\",\n \"nullable\": true\n },\n \"config\": {\n \"interval\": 3600000\n }\n },\n {\n \"name\": \"Value\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\",\n \"nullable\": true\n },\n \"labels\": {\n \"pod\": \"A-pod\"\n },\n \"config\": {}\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1727107111901,\n 1727110711901,\n 1727114311901,\n 1727117911901,\n 1727121511901,\n 1727125111901\n ],\n [\n 1.907286825122581,\n 2.260951647569786,\n 1.887442338051216,\n 2.1526144400893514,\n 1.7287721375237766,\n 1.7262902137793208\n ]\n ]\n }\n },\n {\n \"schema\": {\n \"refId\": \"A\",\n \"meta\": {\n \"typeVersion\": [\n 0,\n 0\n ],\n \"custom\": {\n \"customStat\": 10\n }\n },\n \"fields\": [\n {\n \"name\": \"time\",\n \"type\": \"time\",\n \"typeInfo\": {\n \"frame\": \"time.Time\",\n \"nullable\": true\n },\n \"config\": {\n \"interval\": 3600000\n }\n },\n {\n \"name\": \"Value\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\",\n \"nullable\": true\n },\n \"labels\": {\n \"pod\": \"A-pod1\"\n },\n \"config\": {}\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1727107111901,\n 1727110711901,\n 1727114311901,\n 1727117911901,\n 1727121511901,\n 1727125111901\n ],\n [\n 1.907286825122581,\n 1.589539045095202,\n 1.5914283506847613,\n 1.8976990616650726,\n 1.758223085999124,\n 2.2294649594813816\n ]\n ]\n }\n },\n {\n \"schema\": {\n \"refId\": \"A\",\n \"meta\": {\n \"typeVersion\": [\n 0,\n 0\n ],\n \"custom\": {\n \"customStat\": 10\n }\n },\n \"fields\": [\n {\n \"name\": \"time\",\n \"type\": \"time\",\n \"typeInfo\": {\n \"frame\": \"time.Time\",\n \"nullable\": true\n },\n \"config\": {\n \"interval\": 3600000\n }\n },\n {\n \"name\": \"Value\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"float64\",\n \"nullable\": true\n },\n \"labels\": {\n \"pod\": \"A-pod2\"\n },\n \"config\": {}\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1727107111901,\n 1727110711901,\n 1727114311901,\n 1727117911901,\n 1727121511901,\n 1727125111901\n ],\n [\n 1.907286825122581,\n 2.0914263380328766,\n 1.8164545521094575,\n 1.621111084665713,\n 1.3902653996444705,\n 1.482803315949775\n ]\n ]\n }\n }\n]", "refId": "A", - "scenarioId": "random_walk", - "seriesCount": 3 + "scenarioId": "raw_frame" } ], "thresholds": [], @@ -462,6 +462,145 @@ "title": "Status + Notes", "type": "text" }, + { + "aliasColors": {}, + "bars": true, + "dashLength": 10, + "dashes": false, + "datasource": { + "default": false, + "type": "datasource", + "uid": "-- Dashboard --" + }, + "fieldConfig": { + "defaults": { + "unit": "short" + }, + "overrides": [] + }, + "fill": 1, + "fillGradient": 0, + "gridPos": { + "h": 11, + "w": 16, + "x": 0, + "y": 22 + }, + "hiddenSeries": false, + "id": 32, + "legend": { + "alignAsTable": true, + "avg": true, + "current": true, + "max": true, + "min": false, + "show": true, + "total": false, + "values": true + }, + "lines": false, + "linewidth": 1, + "nullPointMode": "null", + "options": { + "alertThreshold": true, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "percentage": false, + "pluginVersion": "11.3.0-pre", + "pointradius": 2, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "spaceLength": 10, + "stack": false, + "steppedLine": false, + "targets": [ + { + "datasource": { + "type": "datasource", + "uid": "-- Dashboard --" + }, + "panelId": 28, + "refId": "A" + } + ], + "thresholds": [], + "timeRegions": [], + "title": "Flot graph - x axis series mode (with legend calcs)", + "tooltip": { + "shared": false, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "mode": "series", + "show": true, + "values": [ + "total" + ] + }, + "yaxes": [ + { + "$$hashKey": "object:88", + "format": "short", + "logBase": 1, + "show": true + }, + { + "$$hashKey": "object:89", + "format": "short", + "logBase": 1, + "show": true + } + ], + "yaxis": { + "align": false + } + }, + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "gridPos": { + "h": 11, + "w": 8, + "x": 16, + "y": 22 + }, + "id": 33, + "options": { + "code": { + "language": "plaintext", + "showLineNumbers": false, + "showMiniMap": false + }, + "content": "# Graph panel >> Bar gauge panel\n", + "mode": "markdown" + }, + "pluginVersion": "11.3.0-pre", + "targets": [ + { + "datasource": { + "type": "testdata", + "uid": "PD8C576611E62080A" + }, + "refId": "A" + } + ], + "title": "Status + Notes", + "type": "text" + }, { "aliasColors": {}, "bars": true, @@ -1183,4 +1322,4 @@ "uid": "cdd412c4", "version": 68, "weekStart": "" -} \ No newline at end of file +} diff --git a/devenv/docker/blocks/auth/nginx_proxy/nginx_serve_from_sub_path.conf b/devenv/docker/blocks/auth/nginx_proxy/nginx_serve_from_sub_path.conf new file mode 100644 index 00000000000..5da5207657b --- /dev/null +++ b/devenv/docker/blocks/auth/nginx_proxy/nginx_serve_from_sub_path.conf @@ -0,0 +1,39 @@ +events { worker_connections 1024; } + +http { + # This is required to proxy Grafana Live WebSocket connections. + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + upstream grafana { + server host.docker.internal:3000; + } + + server { + listen 8090; + + location / { + root /var/www/html; + } + + # Set the followings in grafana.ini: + # [server] + # root_url = http://localhost:8090/grafana/ + # serve_from_sub_path = true + location /grafana/ { + proxy_set_header Host $host; + proxy_pass http://grafana; + } + + # Proxy Grafana Live WebSocket connections. + location /grafana/api/live/ { + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_pass http://grafana; + } + } +} \ No newline at end of file diff --git a/docs/sources/administration/roles-and-permissions/access-control/custom-role-actions-scopes/index.md b/docs/sources/administration/roles-and-permissions/access-control/custom-role-actions-scopes/index.md index 396ad7dea81..7f3f60f1c96 100644 --- a/docs/sources/administration/roles-and-permissions/access-control/custom-role-actions-scopes/index.md +++ b/docs/sources/administration/roles-and-permissions/access-control/custom-role-actions-scopes/index.md @@ -79,7 +79,7 @@ The following list contains role-based access control actions. | `dashboards.permissions:write` |
  • `dashboards:*`
  • `dashboards:uid:*`
  • `folders:*`
  • `folders:uid:*`
| Update permissions for one or more dashboards. | | `dashboards:read` |
  • `dashboards:*`
  • `dashboards:uid:*`
  • `folders:*`
  • `folders:uid:*`
| Read one or more dashboards. | | `dashboards:write` |
  • `dashboards:*`
  • `dashboards:uid:*`
  • `folders:*`
  • `folders:uid:*`
| Update one or more dashboards. | -| `dashboards.public:write` |
  • `dashboards:*`
  • `dashboards:uid:*`
| Write public dashboard configuration. | +| `dashboards.public:write` |
  • `dashboards:*`
  • `dashboards:uid:*`
| Write shared dashboard configuration. | | `datasources.caching:read` |
  • `datasources:*`
  • `datasources:uid:*`
| Read data source query caching settings. | | `datasources.caching:write` |
  • `datasources:*`
  • `datasources:uid:*`
| Update data source query caching settings. | | `datasources:create` | None | Create data sources. | diff --git a/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/index.md b/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/index.md index 68ea28a7814..ccc283db650 100644 --- a/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/index.md +++ b/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/index.md @@ -98,7 +98,7 @@ To learn how to use the roles API to determine the role UUIDs, refer to [Manage | `fixed:dashboards.insights:reader` | `fixed_JlBJ2_gizP8zhgaeGE2rjyZe2Rs` | `dashboards.insights:read` | Read dashboard insights data and see presence indicators. | | `fixed:dashboards.permissions:reader` | `fixed_f17oxuXW_58LL8mYJsm4T_mCeIw` | `dashboards.permissions:read` | Read all dashboard permissions. | | `fixed:dashboards.permissions:writer` | `fixed_CcznxhWX_Yqn8uWMXMQ-b5iFW9k` | All permissions from `fixed:dashboards.permissions:reader` and
`dashboards.permissions:write` | Read and update all dashboard permissions. | -| `fixed:dashboards.public:writer` | `fixed_f_GHHRBciaqESXfGz2oCcooqHxs` | `dashboards.public:write` | Create, update, delete or pause a public dashboard. | +| `fixed:dashboards.public:writer` | `fixed_f_GHHRBciaqESXfGz2oCcooqHxs` | `dashboards.public:write` | Create, update, delete or pause a shared dashboard. | | `fixed:datasources:creator` | `fixed_XX8jHREgUt-wo1A-rPXIiFlX6Zw` | `datasources:create` | Create data sources. | | `fixed:datasources:explorer` | `fixed_qDzW9mzx9yM91T5Bi8dHUM2muTw` | `datasources:explore` | Enable the Explore feature. Data source permissions still apply, you can only query data sources for which you have query permissions. | | `fixed:datasources:reader` | `fixed_C2x8IxkiBc1KZVjyYH775T9jNMQ` | `datasources:read`
`datasources:query` | Read and query data sources. | diff --git a/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier.md b/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier.md index 752f3b9fa52..556d66755ce 100644 --- a/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier.md +++ b/docs/sources/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier.md @@ -117,9 +117,9 @@ The webhook notification is a simple way to send information about a state chang | version | string | Version of the payload | | groupKey | string | Key that is used for grouping | | truncatedAlerts | number | Number of alerts that were truncated | -| title | string | **Will be deprecated soon** | -| state | string | **Will be deprecated soon** | -| message | string | **Will be deprecated soon** | +| title | string | Custom title | +| state | string | State of the alert group (either `alerting` or `ok`) | +| message | string | Custom message | ### Alert diff --git a/docs/sources/alerting/fundamentals/alert-rules/_index.md b/docs/sources/alerting/fundamentals/alert-rules/_index.md index b948591475f..5d9a3219f64 100644 --- a/docs/sources/alerting/fundamentals/alert-rules/_index.md +++ b/docs/sources/alerting/fundamentals/alert-rules/_index.md @@ -69,9 +69,7 @@ Grafana supports two different alert rule types: Grafana-managed alert rules and ## Grafana-managed alert rules -Grafana-managed alert rules are the most flexible alert rule type. They allow you to create alert rules that can act on data from any of the [supported data sources](#supported-data-sources), and use multiple data sources in a single alert rule. You can also add expressions to transform your data and set alert conditions. Using images in alert notifications is also supported. - -Additionally, you can also add [expressions to transform your data](ref:expression-queries), set custom alert conditions, and include [images in alert notifications](ref:notification-images). +Grafana-managed alert rules are the most flexible alert rule type. They allow you to create alert rules that can act on data from any of the [supported data sources](#supported-data-sources), and use multiple data sources in a single alert rule. You can add [expressions to transform your data](ref:expression-queries) and set custom alert conditions. Using [images in alert notifications](ref:notification-images). is also supported. {{< figure src="/media/docs/alerting/grafana-managed-alerting-architecture.png" max-width="750px" caption="How Grafana-managed alerting works by default" >}} diff --git a/docs/sources/alerting/set-up/configure-alertmanager/index.md b/docs/sources/alerting/set-up/configure-alertmanager/index.md index 86956ad6837..52ef493b7c4 100644 --- a/docs/sources/alerting/set-up/configure-alertmanager/index.md +++ b/docs/sources/alerting/set-up/configure-alertmanager/index.md @@ -34,17 +34,32 @@ Grafana Alerting is based on the architecture of the Prometheus alerting system. {{< figure src="/media/docs/alerting/alerting-alertmanager-architecture.png" max-width="750px" alt="A diagram with the alert generator and alert manager architecture" >}} -**Grafana Alertmanager** +This architecture decouples alert rule evaluation from notification handling, allowing alerts to be forwarded to other Alertmanagers. -Grafana has its own built-in Alertmanager, referred to as "Grafana" in the user interface. It is the default Alertmanager and can only handle Grafana-managed alerts. +Grafana can use different Alertmanagers. It’s important to note that each Alertmanager manages its own independent alerting resources, such as: -**Cloud Alertmanager** +- Contact points and notification templates +- Notification policies and mute timings +- Silences +- Active notifications -Each Grafana Cloud instance comes preconfigured with an additional Alertmanager (`grafanacloud-STACK_NAME-ngalertmanager`) from the Mimir (Prometheus) instance running in the Grafana Cloud Stack. The Cloud Alertmanager can handle both Grafana-managed and data source-managed alerts. +Use the `Choose Alertmanager` on these pages to switch between Alertmanagers. -**Other Alertmanagers** +{{< figure src="/media/docs/alerting/alerting-choose-alertmanager.png" max-width="750px" alt="A screenshot choosing an Alertmanager in the notification policies UI" >}} -Grafana Alerting also supports sending alerts to other alertmanagers, such as the [Prometheus Alertmanager](https://prometheus.io/docs/alerting/latest/alertmanager/), which can handle Grafana-managed alerts and data sources-managed alerts such as alerts from Loki, Mimir, and Prometheus. +## Types of Alertmanagers in Grafana + +Grafana can be configured to manage both Grafana-managed and data source-managed alerts using various Alertmanagers, depending on your infrastructure and alerting requirements. + +- **Grafana Alertmanager**: Grafana has its own built-in Alertmanager, referred to as "Grafana" in the user interface. It is the default Alertmanager and can only handle Grafana-managed alerts. + +- **Cloud Alertmanager**: Each Grafana Cloud instance comes preconfigured with an additional Alertmanager (`grafanacloud-STACK_NAME-ngalertmanager`) from the Mimir (Prometheus) instance running in the Grafana Cloud Stack. + + The Cloud Alertmanager is available exclusively in Grafana Cloud and can handle both Grafana-managed and data source-managed alerts. + + Some Grafana Cloud services, such as **Kubernetes Monitoring** and **Synthetic Monitoring** use the Cloud Alertmanager to create and manage alerts. + +- **Other Alertmanagers**: Grafana Alerting also supports sending alerts to other Alertmanagers, such as the [Prometheus Alertmanager](https://prometheus.io/docs/alerting/latest/alertmanager/), which can handle Grafana-managed alerts and data sources-managed alerts such as alerts from Loki, Mimir, and Prometheus. You can use a combination of Alertmanagers. The decision often depends on your alerting setup and where your alerts are being generated. Here are two examples of when you may want to add an Alertmanager and send your alerts there instead of using the built-in Grafana Alertmanager. @@ -56,36 +71,32 @@ You can use a combination of Alertmanagers. The decision often depends on your a From Grafana, you can configure and administer your own Alertmanager to receive Grafana alerts. -{{% admonition type="note" %}} -Grafana Alerting does not support sending alerts to the AWS Managed Service for Prometheus due to the lack of sigv4 support in Prometheus. -{{% /admonition %}} +After adding an Alertmanager, you can use the Grafana Alerting UI to manage notification policies, contact points, and other alerting resources from within Grafana, with support for HTTP basic authentication credentials. -After you have added the Alertmanager, you can use the Grafana Alerting UI to manage silences, contact points, and notification policies. A drop-down option in these pages allows you to switch between alertmanagers. - -{{< figure src="/media/docs/alerting/alerting-choose-alertmanager.png" max-width="750px" alt="A screenshot choosing an Alertmanager in the notification policies UI" >}} - -Alertmanagers should now be configured as data sources using Grafana Configuration from the main Grafana navigation menu. This enables you to manage the contact points and notification policies of external alertmanagers from within Grafana and also encrypts HTTP basic authentication credentials. - -To add an Alertmanager, complete the following steps. +Alertmanagers should be configured as data sources using Grafana Configuration from the main Grafana navigation menu. To add an Alertmanager, complete the following steps. 1. Click **Connections** in the left-side menu. -2. On the Connections page, search for `Alertmanager`. -3. Click the **Create a new data source** button. +1. On the Connections page, search for `Alertmanager`. +1. Click the **Create a new data source** button. If you don't see this button, you may need to install the plugin, relaunch your Cloud instance, and then repeat steps 1 and 2. -4. Fill out the fields on the page, as required. +1. Fill out the fields on the page, as required. If you are provisioning your data source, set the flag `handleGrafanaManagedAlerts` in the `jsonData` field to `true` to send Grafana-managed alerts to this Alertmanager. **Note:** Prometheus, Grafana Mimir, and Cortex implementations of Alertmanager are supported. For Prometheus, contact points and notification policies are read-only in the Grafana Alerting UI. -5. Click **Save & test**. +1. Click **Save & test**. + +{{% admonition type="note" %}} +Grafana Alerting does not support sending alerts to the AWS Managed Service for Prometheus due to the lack of sigv4 support in Prometheus. +{{% /admonition %}} + +## Manage Alertmanager configurations -{{< admonition type="note" >}} On the Settings page, you can manage your Alertmanager configurations and configure where Grafana-managed alert instances are forwarded. - Manage which Alertmanagers receive alert instances from Grafana-managed rules without navigating and editing data sources. - Manage version snapshots for the built-in Alertmanager, which allows administrators to roll back unintentional changes or mistakes in the Alertmanager configuration. - Compare the historical snapshot with the latest configuration to see which changes were made. - {{< /admonition >}} diff --git a/docs/sources/breaking-changes/breaking-changes-v11-0.md b/docs/sources/breaking-changes/breaking-changes-v11-0.md index f223375f276..883c062ae04 100644 --- a/docs/sources/breaking-changes/breaking-changes-v11-0.md +++ b/docs/sources/breaking-changes/breaking-changes-v11-0.md @@ -118,7 +118,7 @@ Ensure you have a public dashboard footer logo or footer text set if you don't w #### Learn more -[Configure custom branding documentation](https://grafana.com/docs/grafana//setup-grafana/configure-grafana/configure-custom-branding/#custom-branding-for-public-dashboards) for public dashboards +[Configure custom branding documentation](https://grafana.com/docs/grafana//setup-grafana/configure-grafana/configure-custom-branding/#custom-branding-for-shared-dashboards) for public dashboards ### Subfolders cause very rare issues with folders that have forward slashes in their names diff --git a/docs/sources/dashboards/_index.md b/docs/sources/dashboards/_index.md index 0ebbf8d3334..bd2fc73180e 100644 --- a/docs/sources/dashboards/_index.md +++ b/docs/sources/dashboards/_index.md @@ -31,17 +31,17 @@ cards: href: ./variables/ description: Add variables to metric queries and panel titles to create interactive and dynamic dashboards. height: 24 - - title: Public dashboards - href: ./dashboard-public/ - description: Make your Grafana dashboards public and share them with anyone without requiring access to your Grafana organization. - height: 24 - title: Reporting href: ./create-reports/ description: Automatically generate and share PDF reports from your Grafana dashboards. height: 24 - title: Sharing href: ./share-dashboards-panels/ - description: Share Grafana dashboards and panels within your organization using links, snapshots, and JSON exports. + description: Share Grafana dashboards and panels using links, snapshots, embeds, and exports. + height: 24 + - title: Shared dashboards + href: ./share-dashboards-panels/shared-dashboards/ + description: Share your dashboards with anyone without requiring access to your Grafana organization. height: 24 refs: panels: diff --git a/docs/sources/dashboards/assess-dashboard-usage/index.md b/docs/sources/dashboards/assess-dashboard-usage/index.md index 260ed8baaf0..5c0c46a2e8a 100644 --- a/docs/sources/dashboards/assess-dashboard-usage/index.md +++ b/docs/sources/dashboards/assess-dashboard-usage/index.md @@ -17,7 +17,7 @@ labels: - cloud - enterprise title: Assess dashboard usage -weight: 200 +weight: 900 refs: grafana-enterprise: - pattern: /docs/grafana/ @@ -34,7 +34,7 @@ refs: destination: /docs/grafana//setup-grafana/configure-security/export-logs/ - pattern: /docs/grafana-cloud/ destination: /docs/grafana//setup-grafana/configure-security/export-logs/ - enabled: + dashboard-sharing: - pattern: /docs/grafana/ destination: /docs/grafana//setup-grafana/configure-grafana/#public_dashboards - pattern: /docs/grafana-cloud/ @@ -79,7 +79,7 @@ For every dashboard and data source, you can access usage information. To see dashboard usage information, click the dashboard insights icon in the header. -{{< figure src="/media/docs/grafana/dashboards/screenshot-dashboard-insights-11.2.png" alt="Dashboard insights icon" >}} +![Dashboard insights icon](/media/docs/grafana/dashboards/screenshot-dashboard-insights-icon-11.2.png) Dashboard insights show the following information: @@ -88,7 +88,7 @@ Dashboard insights show the following information: {{< figure src="/static/img/docs/enterprise/dashboard_insights_stats.png" max-width="400px" class="docs-image--no-shadow" alt="Stats tab" >}}{{< figure src="/static/img/docs/enterprise/dashboard_insights_users.png" max-width="400px" class="docs-image--no-shadow" alt="Users and activity tab" >}} -If public dashboards are [enabled](ref:enabled), you'll also see a **Public dashboards** tab in your analytics. +If [dashboard sharing](ref:dashboard-sharing) is enabled, you'll also see a **Shared dashboards** tab in your analytics. ### Data source insights diff --git a/docs/sources/dashboards/build-dashboards/_index.md b/docs/sources/dashboards/build-dashboards/_index.md index 7077ff30bdd..4c228c3458c 100644 --- a/docs/sources/dashboards/build-dashboards/_index.md +++ b/docs/sources/dashboards/build-dashboards/_index.md @@ -14,7 +14,7 @@ labels: menuTitle: Build dashboards title: Build dashboards description: Build dashboards including managing settings, links, and version history -weight: 2 +weight: 200 refs: variables: - pattern: /docs/grafana/ diff --git a/docs/sources/dashboards/build-dashboards/manage-dashboard-links/index.md b/docs/sources/dashboards/build-dashboards/manage-dashboard-links/index.md index d0917600909..3dd373f4c31 100644 --- a/docs/sources/dashboards/build-dashboards/manage-dashboard-links/index.md +++ b/docs/sources/dashboards/build-dashboards/manage-dashboard-links/index.md @@ -22,7 +22,7 @@ labels: - oss menuTitle: Manage dashboard links title: Manage dashboard links -weight: 500 +weight: 200 refs: data-links: - pattern: /docs/grafana/ @@ -148,7 +148,7 @@ Each panel can have its own set of links that are shown in the upper left of the Click the icon next to the panel title to see available panel links. -{{< figure src="/media/docs/grafana/screenshot-panel-links.png" width="200px" alt="List of panel links displayed" >}} +{{< figure src="/media/docs/grafana/dashboards/screenshot-panel-links-v11.3.png" max-width="550px" alt="List of panel links displayed" >}} ### Add a panel link diff --git a/docs/sources/dashboards/build-dashboards/manage-library-panels/index.md b/docs/sources/dashboards/build-dashboards/manage-library-panels/index.md index 838c194956c..facbdfa92a2 100644 --- a/docs/sources/dashboards/build-dashboards/manage-library-panels/index.md +++ b/docs/sources/dashboards/build-dashboards/manage-library-panels/index.md @@ -37,15 +37,20 @@ You can control permissions for library panels using [role-based access control ## Create a library panel -When you create a library panel, the panel on the source dashboard is converted to a library panel as well. You need to save the original dashboard once a panel is converted. +Library panels can be reused in different dashboards throughout Grafana. When you create a library panel, the panel on the source dashboard is converted to a library panel as well. You need to save the original dashboard once a panel is converted. -1. Click **Edit** in the top-right corner of the dashboard. -1. On the panel you want to update, hover over any part of the panel to display the menu icon on the top-right corner. -1. Click the menu icon and select **More > Create library panel**. -1. In **Library panel name**, enter the name. -1. In **Save in folder**, select the folder to save the library panel. -1. Click **Create library panel**. -1. Click **Save dashboard** and **Exit edit**. +To create a library panel, follow these steps: + +1. In the top-right corner of the dashboard, click **Edit**. +1. Hover over any part of the panel you want to share to display the actions menu on the top right corner. +1. Click **More > New library panel**. +1. In the **Library panel name** field, enter the name. +1. In the **Save in folder** drop-down list, select the folder in which to save the library panel. By default, the root level is selected. +1. Click **Create library panel** to save your changes. +1. Click **Save dashboard**. +1. (Optional) Enter a description of the changes you've made. +1. Click **Save**. +1. Click **Exit edit**. Once created, you can modify the library panel using any dashboard on which it appears. After you save the changes, all instances of the library panel reflect these modifications. @@ -53,11 +58,13 @@ Once created, you can modify the library panel using any dashboard on which it a Add a Grafana library panel to a dashboard when you want to provide visualizations to other dashboard users. +To add a library panel, follow these steps: + 1. Click **Dashboards** in the main menu. -1. Click **New** and select **New Dashboard** in the dropdown. +1. Click **New** and select **New Dashboard** in the drop-down list. 1. On the empty dashboard, click **+ Add library panel**. - You'll see a list of your library panels. + The **Add panel from panel library** drawer opens. 1. Filter the list or search to find the panel you want to add. 1. Click a panel to add it to the dashboard. @@ -69,6 +76,8 @@ Add a Grafana library panel to a dashboard when you want to provide visualizatio Unlink a library panel when you want to make a change to the panel and not affect other instances of the library panel. +To unlink a library panel, follow these steps: + 1. Click **Dashboards** in the main menu. 1. Click **Library panels**. 1. Select a library panel that is being used in dashboards. @@ -107,8 +116,9 @@ Alternatively, if you know where the library panel that you want to replace is b You can view a list of available library panels and see where those panels are being used. -1. Click **Dashboards** in the main menu. -1. Click **Library panels**. +To view and manage library panels, follow these steps: + +1. Click **Dashboards > Library panels** in the main menu. You can see a list of previously defined library panels. {{< figure src="/media/docs/grafana/panels-visualizations/screenshot-library-panel-list-9-5.png" class="docs-image--no-shadow" max-width= "900px" alt="Library panels page with list of library panels" >}} @@ -123,8 +133,8 @@ You can view a list of available library panels and see where those panels are b ## Delete a library panel -Delete a library panel when you no longer need it. +To delete a library panel that you no longer need, follow these steps: -1. Click **Dashboards** in the main menu. -1. Click **Library panels**. +1. Click **Dashboards > Library panels** in the main menu. 1. Click the delete icon next to the library panel name. +1. Click **Delete**. diff --git a/docs/sources/dashboards/create-manage-playlists/index.md b/docs/sources/dashboards/create-manage-playlists/index.md index 07e51ee44b7..cae7a433784 100644 --- a/docs/sources/dashboards/create-manage-playlists/index.md +++ b/docs/sources/dashboards/create-manage-playlists/index.md @@ -15,7 +15,7 @@ labels: menuTitle: Manage playlists title: Manage playlists description: Create and manage dashboard playlists -weight: 9 +weight: 500 --- # Manage playlists diff --git a/docs/sources/dashboards/create-reports/index.md b/docs/sources/dashboards/create-reports/index.md index ae06d3becc9..5ce55c6b50e 100644 --- a/docs/sources/dashboards/create-reports/index.md +++ b/docs/sources/dashboards/create-reports/index.md @@ -17,7 +17,7 @@ labels: menuTitle: Reporting title: Create and manage reports description: Generate and share PDF reports from your Grafana dashboards -weight: 85 +weight: 600 refs: repeat-panels-or-rows: - pattern: /docs/grafana/ diff --git a/docs/sources/dashboards/dashboard-public/index.md b/docs/sources/dashboards/dashboard-public/index.md deleted file mode 100644 index d6cbde2c78a..00000000000 --- a/docs/sources/dashboards/dashboard-public/index.md +++ /dev/null @@ -1,273 +0,0 @@ ---- -labels: - products: - - cloud - - enterprise - - oss -title: Public dashboards -description: Make your Grafana dashboards public and share them with anyone -weight: 8 -refs: - dashboard-sharing: - - pattern: /docs/grafana/ - destination: /docs/grafana//dashboards/share-dashboards-panels/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/dashboards/share-dashboards-panels/ - custom-branding: - - pattern: /docs/grafana/ - destination: /docs/grafana//setup-grafana/configure-grafana/configure-custom-branding/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//setup-grafana/configure-grafana/configure-custom-branding/ - dashboard-insights-documentation: - - pattern: /docs/grafana/ - destination: /docs/grafana//dashboards/assess-dashboard-usage/#dashboard-insights - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana-cloud/visualizations/dashboards/assess-dashboard-usage/ - caching: - - pattern: /docs/grafana/ - destination: /docs/grafana//administration/data-source-management/#query-and-resource-caching - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//administration/data-source-management/#query-and-resource-caching - grafana-enterprise: - - pattern: /docs/grafana/ - destination: /docs/grafana//introduction/grafana-enterprise/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//introduction/grafana-enterprise/ ---- - -# Public dashboards - -> **Warning:** Making your dashboard public could result in a large number of queries to the data sources used by your dashboard. -> This can be mitigated by utilizing the enterprise [caching](ref:caching) and/or rate limiting features. - -Public dashboards allow you to share your Grafana dashboard with anyone. This is useful when you want to make your dashboard available to the world without requiring access to your Grafana organization. This differs from [dashboard sharing](ref:dashboard-sharing), which either requires recipients to be users in the same Grafana organization or provides limited information, as with a snapshot. - -You can see a list of all your public dashboards in one place by navigating to **Dashboards > Public dashboards**. For each dashboard in the list, the page displays the status, a link to view the dashboard, a link to the public dashboard configuration, and the option to revoke the public URL. - -## Security implications of making your dashboard public - -- Anyone with the URL can access the dashboard. -- Public dashboards are read-only. -- Arbitrary queries **cannot** be run against your data sources through public dashboards. Public dashboards can only execute the - queries stored on the original dashboard. - -## Make a dashboard public - -1. Click **Share** in the top-right corner of the dashboard. -1. Click the **Public dashboard** tab. -1. Acknowledge the implications of making the dashboard public by selecting all the checkboxes. -1. Click **Generate public URL** to make the dashboard public and make your link live. -1. Copy the public dashboard link if you'd like to share it. You can always come back later for it. - -Once you've made the dashboard public, a **Public** tag is displayed in the header of the dashboard. - -## Pause access - -1. Click **Share** in the top-right corner of the dashboard. -1. Click the **Public dashboard** tab. -1. Enable the **Pause sharing dashboard** toggle. - -The dashboard is no longer accessible, even with the link, until you make it shareable again. - -## Revoke access - -1. Click **Share** in the top-right corner of the dashboard. -1. Click the **Public dashboard** tab. -1. Click **Revoke public URL** to delete the public dashboard. - -The link no longer works. You must create a new public URL, as in [Make a dashboard public](#make-a-dashboard-public). - -## Email sharing - -{{% admonition type="note" %}} - -Available in [private preview](/docs/release-life-cycle/) in [Grafana Cloud](/docs/grafana-cloud). This feature will have a cost by active users after being promoted into general availability. - -Please contact support to have the feature enabled. - -{{% /admonition %}} - -Email sharing allows you to share your public dashboard with only specific people by email, instead of having it accessible to anyone with the URL. When you use email sharing, recipients receive a one-time use link that's valid for **one hour**. Once the link is used, the viewer has access to the public dashboard for **30 days**. - -### Invite a viewer - -1. Click **Share** in the top-right corner of the dashboard. -1. Click the **Public dashboard** tab. -1. Acknowledge the implications of making the dashboard public by selecting all the checkboxes. -1. Click **Generate public URL** to make the dashboard public and make your link live. -1. Under Can view dashboard, click **Only specified people**. -1. Enter the email you want to share the public dashboard with. -1. Click **Invite**. -1. The recipient will receive an email with a one-time use link. - -### Viewers requesting access - -If a viewer without access tries to navigate to the public dashboard, they'll be asked to request access by providing their email. They will receive an email with a new one-time use link if the email they provided has already been invited to view the public dashboard and has not been revoked. - -If the viewer doesn't have an invitation or it's been revoked, you won't be notified and no link is sent. - -### Revoke access for a viewer - -1. Click **Share** in the top-right corner of the dashboard. -1. Click the **Public dashboard** tab. -1. Click **Revoke** on the viewer you'd like to revoke access for. - -Immediately, the viewer no longer has access to the public dashboard, nor can they use any existing one-time use links they may have. - -### Reinvite a viewer - -1. Click **Share** in the top-right corner of the dashboard. -1. Click the **Public dashboard** tab. -1. Click **Resend** on the viewer you'd like to re-share the public dashboard with. - -The viewer will receive an email with a new one-time use link. This will invalidate all previously issued links for that viewer. - -### View public dashboard users - -To see a list of users who have accessed your dashboard by way of email sharing, take the following steps: - -1. In the main sidebar navigation, click **Administration**. -1. Click **Users**. -1. Click the **Public dashboard users** tab. - -From here, you can see the earliest time a user has been active in a dashboard, which public dashboards they have access to, and their role. - -### Access limitations - -One-time use links use browser cookies, so when a viewer is granted access through one of these links, they will only have access on the browser they used to claim the link. - -A single viewer cannot generate multiple valid one-time use links. When a new one-time use link is issued for a viewer, all previous ones are invalidated. - -If a Grafana user has read access to the parent dashboard, they can view the public dashboard without needing to have access granted. - -## Assess public dashboard usage - -> **Note:** Available in [Grafana Enterprise](ref:grafana-enterprise) and [Grafana Cloud](/docs/grafana-cloud). - -You can check usage analytics about your public dashboard by clicking the insights icon in the dashboard header: - -{{< figure src="/media/docs/grafana/dashboards/screenshot-dashboard-insights-11.2.png" max-width="400px" class="docs-image--no-shadow" alt="Dashboard insights icon" >}} - -Learn more about the kind of information provided in the [dashboard insights documentation](ref:dashboard-insights-documentation). - -## Supported data sources - -Public dashboards _should_ work with any data source that has the properties `backend` and `alerting` both set to true in its `plugin.json`. However, this can't always be -guaranteed because plugin developers can override this functionality. The following lists include data sources confirmed to work with public dashboards and data sources that should work, but have not been confirmed as compatible. - -### Confirmed: - - - - - - -
-
    -
  • ClickHouse
  • -
  • CloudWatch
  • -
  • Elasticsearch
  • -
  • Infinity
  • -
  • InfluxDB
  • -
  • Loki
  • -
  • Microsoft SQL Server
  • -
-
-
    -
  • MongoDB
  • -
  • MySQL
  • -
  • Oracle Database
  • -
  • PostgreSQL
  • -
  • Prometheus
  • -
  • Redis
  • -
  • SQLite
  • -
-
- -### Unsupported: - - - - - -
-
    -
  • Graphite
  • -
-
- -### Unconfirmed: - - - - - - - -
-
    -
  • Altinity plugin for ClickHouse
  • -
  • Amazon Athena
  • -
  • Amazon Redshift
  • -
  • Amazon Timestream
  • -
  • Apache Cassandra
  • -
  • AppDynamics
  • -
  • Azure Data Explorer Datasource
  • -
  • Azure Monitor
  • -
  • CSV
  • -
  • DB2 Datasource
  • -
  • Databricks
  • -
  • Datadog
  • -
  • Dataset
  • -
  • Druid
  • -
-
-
    -
  • Dynatrace
  • -
  • GitHub
  • -
  • Google BigQuery
  • -
  • Grafana for YNAB
  • -
  • Honeycomb
  • -
  • Jira
  • -
  • Mock
  • -
  • Neo4j Datasource
  • -
  • New Relic
  • -
  • OPC UA (Unified Architecture)
  • -
  • Open Distro for Elasticsearch
  • -
  • OpenSearch
  • -
  • OpenTSDB
  • -
-
-
    -
  • Orbit
  • -
  • SAP HANA®
  • -
  • Salesforce
  • -
  • Sentry
  • -
  • ServiceNow
  • -
  • Snowflake
  • -
  • Splunk
  • -
  • Splunk Infrastructure Monitoring
  • -
  • Sqlyze data source
  • -
  • TDengine
  • -
  • Vertica
  • -
  • Wavefront
  • -
  • X-Ray
  • -
  • kdb+
  • -
  • simple grpc data source
  • -
-
- -## Limitations - -- Panels that use frontend data sources will fail to fetch data. -- Template variables are not supported. -- Exemplars will be omitted from the panel. -- Only annotations that query the `-- Grafana --` data source are supported. -- Organization annotations are not supported. -- Grafana Live and real-time event streams are not supported. -- Library panels are not supported. -- Data sources using Reverse Proxy functionality are not supported. - -## Custom branding - -If you're a Grafana Enterprise customer, you can use custom branding to change the appearance of a public dashboard footer. For more information, refer to [Custom branding](ref:custom-branding). diff --git a/docs/sources/dashboards/manage-dashboards/index.md b/docs/sources/dashboards/manage-dashboards/index.md index 6853bcb02a7..2cb660a2642 100644 --- a/docs/sources/dashboards/manage-dashboards/index.md +++ b/docs/sources/dashboards/manage-dashboards/index.md @@ -19,7 +19,7 @@ labels: menuTitle: Manage dashboards title: Manage dashboards description: Learn about dashboard management and generative AI features for dashboards -weight: 8 +weight: 300 refs: build-dashboards: - pattern: /docs/grafana/ diff --git a/docs/sources/dashboards/search-dashboards/index.md b/docs/sources/dashboards/search-dashboards/index.md new file mode 100644 index 00000000000..f1f78724204 --- /dev/null +++ b/docs/sources/dashboards/search-dashboards/index.md @@ -0,0 +1,125 @@ +--- +description: Learn how to search for Grafana dashboards and folders +keywords: + - search + - dashboard + - folder +labels: + products: + - cloud + - enterprise + - oss +menutitle: Search dashboards +title: Search dashboards and folders +weight: 400 +refs: + service-accounts: + - pattern: /docs/grafana/ + destination: /docs/grafana//administration/service-accounts/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/account-management/authentication-and-permissions/service-accounts/ + config-file: + - pattern: /docs/grafana/ + destination: /docs/grafana//setup-grafana/configure-grafana/#configuration-file-location + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//setup-grafana/configure-grafana/#configuration-file-location + feature-toggles: + - pattern: /docs/grafana/ + destination: /docs/grafana//setup-grafana/configure-grafana/#feature_toggles + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//setup-grafana/configure-grafana/#feature_toggles +--- + +# Search dashboards and folders + +You can search for dashboards and dashboard folders by name. + +When you search for dashboards, you can also do it by panel title. Whether you search by name or panel title, the system returns all dashboards available within the Grafana instance, even if you do not have permission to view the contents of the dashboard. + +## Search by name + +Begin typing any part of the dashboard or folder name in the search bar. The search returns results for any partial string match in real-time, as you type. + +The search is: + +- Real-time +- _Not_ case sensitive +- Functional across stored _and_ file based dashboards and folders. + +{{% admonition type="note" %}} +You can use your keyboard arrow keys to navigate the results and press `Enter` to open the selected dashboard or folder. +{{% /admonition %}} + +The following images show: + +Searching by dashboard name from the **Dashboards** page. + +{{< figure src="/media/docs/grafana/dashboards/search-for-dashboard.png" width="700px" >}} + +Searching by folder name from the **Dashboards** page. + +{{< figure src="/media/docs/grafana/dashboards/search-folder.png" width="700px" >}} + +Searching by dashboard name inside a folder. + +{{< figure src="/media/docs/grafana/dashboards/search-in-folder.png" width="700px" >}} + +{{% admonition type="note" %}} +When you search within a folder, its subfolders are not part of the results returned. You need to be on the **Dashboards** page (or the root level) to search for subfolders by name. +{{% /admonition %}} + +## Search dashboards using panel title + +You can search for a dashboard by the title of a panel that appears in a dashboard. +If a panel's title matches your search query, the dashboard appears in the search results. + +This feature is available by default in Grafana Cloud and in Grafana OSS v9.1 and higher, you access this feature by enabling the `panelTitleSearch` feature toggle. +For more information about enabling panel title search, refer to [Enable the panelTitleSearch feature toggle.](#enable-the-paneltitlesearch-feature-toggle) + +The following image shows the search results when you search using panel title. + +{{< figure src="/static/img/docs/v91/dashboard-features/search-by-panel-title.png" width="700px" >}} + +### Enable the panelTitleSearch feature toggle + +Complete the following steps to enable the `panelTitleSearch` feature toggle. + +**Before you begin:** + +- If you are running Grafana Enterprise with RBAC, enable [service accounts](ref:service-accounts). + +**To enable the panelTitleSearch feature toggle:** + +1. Open the Grafana [configuration file](ref:config-file). + +1. Locate the [feature_toggles](ref:feature-toggles) section. + +1. Add the following parameter to the `feature_toggles` section: + + ``` + [feature_toggles] + # enable features, separated by spaces + enable = panelTitleSearch + ``` + +1. Save your changes and restart the Grafana server. + +## Filter dashboard search results by tag(s) + +Tags are a great way to organize your dashboards, especially as the number of dashboards grow. You can add and manage tags in dashboard `Settings`. + +When you select multiple tags, Grafana shows dashboards that include all selected tags. + +To filter dashboard search result by a tag, complete one of the following steps: + +- To filter dashboard search results by tag, click a tag that appears in the right column of the search results. + + You can continue filtering by clicking additional tags. + +- To see a list of all available tags, click the **Filter by tags** dropdown menu and select a tag. + + All tags will be shown, and when you select a tag, the dashboard search will be instantly filtered. + +{{% admonition type="note" %}} +When using only a keyboard, press the `tab` key and navigate to the **Filter by tag** drop-down menu, press the down arrow key `▼` to activate the menu and locate a tag, and press `Enter` to select the tag. +{{% /admonition %}} diff --git a/docs/sources/dashboards/share-dashboards-panels/_index.md b/docs/sources/dashboards/share-dashboards-panels/_index.md new file mode 100644 index 00000000000..26b269b1231 --- /dev/null +++ b/docs/sources/dashboards/share-dashboards-panels/_index.md @@ -0,0 +1,348 @@ +--- +aliases: + - ../administration/reports/ + - ../enterprise/export-pdf/ + - ../enterprise/reporting/ + - ../reference/share_dashboard/ + - ../reference/share_panel/ + - ../share-dashboards-panels/ + - ../sharing/ + - ../sharing/playlists/ + - ../sharing/share-dashboard/ + - ../sharing/share-panel/ + - ./ + - reporting/ + - share-dashboard/ +keywords: + - grafana + - dashboard + - documentation + - share + - panel + - reporting + - export + - pdf +labels: + products: + - cloud + - enterprise + - oss +menuTitle: Sharing +title: Share dashboards and panels +description: Share Grafana dashboards and panels within your organization and publicly +weight: 650 +refs: + image-rendering: + - pattern: /docs/grafana/ + destination: /docs/grafana//setup-grafana/image-rendering/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//setup-grafana/image-rendering/ + grafana-enterprise: + - pattern: /docs/grafana/ + destination: /docs/grafana//introduction/grafana-enterprise/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//introduction/grafana-enterprise/ + shared-dashboards: + - pattern: /docs/grafana/ + destination: /docs/grafana//dashboards/share-dashboards-panels/shared-dashboards/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana/grafana-cloud/visualizations/dashboards/share-dashboards-panels/shared-dashboards/ + configure-report: + - pattern: /docs/grafana/ + destination: /docs/grafana//dashboards/create-reports/#create-or-update-a-report + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/visualizations/dashboards/create-reports/#create-or-update-a-report + image-rendering-config: + - pattern: /docs/grafana/ + destination: /docs/grafana//setup-grafana/image-rendering/#configuration + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//setup-grafana/image-rendering/#configuration +--- + +# Share dashboards and panels + +Grafana enables you to share dashboards and panels with other users within your organization and in certain situations, publicly on the web. You can share using: + +- Direct links with users in and outside of your organization +- Snapshots +- Embeds +- PDFs +- JSON files +- Reports +- Library panels + +You must have an authorized viewer permission to see an image rendered by a direct link. + +The same permission is also required to view embedded links unless you have anonymous access permission enabled for your Grafana instance. + +{{< admonition type="note" >}} +As of Grafana 8.0, anonymous access permission is not available in Grafana Cloud. +{{< /admonition >}} + +## Share dashboards {#share-a-dashboard} + +You can share dashboards in the following ways: + +- [Internally with a link](#share-an-internal-link) +- [Externally with anyone or specific people](#share-an-external-link) +- [As a report](#schedule-a-report) +- [As a snapshot](#share-a-snapshot) +- [As a PDF export](#export-a-dashboard-as-pdf) +- [As a JSON file export](#export-a-dashboard-as-json) + +When you share a dashboard externally as a link or by email, those dashboards are included in a list of your shared dashboards. To view the list and manage these dashboards, navigate to **Dashboards > Shared dashboards**. + +{{< admonition type="note" >}} +If you change a dashboard, ensure that you save the changes before sharing. +{{< /admonition >}} + +### Share an internal link + +To share a customized, direct link to your dashboard within your organization, follow these steps: + +1. Click **Dashboards** in the main menu. +1. Click the dashboard you want to share. +1. Click the **Share** drop-down list in the top-right corner and select **Share internally**. +1. (Optional) In the **Share internally** drawer that opens, set the following options: + - **Lock time range** - Change the current relative time range to an absolute time range. This option is enabled by default. + - **Shorten link** - Shorten the dashboard link. This option is enabled by default. +1. Select the theme for the dashboard. Choose from **Current**, **Dark**, or **Light**. +1. Click **Copy link**. +1. Send the copied link to a Grafana user with authorization to view the link. +1. Click the **X** at the top-right corner to close the share drawer. + +#### Quick-share an internal link + +Once you've customized an internal link, you can share it quickly by following these steps: + +1. Click **Dashboards** in the main menu. +1. Click the dashboard you want to share. +1. Click the **Share** button, not the drop-down list icon, to copy a shortened link. + +This link has any customizations, like time range locking or theme, that you've previously set. These are stored in the browser scope. + +### Share an external link + +Externally shared dashboards allow you to share your Grafana dashboard with anyone. This is useful when you want to make your dashboard available to the world without requiring access to your Grafana organization. + +Learn how to configure and manage externally shared dashboards in [Externally shared dashboards](ref:shared-dashboards). + +### Schedule a report + +{{< admonition type="note" >}} +This feature is only available in Grafana Enterprise. +{{< /admonition >}} + +To share your dashboard as a report, follow these steps: + +1. Click **Dashboards** in the main menu. +1. Click the dashboard you want to share. +1. Click the **Share** drop-down list in the top-right corner and select **Schedule a report**. +1. [Configure the report](ref:configure-report). +1. Depending on your schedule settings, you'll have different options at this step. Click either **Schedule send** or **Send now**. + +You can also save the report as a draft. + +To manage your reports, navigate to **Dashboards > Reporting > Reports**. + +### Share a snapshot + +A dashboard snapshot publicly shares a dashboard while removing sensitive data such as queries and panel links, leaving only visible metrics and series names. Anyone with the link can access the snapshot. + +You can publish snapshots to your local instance or to [snapshots.raintank.io](http://snapshots.raintank.io). The latter is a free service provided by Grafana Labs that enables you to publish dashboard snapshots to an external Grafana instance. Anyone with the link can view it. You can set an expiration time if you want the snapshot removed after a certain time period. + +{{< admonition type=note >}} +The snapshots.raintank.io option is disabled by default in Grafana Cloud. You can update [your config file](https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/#external_enabled) to enable this functionality. +{{< /admonition >}} + +To see the other snapshots shared from your organization, navigate to **Dashboards > Snapshots** in the main menu. + +To share your dashboard with anyone as a snapshot, follow these steps: + +1. Click **Dashboards** in the main menu. +1. Click the dashboard you want to share. +1. Click the **Share** drop-down list in the top-right corner and select **Share snapshot**. +1. In the **Share snapshot** drawer that opens, enter a descriptive title for the snapshot in the **Snapshot name** field. +1. Select one of the following expiration options for the snapshot: + - **1 Hour** + - **1 Day** + - **1 Week** + - **Never** +1. Click **Publish snapshot** or **Publish to snapshots.raintank.io**. + + Grafana generates the link of the snapshot. Note that you can't publish dashboard snapshots containing custom panels to snapshot.raintank.io. + +1. Click **Copy link**, and share it either within your organization or publicly on the web. +1. Click the **X** at the top-right corner to close the share drawer. + +#### Delete a snapshot + +To delete existing snapshots, follow these steps: + +1. Navigate to **Dashboards > Snapshots** in the main menu. +1. To confirm which snapshot you're about to delete, click **View** on the snapshot row. + + The URLs for panel and dashboard snapshots from the same dashboard look similar and viewing them first can help you distinguish them. + +1. Click the red **x** next to the snapshot that you want to delete. + +The snapshot is immediately deleted. You might need to clear your browser cache or use a private or incognito browser to confirm this. + +## Export dashboards + +In addition to sharing dashboards as links, reports, and snapshots, you can export them as PDFs or JSON files. + +### Export a dashboard as PDF + +To export a dashboard in its current state as a PDF, follow these steps: + +1. Click **Dashboards** in the main menu. +1. Open the dashboard you want to export. +1. Click the **Export** drop-down in the top-right corner and select **Export as PDF**. +1. In the **Export dashboard PDF** drawer that opens, select either **Landscape** or **Portrait** for the PDF orientation. +1. Select either **Grid** or **Simple** for the PDF layout. +1. Set the **Zoom** level; zoom in to enlarge text, or zoom out to see more data (like table columns) per panel. +1. Click **Generate PDF**. + + The PDF opens in another tab where you can download it. + +1. Click the **X** at the top-right corner to close the share drawer. + +### Export a dashboard as JSON + +Export a Grafana JSON file that contains everything you need, including layout, variables, styles, data sources, queries, and so on, so that you can later import the dashboard. To export a JSON file, follow these steps: + +1. Click **Dashboards** in the main menu. +1. Open the dashboard you want to export. +1. Click the **Export** drop-down list in the top-right corner and select **Export as JSON**. + + The **Export dashboard JSON** drawer opens. + +1. Toggle the **Export the dashboard to use in another instance** switch to generate the JSON with a different data source UID. +1. Click **Download file** or **Copy to clipboard**. +1. Click the **X** at the top-right corner to close the share drawer. + +## Share panels {#share-a-panel} + +You can share a panels in the following ways: + +- [Internally with a link](#share-an-internal-link) +- [As an embed](#share-an-embed) +- [As a snapshot](#panel-snapshot) + +{{< admonition type="note" >}} +If you change a panel, ensure that you save the changes before sharing. +{{< /admonition >}} + +### Share an internal link + +To share a personalized, direct link to your panel within your organization, follow these steps: + +1. Hover over any part of the panel you want to share to display the actions menu on the top right corner. +1. Click the menu and select **Share link**. +1. (Optional) In the **Link settings** drawer that opens, set the following options: + - **Lock time range** - Change the current relative time range to an absolute time range. This option is enabled by default. + - **Shorten link** - Shorten the panel link. This option is disabled by default. +1. Select the theme for the dashboard. Choose from **Current**, **Dark**, or **Light**. +1. Do one or both of the following: + - Click **Copy link**. + - Click **Render image**, which [renders the panel as a PNG image](ref:image-rendering). +1. Send the copied link or image URL to a Grafana user with authorization to view it. +1. Click the **X** at the top-right corner to close the share drawer. + +#### Query string parameters for server-side rendered images + +When you click **Render image** in the panel link settings, Grafana generates a PNG image of the panel with the following default parameters: + +| Parameter | Description | +| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| width | Width in pixels. Default is 800. | +| height | Height in pixels. Default is 400. | +| tz | Timezone in the format `UTC%2BHH%3AMM` where HH and MM are offset in hours and minutes after UTC. | +| timeout | Number of seconds. The timeout can be increased if the query for the panel needs more than the default 30 seconds. | +| scale | Numeric value to configure device scale factor. Default is 1. Use a higher value to produce more detailed images (higher DPI). Supported in Grafana v7.0+. | + +You can also update these parameters in the [image rendering configuration](ref:image-rendering-config). + +The following example shows a link to a server-side rendered PNG: + +```bash +https://play.grafana.org/render/d-solo/ktMs4D6Mk?from=2024-09-03T11:55:44.442Z&to=2024-09-03T17:55:44.442Z&panelId=panel-13&__feature.dashboardSceneSolo&width=1000&height=500&tz=UTC +``` + +### Share an embed + +You can share a panel by embedding it on another website using an iframe. Users must be signed into Grafana to view the panel. + +{{< admonition type="note" >}} +As of Grafana 8.0, anonymous access permission is no longer available for Grafana Cloud. +{{< /admonition >}} + +To create a panel that can be embedded, follow these steps: + +1. Hover over any part of the panel you want to share to display the actions menu on the top-right corner. +1. Click the menu and select **Share embed**. + + The **Share embed** drawer opens. + +1. (Optional) Toggle the **Lock time range** switch to set whether the panel uses the current relative time range or an absolute time range. This option is enabled by default. +1. Select the theme for the dashboard. Choose from **Current**, **Dark**, or **Light**. +1. (Optional) Make any changes to the HTML that you need. +1. Click **Copy to clipboard**. +1. Paste the HTML code into your website code. +1. Click the **X** at the top-right corner to close the share drawer. + +Here's an example of what the HTML code might look like: + +```html + +``` + +The result is an interactive Grafana visualization embedded in an iframe. + +### Share a snapshot {#panel-snapshot} + +A panel snapshot shares an interactive panel publicly while removing sensitive data such as queries and panel links, leaving only visible metrics and series names. Anyone with the link can access the snapshot. + +You can publish snapshots to your local instance or to [snapshots.raintank.io](http://snapshots.raintank.io). The latter is a free service provided by Grafana Labs that enables you to publish dashboard snapshots to an external Grafana instance. Anyone with the link can view it. You can set an expiration time if you want the snapshot removed after a certain time period. + +{{< admonition type=note >}} +The snapshots.raintank.io option is disabled by default in Grafana Cloud. You can update [your config file](https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/#external_enabled) to enable this functionality. +{{< /admonition >}} + +To see the other snapshots shared from your organization, navigate to **Dashboards > Snapshots** in the main menu. + +To share your panel with anyone as a snapshot, follow these steps: + +1. Hover over any part of the panel you want to share to display the actions menu on the top-right corner. +1. Click the menu and select **Share snapshot**. +1. In the **Share snapshot** drawer that opens, enter a descriptive title for the snapshot in the **Snapshot name** field. +1. Select one of the following expiration options for the snapshot: + - **1 Hour** + - **1 Day** + - **1 Week** + - **Never** +1. Click **Publish snapshot** or **Publish to snapshots.raintank.io**. + + Grafana generates the link of the snapshot. Note that you can't publish snapshots that include custom panels to snapshot.raintank.io. + +1. Click **Copy link**, and share it either within your organization or publicly on the web. +1. Click the **X** at the top-right corner to close the share drawer. + +#### Delete a snapshot + +To delete existing snapshots, follow these steps: + +1. Navigate to **Dashboards > Snapshots** in the main menu. +1. To confirm which snapshot you're about to delete, click **View** on the snapshot row. + + The URLs for panel and dashboard snapshots from the same dashboard look similar and viewing them first can help you distinguish them. + +1. Click the red **x** next to the snapshot URL that you want to delete. + +The snapshot is immediately deleted. You may need to clear your browser cache or use a private or incognito browser to confirm this. diff --git a/docs/sources/dashboards/share-dashboards-panels/index.md b/docs/sources/dashboards/share-dashboards-panels/index.md deleted file mode 100644 index 01ef05c1923..00000000000 --- a/docs/sources/dashboards/share-dashboards-panels/index.md +++ /dev/null @@ -1,250 +0,0 @@ ---- -aliases: - - ../administration/reports/ - - ../enterprise/export-pdf/ - - ../enterprise/reporting/ - - ../reference/share_dashboard/ - - ../reference/share_panel/ - - ../share-dashboards-panels/ - - ../sharing/ - - ../sharing/playlists/ - - ../sharing/share-dashboard/ - - ../sharing/share-panel/ - - ./ - - reporting/ - - share-dashboard/ -keywords: - - grafana - - dashboard - - documentation - - share - - panel - - library panel - - playlist - - reporting - - export - - pdf -labels: - products: - - cloud - - enterprise - - oss -menuTitle: Sharing -title: Share dashboards and panels -description: Share Grafana dashboards and panels within your organization and publicly -weight: 85 -refs: - image-rendering: - - pattern: /docs/grafana/ - destination: /docs/grafana//setup-grafana/image-rendering/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//setup-grafana/image-rendering/ - grafana-enterprise: - - pattern: /docs/grafana/ - destination: /docs/grafana//introduction/grafana-enterprise/ - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//introduction/grafana-enterprise/ ---- - -# Share dashboards and panels - -Grafana enables you to share dashboards and panels with other users within an organization and in certain situations, publicly on the Web. You can share using: - -- A direct link -- A Snapshot -- An embedded link (for panels only) -- An export link (for dashboards only) - -You must have an authorized viewer permission to see an image rendered by a direct link. - -The same permission is also required to view embedded links unless you have anonymous access permission enabled for your Grafana instance. - -{{< admonition type="note" >}} -As of Grafana 8.0, anonymous access permission is not available in Grafana Cloud. -{{< /admonition >}} - -When you share a panel or dashboard as a snapshot, a snapshot (which is a panel or dashboard at the moment you take the snapshot) is publicly available on the web. Anyone with a link to it can access it. Because snapshots do not require any authorization to view, Grafana removes information related to the account it came from, as well as any sensitive data from the snapshot. - -## Share a dashboard - -You can share a dashboard as a direct link or as a snapshot. You can also export a dashboard. - -{{< admonition type="note" >}} -If you change a dashboard, ensure that you save the changes before sharing. -{{< /admonition >}} - -1. Click **Dashboards** in the main menu. -1. Click the dashboard you want to share. -1. Click **Share** in the top-right corner. - - The share dialog opens and shows the Link tab. - -### Share a direct link - -The **Link** tab shows the current time range, template variables, and the default theme. You can also share a shortened URL. - -1. Click **Copy**. - - This action copies the default or the shortened URL to the clipboard. - -1. Send the copied URL to a Grafana user with authorization to view the link. - -### Publish a snapshot - -A dashboard snapshot shares an interactive dashboard publicly. Grafana strips sensitive data such as queries (metric, template and annotation) and panel links, leaving only the visible metric data and series names embedded in the dashboard. Dashboard snapshots can be accessed by anyone with the link. - -You can publish snapshots to your local instance or to [snapshots.raintank.io](http://snapshots.raintank.io). The latter is a free service provided by Grafana Labs that enables you to publish dashboard snapshots to an external Grafana instance. Anyone with the link can view it. You can set an expiration time if you want the snapshot removed after a certain time period. - -1. Click the **Snapshot** tab. -1. Click **Publish to snapshots.raintank.io** or **Publish Snapshot**. - - Grafana generates a link of the snapshot. - -1. Copy the snapshot link, and share it either within your organization or publicly on the web. - -If you created a snapshot by mistake, click **Delete snapshot** in the dialog box to remove the snapshot from your Grafana instance. - -#### Delete a snapshot - -To delete existing snapshots, follow these steps: - -1. Click **Dashboards** in the main menu. -1. Click **Snapshots** to go to the snapshots management page. -1. Click the red **x** next to the snapshot URL that you want to delete. - -The snapshot is immediately deleted. You may need to clear your browser cache or use a private or incognito browser to confirm this. - -### Export a dashboard as JSON - -The dashboard export action creates a Grafana JSON file that contains everything you need, including layout, variables, styles, data sources, queries, and so on, so that you can later import the dashboard. - -1. Click **Dashboards** in the main menu. -1. Open the dashboard you want to export. -1. Click **Share** in the top-right corner. -1. Click **Export**. - - If you're exporting the dashboard to use in another instance, with different data source UIDs, enable the **Export for sharing externally** switch. - -1. Click **Save to file**. - -Grafana downloads a JSON file to your local machine. - -#### Make a dashboard portable - -If you want to export a dashboard for others to use, you can add template variables for things like a metric prefix (use a constant variable) and server name. - -A template variable of the type `Constant` is automatically hidden in the dashboard, and is also added as a required input when the dashboard is imported. - -## Export dashboard as PDF - -You can generate and save PDF files of any dashboard. - -{{< admonition type="note" >}} -Available in [Grafana Enterprise](ref:grafana-enterprise) and [Grafana Cloud](/docs/grafana-cloud/). -{{< /admonition >}} - -1. Click **Dashboards** in the main menu. -1. Click the dashboard you want to share. -1. Click **Share** in the top-right corner. -1. On the **PDF** tab, select a layout option for the exported dashboard: **Portrait** or **Landscape**. -1. Click **Save as PDF** to render the dashboard as a PDF file. - - Grafana opens the PDF file in a new window or browser tab. - -## Share a panel - -You can share a panel as a direct link, as a snapshot, or as an embedded link. You can also create library panels using the **Share** option on any panel. - -1. Hover over any part of the panel to display the actions menu on the top right corner. -1. Click the menu and select **Share**. - - The share dialog opens and shows the **Link** tab. - -### Use direct link - -The **Link** tab shows the current time range, template variables, and the default theme. You can optionally enable a shortened URL to share. - -1. Click **Copy**. - - This action copies the default or the shortened URL to the clipboard. - -1. Send the copied URL to a Grafana user with authorization to view the link. -1. You also optionally click **Direct link rendered image** to share an image of the panel. - -For more information, refer to [Image rendering](ref:image-rendering). - -The following example shows a link to a server-side rendered PNG: - -```bash -https://play.grafana.org/d/000000012/grafana-play-home?orgId=1&from=1568719680173&to=1568726880174&panelId=4&fullscreen -``` - -#### Query string parameters for server-side rendered images - -- **width:** Width in pixels. Default is 800. -- **height:** Height in pixels. Default is 400. -- **tz:** Timezone in the format `UTC%2BHH%3AMM` where HH and MM are offset in hours and minutes after UTC -- **timeout:** Number of seconds. The timeout can be increased if the query for the panel needs more than the default 30 seconds. -- **scale:** Numeric value to configure device scale factor. Default is 1. Use a higher value to produce more detailed images (higher DPI). Supported in Grafana v7.0+. - -### Publish a snapshot - -A panel snapshot shares an interactive panel publicly. Grafana strips sensitive data leaving only the visible metric data and series names embedded in the dashboard. Panel snapshots can be accessed by anyone with the link. - -You can publish snapshots to your local instance or to [snapshots.raintank.io](http://snapshots.raintank.io). The latter is a free service provided by [Grafana Labs](https://grafana.com), that enables you to publish dashboard snapshots to an external Grafana instance. - -{{< admonition type="note" >}} -As of Grafana 11, the option to publish to [snapshots.raintank.io](http://snapshots.raintank.io) is no longer available for Grafana Cloud. -{{< /admonition >}} - -You can optionally set an expiration time if you want the snapshot to be removed after a certain time period. - -1. In the **Share Panel** dialog, click **Snapshot** to go to the tab. -1. Click **Publish to snapshots.raintank.io** or **Publish Snapshot**. - - Grafana generates the link of the snapshot. - -1. Copy the snapshot link, and share it either within your organization or publicly on the web. - -If you created a snapshot by mistake, click **Delete snapshot** in the dialog box to remove the snapshot from your Grafana instance. - -#### Delete a snapshot - -To delete existing snapshots, follow these steps: - -1. Click **Dashboards** in the main menu. -1. Click **Snapshots** to go to the snapshots management page. -1. Click the red **x** next to the snapshot URL that you want to delete. - -The snapshot is immediately deleted. You may need to clear your browser cache or use a private or incognito browser to confirm this. - -### Embed panel - -You can embed a panel using an iframe on another web site. A viewer must be signed into Grafana to view the graph. - -{{< admonition type="note" >}} -As of Grafana 8.0, anonymous access permission is no longer available for Grafana Cloud. -{{< /admonition >}} - -Here is an example of the HTML code: - -```html - -``` - -The result is an interactive Grafana graph embedded in an iframe. - -### Library panel - -To create a library panel from the **Share Panel** dialog: - -1. Click **Library panel**. -1. In **Library panel name**, enter the name. -1. In **Save in folder**, select the folder in which to save the library panel. By default, the root level is selected. -1. Click **Create library panel** to save your changes. -1. Click **Save dashboard**. diff --git a/docs/sources/dashboards/share-dashboards-panels/shared-dashboards/index.md b/docs/sources/dashboards/share-dashboards-panels/shared-dashboards/index.md new file mode 100644 index 00000000000..1c92c0cee32 --- /dev/null +++ b/docs/sources/dashboards/share-dashboards-panels/shared-dashboards/index.md @@ -0,0 +1,331 @@ +--- +aliases: + - ../dashboard-public/ # /docs/grafana/latest/dashboards/dashboard-public/ +labels: + products: + - cloud + - enterprise + - oss +title: Externally shared dashboards +menuTitle: Shared dashboards +description: Make your Grafana dashboards externally shared and share them with anyone +weight: 8 +refs: + dashboard-sharing: + - pattern: /docs/grafana/ + destination: /docs/grafana//dashboards/share-dashboards-panels/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/visualizations/dashboards/share-dashboards-panels/ + custom-branding: + - pattern: /docs/grafana/ + destination: /docs/grafana//setup-grafana/configure-grafana/configure-custom-branding/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//setup-grafana/configure-grafana/configure-custom-branding/ + dashboard-insights-documentation: + - pattern: /docs/grafana/ + destination: /docs/grafana//dashboards/assess-dashboard-usage/#dashboard-insights + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/visualizations/dashboards/assess-dashboard-usage/ + caching: + - pattern: /docs/grafana/ + destination: /docs/grafana//administration/data-source-management/#query-and-resource-caching + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//administration/data-source-management/#query-and-resource-caching + grafana-enterprise: + - pattern: /docs/grafana/ + destination: /docs/grafana//introduction/grafana-enterprise/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//introduction/grafana-enterprise/ +--- + +# Externally shared dashboards + +{{< admonition type="note" >}} +This feature was previously called **Public dashboards**. +{{< /admonition >}} + +Externally shared dashboards allow you to share your Grafana dashboard with anyone. This is useful when you want to make your dashboard available to the world without requiring access to your Grafana organization. + +If you change a dashboard, ensure that you save the changes before sharing. + +{{< admonition type="warning" >}} +Sharing your dashboard externally could result in a large number of queries to the data sources used by your dashboard. +This can be mitigated by using the Enterprise [caching](ref:caching) and/or rate limiting features. +{{< /admonition >}} + +## Shared dashboards list + +You can see a list of all your externally shared dashboards in one place by navigating to **Dashboards > Shared dashboards**. For each dashboard in the list, the page displays: + +- Link to view the externally shared version of the dashboard +- Link to the shared dashboard configuration +- Options to pause or revoke access to the external dashboard + +You can also click the name of the dashboard to navigate to the dashboard internally. + +## Important notes about sharing your dashboard externally + +- Anyone with the URL can access the dashboard. +- Externally shared dashboards are read-only. +- Arbitrary queries **cannot** be run against your data sources through externally shared dashboards. Externally shared dashboards can only execute the queries stored on the original dashboard. + +## Share externally with specific people + +{{< admonition type="note">}} +This feature was previously called **email sharing**. + +Available in [Grafana Enterprise](ref:grafana-enterprise) and [Grafana Cloud](/docs/grafana-cloud). +{{< /admonition >}} + +To share a dashboard with specific external users, you can send them a link by email. Use this option when you only want to share your dashboard with specific people. When you share dashboards by email, recipients receive a one-time use link that's valid for **one hour**. Once the link is used, the viewer has access to the shared dashboard for **30 days**. + +When you share a dashboard with an email link, your organization is billed per user, regardless of how many dashboards are shared. Grafana bills monthly per user until access is revoked. + +To share a dashboard with specific people, follow these steps: + +1. Click **Dashboards** in the main menu. +1. Click the dashboard you want to share. +1. Click the **Share** drop-down list in the top-right corner and select **Share externally**. + + The **Share externally** drawer opens. + +1. In the **Link access** drop-down list, select **Only specific people**. +1. Click the checkbox confirming that you understand payment is required to add users. +1. Click **Accept**. +1. In the **Invite** field, enter the email address of the person you want to invite and click **Invite** and repeat this process for all the people you want to invite. + + You can only invite one person at a time. + +1. (Optional) Set the following options: + - **Enable time range** - Allow people accessing the link to change the time range. This configuration screen shows the default time range of the dashboard. + - **Display annotations** - Allow people accessing the link to view the dashboard annotations. +1. (Optional) Click **Copy external link** and send the copied URL to any external user. +1. Click the **X** at the top-right corner to close the share drawer. + +Once you've shared a dashboard externally, a **Public** label is displayed in the header of the dashboard. + +### Viewers requesting access + +If a viewer without access tries to navigate to the shared dashboard, they'll be asked to request access by providing their email. They'll receive an email with a new one-time use link if the email they provided has already been invited to view the shared dashboard and hasn't been revoked. + +### Revoke access for a viewer + +You can revoke access to the entire dashboard using the steps in [Update access to an external dashboard link](#update-access-to-an-external-dashboard-link), but you can also revoke access to the dashboard for specific people. + +To revoke access for a viewer, follow these steps: + +1. Click **Dashboards** in the main menu. +1. Click the dashboard you want to share. +1. Click the **Share** drop-down list in the top-right corner and select **Share externally**. +1. In the **Share externally** drawer that opens, click the menu icon (three dots) next to the email address of the viewer for whom you'd like to revoke access. +1. Click **Revoke access**. +1. Click the **X** at the top-right corner to close the share drawer. + +The viewer immediately no longer has access to the dashboard, nor can they use any existing one-time use links they may have. + +### Re-invite a viewer + +To re-invite a viewer, follow these steps: + +1. Click **Dashboards** in the main menu. +1. Click the dashboard you want to share. +1. Click the **Share** drop-down list in the top-right corner and select **Share externally**. +1. In the **Share externally** drawer that opens, click the menu icon (three dots) next to the email address of the viewer you'd like to invite again. +1. Click **Resend invite**. +1. Click the **X** at the top-right corner to close the share drawer. + +The viewer receives an email with a new one-time use link. This invalidates all previously issued links for that viewer. + +### View shared dashboard users + +To see a list of users who have accessed your externally shared dashboard by way of an emailed link, follow these steps: + +1. Click **Administration** in in the main menu. +1. Select **Users and access** > **Users**. +1. On the **Users** page, click the **Shared dashboard users** tab. + +On this screen, you can see: + +- The earliest time a user has been active in a dashboard +- When they last accessed a shared dashboard +- The dashboards to they have access +- Their role + +You can also revoke a user's access to all shared dashboards on from this tab. + +### Access limitations + +One-time use links use browser cookies, so when a viewer is granted access through one of these links, they'll only have access on the browser they used to claim the link. + +A single viewer can't generate multiple valid one-time use links for a dashboard. When a new one-time use link is issued for a viewer, all previous ones are invalidated. + +If a Grafana user has read access to the parent dashboard, they can view the externally shared dashboard without needing to have access granted. + +## Share externally to anyone with a link + +To share your dashboard so that anyone with the link can access it, follow these steps: + +1. Click **Dashboards** in the main menu. +1. Click the dashboard you want to share. +1. Click the **Share** drop-down list in the top-right corner and select **Share externally**. + + The **Share externally** drawer opens. + +1. In the **Link access** drop-down list, select **Anyone with the link**. +1. Click the checkbox confirming that you understand the entire dashboard will be public. +1. Click **Accept**. +1. (Optional) Set the following options: + - **Enable time range** - Allow people accessing the link to change the time range. This configuration screen shows the default time range of the dashboard. + - **Display annotations** - Allow people accessing the link to view the dashboard annotations. +1. Click the **X** at the top-right corner to close the share drawer. + +Now anyone with the link can access the dashboard until you pause or revoke access to it. + +Once you've shared a dashboard externally, a **Public** label is displayed in the header of the dashboard. + +### Update access to an external dashboard link + +You can update the access to externally shared dashboard links by following these steps: + +1. Click **Dashboards** in the main menu. +1. Click the dashboard you want to share. +1. Click the **Share** drop-down list in the top-right corner and select **Share externally**. +1. In the **Share externally** drawer that opens, do one of the following: + - Click **Pause access** so that people can't access the dashboard, but the link is maintained. + - Click **Resume access** so that people can access the dashboard again. + - Click **Revoke access** so that people can't access the dashboard unless a new external link is generated. Confirm that you want to revoke the link. +1. Click the **X** at the top-right corner to close the share drawer. + +## Assess shared dashboard usage + +{{< admonition type="note" >}} +Available in [Grafana Enterprise](ref:grafana-enterprise) and [Grafana Cloud](/docs/grafana-cloud). +{{< /admonition >}} + +You can check usage analytics about your externally shared dashboard by clicking the insights icon in the dashboard header: + +![Dashboard insights icon](/media/docs/grafana/dashboards/screenshot-dashboard-insights-icon-11.2.png) + +Learn more about the kind of information provided in the [dashboard insights documentation](ref:dashboard-insights-documentation). + +## Supported data sources + +Externally shared dashboards _should_ work with any data source that has the properties `backend` and `alerting` both set to true in its `plugin.json`. However, this can't always be +guaranteed because plugin developers can override this functionality. The following lists include data sources confirmed to work with externally shared dashboards and data sources that should work, but have not been confirmed as compatible. + +### Confirmed: + + + + + + +
+
    +
  • ClickHouse
  • +
  • CloudWatch
  • +
  • Elasticsearch
  • +
  • Infinity
  • +
  • InfluxDB
  • +
  • Loki
  • +
  • Microsoft SQL Server
  • +
+
+
    +
  • MongoDB
  • +
  • MySQL
  • +
  • Oracle Database
  • +
  • PostgreSQL
  • +
  • Prometheus
  • +
  • Redis
  • +
  • SQLite
  • +
+
+ +### Unsupported: + + + + + +
+
    +
  • Graphite
  • +
+
+ +### Unconfirmed: + + + + + + + +
+
    +
  • Altinity plugin for ClickHouse
  • +
  • Amazon Athena
  • +
  • Amazon Redshift
  • +
  • Amazon Timestream
  • +
  • Apache Cassandra
  • +
  • AppDynamics
  • +
  • Azure Data Explorer Datasource
  • +
  • Azure Monitor
  • +
  • CSV
  • +
  • DB2 Datasource
  • +
  • Databricks
  • +
  • Datadog
  • +
  • Dataset
  • +
  • Druid
  • +
+
+
    +
  • Dynatrace
  • +
  • GitHub
  • +
  • Google BigQuery
  • +
  • Grafana for YNAB
  • +
  • Honeycomb
  • +
  • Jira
  • +
  • Mock
  • +
  • Neo4j Datasource
  • +
  • New Relic
  • +
  • OPC UA (Unified Architecture)
  • +
  • Open Distro for Elasticsearch
  • +
  • OpenSearch
  • +
  • OpenTSDB
  • +
+
+
    +
  • Orbit
  • +
  • SAP HANA®
  • +
  • Salesforce
  • +
  • Sentry
  • +
  • ServiceNow
  • +
  • Snowflake
  • +
  • Splunk
  • +
  • Splunk Infrastructure Monitoring
  • +
  • Sqlyze data source
  • +
  • TDengine
  • +
  • Vertica
  • +
  • Wavefront
  • +
  • X-Ray
  • +
  • kdb+
  • +
  • simple grpc data source
  • +
+
+ +## Limitations + +- Panels that use frontend data sources will fail to fetch data. +- Template variables are not supported. +- Exemplars will be omitted from the panel. +- Only annotations that query the `-- Grafana --` data source are supported. +- Organization annotations are not supported. +- Grafana Live and real-time event streams are not supported. +- Library panels are not supported. +- Data sources using Reverse Proxy functionality are not supported. + +## Custom branding + +If you're a Grafana Enterprise customer, you can use custom branding to change the appearance of an externally shared dashboard footer. For more information, refer to [Custom branding](ref:custom-branding). diff --git a/docs/sources/dashboards/troubleshoot-dashboards/index.md b/docs/sources/dashboards/troubleshoot-dashboards/index.md index 817f035fb2d..bb6530b7952 100644 --- a/docs/sources/dashboards/troubleshoot-dashboards/index.md +++ b/docs/sources/dashboards/troubleshoot-dashboards/index.md @@ -16,7 +16,7 @@ labels: menuTitle: Troubleshoot dashboards title: Troubleshoot dashboards description: Learn how to troubleshoot common dashboard issues -weight: 300 +weight: 1000 --- # Troubleshoot dashboards diff --git a/docs/sources/dashboards/use-dashboards/index.md b/docs/sources/dashboards/use-dashboards/index.md index c9771019310..a2be205471a 100644 --- a/docs/sources/dashboards/use-dashboards/index.md +++ b/docs/sources/dashboards/use-dashboards/index.md @@ -19,7 +19,7 @@ labels: menuTitle: Use dashboards title: Use dashboards description: Learn about the features of a Grafana dashboard -weight: 1 +weight: 100 refs: dashboard-analytics: - pattern: /docs/grafana/ @@ -66,6 +66,11 @@ refs: destination: /docs/grafana//panels-visualizations/panel-overview/ - pattern: /docs/grafana-cloud/ destination: /docs/grafana-cloud/visualizations/panels-visualizations/panel-overview/ + export-dashboards: + - pattern: /docs/grafana/ + destination: /docs/grafana//dashboards/share-dashboards-panels/#export-dashboards + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/visualizations/dashboards/share-dashboards-panels/#export-dashboards --- # Use dashboards @@ -80,15 +85,17 @@ The dashboard user interface provides a number of features that you can use to c The following image and descriptions highlight all dashboard features. -![An annotated image of a dashboard](/media/docs/grafana/dashboards/screenshot-dashboard-annotated-11.2.png) +![An annotated image of a dashboard](/media/docs/grafana/dashboards/screenshot-dashboard-no-toggle-annotated-11.3.png) 1. **Grafana home** - Click **Home** in the breadcrumb to go to the home page configured in the Grafana instance. 1. **Dashboard folder** - When you click the dashboard folder name, you can search for other dashboards contained in the folder and perform other [folder management tasks](ref:dashboard-folders). 1. **Dashboard title** - You can create your own dashboard titles or have Grafana create them for you using [generative AI features](ref:generative-ai-features). 1. **Mark as favorite** - Mark the dashboard as one of your favorites so it's included in your list of **Starred** dashboards in the main menu. +1. **Public label** - When you share a dashboard externally, it's marked with the **Public** label. 1. **Dashboard insights** - Click to view analytics about your dashboard including information about users, activity, query counts. Learn more about [dashboard analytics](ref:dashboard-analytics). -1. **Share dashboard** - Access several [dashboard sharing](ref:sharing) options. 1. **Edit** - Click to leave view-only mode and enter edit mode, where you can make changes directly to the dashboard and access dashboard settings, as well as several panel editing functions. +1. **Export** - Access [dashboard exporting](ref:export-dashboards) options. +1. **Share dashboard** - Access several [dashboard sharing](ref:sharing) options. 1. **Kiosk mode** - Click to display the dashboard on a large screen such as a TV or a kiosk. Kiosk mode hides elements such as navigation menus. Learn more about kiosk mode in our [How to Create Kiosks to Display Dashboards on a TV blog post](https://grafana.com/blog/2019/05/02/grafana-tutorial-how-to-create-kiosks-to-display-dashboards-on-a-tv/). Press `Enter` to leave kiosk mode. 1. **Variables** - Use [variables](ref:variables) to create more interactive and dynamic dashboards. 1. **Dashboard links** - Link to other dashboards, panels, and external websites. Learn more about [dashboard links](ref:dashboard-links). @@ -123,10 +130,11 @@ By hovering over a panel with the mouse you can use some shortcuts that will tar - `e`: Toggle panel edit view - `v`: Toggle panel fullscreen view -- `ps`: Open Panel Share Modal -- `pd`: Duplicate Panel -- `pr`: Remove Panel -- `pl`: Toggle panel legend +- `pu`: Open share panel link configuration +- `pe`: Open share panel embed configuration +- `ps`: Open share panel snapshot configuration +- `pd`: Duplicate panel +- `pr`: Remove panel ## Set dashboard time range diff --git a/docs/sources/dashboards/variables/_index.md b/docs/sources/dashboards/variables/_index.md index ad8eae57c86..6ec5d4916ed 100644 --- a/docs/sources/dashboards/variables/_index.md +++ b/docs/sources/dashboards/variables/_index.md @@ -10,7 +10,7 @@ labels: - oss title: Variables description: Add variables to metric queries and panel titles to create interactive and dynamic dashboards -weight: 130 +weight: 800 --- # Variables diff --git a/docs/sources/developers/http_api/_index.md b/docs/sources/developers/http_api/_index.md index a37489074de..51b5d2cab61 100644 --- a/docs/sources/developers/http_api/_index.md +++ b/docs/sources/developers/http_api/_index.md @@ -66,7 +66,7 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk - [Other API]({{< relref "other/" >}}) - [Playlists API]({{< relref "playlist/" >}}) - [Preferences API]({{< relref "preferences/" >}}) -- [Public dashboard API]({{< relref "dashboard_public/" >}}) +- [Shared dashboards API]({{< relref "dashboard_public/" >}}) - [Query history API]({{< relref "query_history/" >}}) - [Service account API]({{< relref "serviceaccount/" >}}) - [Short URL API]({{< relref "short_url/" >}}) diff --git a/docs/sources/developers/http_api/dashboard_public.md b/docs/sources/developers/http_api/dashboard_public.md index 1604022fd66..f96a4c4e4f4 100644 --- a/docs/sources/developers/http_api/dashboard_public.md +++ b/docs/sources/developers/http_api/dashboard_public.md @@ -2,7 +2,7 @@ aliases: - ../../http_api/dashboard_public/ canonical: /docs/grafana/latest/developers/http_api/dashboard_public/ -description: Grafana Public Dashboard HTTP API +description: Grafana Shared Dashboards HTTP API keywords: - grafana - http @@ -13,7 +13,7 @@ labels: products: - enterprise - oss -title: Public Dashboard HTTP API +title: Shared Dashboards HTTP API refs: role-based-access-control-permissions: - pattern: /docs/grafana/ @@ -22,7 +22,7 @@ refs: destination: /docs/grafana//administration/roles-and-permissions/access-control/custom-role-actions-scopes/ --- -# Public Dashboard API +# Shared Dashboards API {{% admonition type="note" %}} @@ -30,21 +30,21 @@ If you're running Grafana Enterprise, you'll need to have specific permissions f {{% /admonition %}} -## Create a public dashboard +## Create a shared dashboard `POST /api/dashboards/uid/:uid/public-dashboards/` -Creates a new public dashboard. +Creates a new shared dashboard. **Required permissions** -See note in the [introduction](#public-dashboard-api) for an explanation. +See note in the [introduction](#shared-dashboards-api) for an explanation. | Action | Scope | | ------------------------- | -------------------------------- | | `dashboards.public:write` | `dashboards:uid:` | -**Example Request for new public dashboard**: +**Example Request for new shared dashboard**: ```http POST /api/dashboards/uid/xCpsVuc4z/public-dashboards/ HTTP/1.1 @@ -64,10 +64,10 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk JSON Body schema: -- **uid** – Optional. Unique identifier when creating a public dashboard. If it's null, it will generate a new uid. +- **uid** – Optional. Unique identifier when creating a shared dashboard. If it's null, it will generate a new uid. - **accessToken** – Optional. Unique access token. If it's null, it will generate a new access token. -- **timeSelectionEnabled** – Optional. Set to `true` to enable the time picker in the public dashboard. The default value is `false`. -- **isEnabled** – Optional. Set to `true` to enable the public dashboard. The default value is `false`. +- **timeSelectionEnabled** – Optional. Set to `true` to enable the time picker in the shared dashboard. The default value is `false`. +- **isEnabled** – Optional. Set to `true` to enable the shared dashboard. The default value is `false`. - **annotationsEnabled** – Optional. Set to `true` to show annotations. The default value is `false`. - **share** – Optional. Set the share mode. The default value is `public`. @@ -96,7 +96,7 @@ Content-Length: 78 Status Codes: - **200** – Created -- **400** – Errors (such as invalid json, missing or invalid fields, or dashboard is public) +- **400** – Errors (such as invalid json, missing or invalid fields, or dashboard is shared) - **401** – Unauthorized - **403** – Access denied - **404** – Dashboard not found @@ -115,21 +115,21 @@ Content-Length: 107 } ``` -## Update a public dashboard +## Update a shared dashboard `PATCH /api/dashboards/uid/:uid/public-dashboards/:publicDashboardUid` -Will update the public dashboard given the specified unique identifier (uid). +Will update the shared dashboard given the specified unique identifier (uid). **Required permissions** -See note in the [introduction](#public-dashboard-api) for an explanation. +See note in the [introduction](#shared-dashboard-api) for an explanation. | Action | Scope | | ------------------------- | -------------------------------- | | `dashboards.public:write` | `dashboards:uid:` | -**Example Request for updating a public dashboard**: +**Example Request for updating a shared dashboard**: ```http PATCH /api/dashboards/uid/xCpsVuc4z/public-dashboards/cd56d9fd-f3d4-486d-afba-a21760e2acbe HTTP/1.1 @@ -147,8 +147,8 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk JSON Body schema: -- **timeSelectionEnabled** – Optional. Set to `true` to enable the time picker in the public dashboard. The default value is `false`. -- **isEnabled** – Optional. Set to `true` to enable the public dashboard. The default value is `false`. +- **timeSelectionEnabled** – Optional. Set to `true` to enable the time picker in the shared dashboard. The default value is `false`. +- **isEnabled** – Optional. Set to `true` to enable the shared dashboard. The default value is `false`. - **annotationsEnabled** – Optional. Set to `true` to show annotations. The default value is `false`. - **share** – Optional. Set the share mode. The default value is `public`. @@ -180,7 +180,7 @@ Status Codes: - **400** – Errors (such as invalid json, missing or invalid fields) - **401** – Unauthorized - **403** – Access denied -- **404** – Public dashboard not found +- **404** – Dashboard not found The error response body will have the following properties: @@ -196,15 +196,15 @@ Content-Length: 107 } ``` -## Get public dashboard by dashboard uid +## Get shared dashboard by dashboard uid `GET /api/dashboards/uid/:uid/public-dashboards/` -Will return the public dashboard given the dashboard unique identifier (uid). +Will return the shared dashboard given the dashboard unique identifier (uid). **Required permissions** -See note in the [introduction](#public-dashboard-api) for an explanation. +See note in the [introduction](#shared-dashboard-api) for an explanation. | Action | Scope | | ----------------- | -------------------------------- | @@ -245,17 +245,17 @@ Status Codes: - **200** – Found - **401** – Unauthorized - **403** – Access denied -- **404** – Not found +- **404** – Dashboard not found -## Delete public dashboard by dashboard uid and public dashboard uid +## Delete shared dashboard by dashboard uid and shared dashboard uid `DELETE /api/dashboards/uid/:uid/public-dashboards/:publicDashboardUid` -Will delete the public dashboard given the specified unique identifier (uid). +Will delete the shared dashboard given the specified unique identifier (uid). **Required permissions** -See note in the [introduction](#public-dashboard-api) for an explanation. +See note in the [introduction](#shared-dashboard-api) for an explanation. | Action | Scope | | ------------------------- | -------------------------------- | @@ -276,13 +276,13 @@ Status Codes: - **401** – Unauthorized - **403** – Access denied -## Get a list of all public dashboards with pagination +## Get a list of all shared dashboards with pagination `GET /api/dashboards/public-dashboards` **Required permissions** -See note in the [introduction](#public-dashboard-api) for an explanation. +See note in the [introduction](#shared-dashboard-api) for an explanation. | Action | Scope | | ----------------- | -------------------------------- | diff --git a/docs/sources/introduction/grafana-enterprise.md b/docs/sources/introduction/grafana-enterprise.md index f2d96227778..da08521b3ed 100644 --- a/docs/sources/introduction/grafana-enterprise.md +++ b/docs/sources/introduction/grafana-enterprise.md @@ -61,7 +61,7 @@ Grafana Enterprise adds the following features: - [Data source permissions]({{< relref "../administration/data-source-management#data-source-permissions" >}}) to restrict query access to specific teams and users. - [Data source query and resource caching]({{< relref "../administration/data-source-management#query-and-resource-caching" >}}) to temporarily store query results in Grafana to reduce data source load and rate limiting. - [Reporting]({{< relref "../dashboards/create-reports" >}}) to generate a PDF report from any dashboard and set up a schedule to have it emailed to whomever you choose. -- [Export dashboard as PDF]({{< relref "../dashboards/share-dashboards-panels#export-dashboard-as-pdf" >}}) +- [Export dashboard as PDF]({{< relref "../dashboards/share-dashboards-panels#export-a-dashboard-as-pdf" >}}) - [Custom branding]({{< relref "../setup-grafana/configure-grafana/configure-custom-branding" >}}) to customize Grafana from the brand and logo to the footer links. - [Usage insights]({{< relref "../dashboards/assess-dashboard-usage" >}}) to understand how your Grafana instance is used. - [Recorded queries]({{< relref "../administration/recorded-queries" >}}) to see trends over time for your data sources. diff --git a/docs/sources/panels-visualizations/panel-overview/index.md b/docs/sources/panels-visualizations/panel-overview/index.md index 5816a6f1ce8..7acc0cf1ff1 100644 --- a/docs/sources/panels-visualizations/panel-overview/index.md +++ b/docs/sources/panels-visualizations/panel-overview/index.md @@ -140,7 +140,7 @@ To access the panel editor, hover over the top-right corner of any panel. Click - **View**: View the panel in full screen. - **Edit**: Open the panel editor to edit panel and visualization options. -- **Share**: Share the panel as a link, embed, or library panel. +- **Share**: Share the panel as a link, embed, or snapshot. - **Explore**: Open the panel in **Explore**, where you can focus on your query. - **Inspect**: Open the **Inspect** drawer, where you can review the panel data, stats, metadata, JSON, and query. - **Data**: Open the **Inspect** drawer in the **Data** tab. @@ -150,8 +150,8 @@ To access the panel editor, hover over the top-right corner of any panel. Click - **More**: Access other panel actions. - **Duplicate**: Make a copy of the panel. Duplicated panels query data separately from the original panel. You can use the special `Dashboard` data source to [share the same query results across panels](ref:share) instead. - **Copy**: Copy the panel to the clipboard. - - **Create library panel**: Create a panel that can be imported into other dashboards. - - **Create alert**: Open the alert rule configuration page in **Alerting**, where you can [create a Grafana-managed alert](ref:create) based on the panel queries. + - **New library panel**: Create a panel that can be imported into other dashboards. + - **New alert rule**: Open the alert rule configuration page in **Alerting**, where you can [create a Grafana-managed alert](ref:create) based on the panel queries. - **Hide legend**: Hide the panel legend. - **Get help**: Send a snapshot or panel data to Grafana Labs Technical Support. - **Remove**: Remove the panel from the dashboard. @@ -164,10 +164,14 @@ By hovering over a panel with the mouse you can use some shortcuts that will tar - `e`: Toggle panel edit view - `v`: Toggle panel fullscreen view -- `ps`: Open Panel Share Modal +- `pu`: Share link +- `pe`: Share embed +- `ps`: Share snapshot +- `px`: Open panel in **Explore** - `pd`: Duplicate Panel +- `i`: Inspect +- `pl`: Hide or show legend - `pr`: Remove Panel -- `pl`: Toggle panel legend ## Add a panel diff --git a/docs/sources/panels-visualizations/visualizations/table/index.md b/docs/sources/panels-visualizations/visualizations/table/index.md index e7c8b2294fd..e1b6ecdaaaf 100644 --- a/docs/sources/panels-visualizations/visualizations/table/index.md +++ b/docs/sources/panels-visualizations/visualizations/table/index.md @@ -127,197 +127,22 @@ The table visualization helps with debugging when you need to know exactly what ![The Table view switch](/media/docs/grafana/panels-visualizations/screenshot-table-view-on-11.2.png) -## Sort column - -Click a column title to change the sort order from default to descending to ascending. Each time you click, the sort order changes to the next option in the cycle. You can sort multiple columns by holding the `shift` key and clicking the column name. - -![Sort descending](/static/img/docs/tables/sort-descending.png 'Sort descending') - -## Data set selector - -If the data queried contains multiple data sets, a table displays a drop-down list at the bottom, so you can select the data set you want to visualize. - -![Table visualization with multiple data sets](/media/docs/grafana/panels-visualizations/TablePanelMultiSet.png) - -## Panel options - -{{< docs/shared lookup="visualizations/panel-options.md" source="grafana" version="" >}} - -## Table options - -{{% admonition type="note" %}} -If you are using a table created before Grafana 7.0, then you need to migrate to the new table version in order to see these options. To migrate, on the Panel tab, click **Table** visualization. Grafana updates the table version and you can then access all table options. -{{% /admonition %}} - -### Show header - -Show or hide column names imported from your data source. - -### Column width - -By default, Grafana automatically calculates the column width based on the table size and the minimum column width. This field option can override the setting and define the width for all columns in pixels. - -For example, if you enter `100` in the field, then when you click outside the field, all the columns will be set to 100 pixels wide. - -### Minimum column width - -By default, the minimum width of the table column is 150 pixels. This field option can override that default and will define the new minimum column width for the table in pixels. - -For example, if you enter `75` in the field, then when you click outside the field, all the columns will scale to no smaller than 75 pixels wide. - -For small-screen devices, such as smartphones or tablets, reduce the default `150` pixel value to`50` to allow table-based panels to render correctly in dashboards. - -### Column alignment - -Choose how Grafana should align cell contents: - -- Auto (default) -- Left -- Center -- Right - -### Column filter - -You can temporarily change how column data is displayed. For example, you can order values from highest to lowest or hide specific values. For more information, refer to [Filter table columns](#filter-table-columns). - -### Pagination - -Use this option to enable or disable pagination. It is a front-end option that does not affect queries. When enabled, the page size automatically adjusts to the height of the table. - -## Cell options - -### Cell type - -By default, Grafana automatically chooses display settings. You can override the settings by choosing one of the following options to set the default for all fields. Additional configuration is available for some cell types. - -{{% admonition type="note" %}} -If you set these in the Field tab, then the type will apply to all fields, including the time field. Many options will work best if you set them in the Override tab so that they can be restricted to one or more fields. -{{% /admonition %}} - -#### Auto - -The **Auto** cell type automatically displays values, with sensible defaults applied. - -#### Color text - -If thresholds are set, then the field text is displayed in the appropriate threshold color. - -{{< figure src="/static/img/docs/tables/color-text.png" max-width="500px" caption="Color text" class="docs-image--no-shadow" >}} - -#### Color background (gradient or solid) - -If thresholds are set, then the field background is displayed in the appropriate threshold color. - -{{< figure src="/static/img/docs/tables/color-background.png" max-width="500px" caption="Color background" class="docs-image--no-shadow" >}} - -Toggle the **Apply to entire row** switch, to apply the background color that's configured for the cell to the whole row. - -{{< figure src="/static/img/docs/tables/colored-rows.png" max-width="500px" alt="Colored row background" class="docs-image--no-shadow" >}} - -#### Gauge - -Cells can be displayed as a graphical gauge, with several different presentation types. - -{{< admonition type="note" >}} -The maximum and minimum values of the gauges are configured automatically from the smallest and largest values in your whole data set. If you don't want the max/min values to be pulled from the whole data set, you can configure them for each column with field overrides. -{{< /admonition >}} - -##### Basic - -The basic mode will show a simple gauge with the threshold levels defining the color of gauge. - -{{< figure src="/static/img/docs/tables/basic-gauge.png" max-width="500px" caption="Gradient gauge" class="docs-image--no-shadow" >}} - -##### Gradient - -The threshold levels define a gradient. - -{{< figure src="/static/img/docs/tables/gradient-gauge.png" max-width="500px" caption="Gradient gauge" class="docs-image--no-shadow" >}} - -##### LCD - -The gauge is split up in small cells that are lit or unlit. - -{{< figure src="/static/img/docs/tables/lcd-gauge.png" max-width="500px" caption="LCD gauge" class="docs-image--no-shadow" >}} - -##### Label Options - -Additionally, labels displayed alongside of the gauges can be set to be colored by value, match the theme text color, or be hidden. - -**Value Color** - -{{< figure src="/static/img/docs/tables/value-color-mode.png" max-width="500px" caption="Color Label by Value" class="docs-image--no-shadow" >}} - -**Text Color** - -{{< figure src="/static/img/docs/tables/text-color-mode.png" max-width="500px" caption="Color Label by theme color" class="docs-image--no-shadow" >}} - -**Hidden** - -{{< figure src="/static/img/docs/tables/hidden-mode.png" max-width="500px" caption="Hide Label" class="docs-image--no-shadow" >}} - -#### Data links - -If you've configured data links, when the cell type is **Auto** mode, the cell text becomes clickable. If you change the cell type to **Data links**, the cell text reflects the titles of the configured data links. To control the application of data link text more granularly use a **Cell option > Cell type > Data links** field override. - -#### JSON view - -Shows value formatted as code. If a value is an object the JSON view allowing browsing the JSON object will appear on hover. - -{{< figure src="/static/img/docs/tables/json-view.png" max-width="500px" caption="JSON view" class="docs-image--no-shadow" >}} - -#### Image - -> Only available in Grafana 7.3+ - -If you have a field value that is an image URL or a base64 encoded image you can configure the table to display it as an image. - -{{< figure src="/static/img/docs/v73/table_hover.gif" max-width="900px" caption="Table hover" >}} - -Use the **Alt text** option to set the alternative text of an image. The text will be available for screen readers and in cases when images can't be loaded. - -Use the **Title text** option to set the text that's displayed when the image is hovered over with a cursor. - -#### Sparkline - -Shows values rendered as a sparkline. You can show sparklines using the [Time series to table transformation](ref:time-series-to-table-transformation) on data with multiple time series to process it into a format the table can show. - -{{< figure src="/static/img/docs/tables/sparkline2.png" max-width="500px" caption="Sparkline" class="docs-image--no-shadow" >}} - -You can be customize sparklines with many of the same options as the [Time series panel](ref:time-series-panel) including line width, fill opacity, and more. You can also change the color of the sparkline by updating the [color scheme](ref:color-scheme) in the _Standard options_ section of the panel configuration. - -### Wrap text - -{{< admonition type="note" >}} -Text wrapping is in [public preview](https://grafana.com/docs/release-life-cycle/#public-preview), however, it’s available to use by default. We’d love hear from you about how this new feature is working. To provide feedback, you can open an issue in the [Grafana GitHub repository](https://github.com/grafana/grafana). -{{< /admonition >}} - -Toggle the **Wrap text** switch to wrap text in the cell with the longest content in your table. To wrap the text in a specific column only, use the Wrap Text option in a [field override](ref:field-override). - -### Cell value inspect - -Enables value inspection from table cells. When the **Cell inspect value** switch is toggled on, clicking the inspect icon in a cell opens the **Inspect value** drawer. - -The **Inspect value** drawer has two tabs, **Plain text** and **Code editor**. Grafana attempts to automatically detect the type of data in the cell and opens the drawer with the associated tab showing. However, you can switch back and forth between tabs. - -Cell value inspection is only available when the **Cell type** selection is **Auto**, **Colored text**, **Colored background**, or **JSON View**. - ## Turn on column filtering 1. In Grafana, navigate to the dashboard with the table with the columns that you want to filter. 1. On the table panel you want to filter, open the panel editor. -1. Click the **Field** tab. -1. In Table options, turn on the **Column filter** option. +1. Expand the the **Table** options section. +1. Toggle on the [**Column filter** switch](#table-options). A filter icon appears next to each column title. -{{< figure src="/static/img/docs/tables/column-filter-with-icon.png" max-width="500px" caption="Column filtering turned on" class="docs-image--no-shadow" >}} +{{< figure src="/static/img/docs/tables/column-filter-with-icon.png" max-width="350px" alt="Column filtering turned on" class="docs-image--no-shadow" >}} ### Filter column values To filter column values, click the filter (funnel) icon next to a column title. Grafana displays the filter options for that column. -{{< figure src="/static/img/docs/tables/filter-column-values.png" max-width="500px" caption="Filter column values" class="docs-image--no-shadow" >}} +{{< figure src="/static/img/docs/tables/filter-column-values.png" max-width="300px" alt="Filter column values" class="docs-image--no-shadow" >}} Click the check box next to the values that you want to display. Enter text in the search field at the top to show those values in the display so that you can select them rather than scroll to find them. @@ -333,41 +158,202 @@ Click the check box above the **Ok** and **Cancel** buttons to add or remove all Columns with filters applied have a blue funnel displayed next to the title. -{{< figure src="/static/img/docs/tables/filtered-column.png" max-width="500px" caption="Filtered column" class="docs-image--no-shadow" >}} +{{< figure src="/static/img/docs/tables/filtered-column.png" max-width="100px" alt="Filtered column" class="docs-image--no-shadow" >}} To remove the filter, click the blue funnel icon and then click **Clear filter**. -## Table footer +## Sort columns -You can use the table footer to show [calculations](ref:calculations) on fields. +Click a column title to change the sort order from default to descending to ascending. Each time you click, the sort order changes to the next option in the cycle. You can sort multiple columns by holding the `shift` key and clicking the column name. -After you enable the table footer: +{{< figure src="/static/img/docs/tables/sort-descending.png" max-width="350px" alt="Sort descending" class="docs-image--no-shadow" >}} -1. Select the **Calculation** -2. Select the **Fields** that you want to calculate +## Dataset selector -The system applies the calculation to all numeric fields if you do not select a field. +If the data queried contains multiple datasets, a table displays a drop-down list at the bottom, so you can select the dataset you want to visualize. -### Count rows +{{< figure src="/media/docs/grafana/panels-visualizations/TablePanelMultiSet.png" max-width="650px" alt="Table visualization with multiple datasets" class="docs-image--no-shadow" >}} -If you want to show the number of rows in the dataset instead of the number of values in the selected fields, select the **Count** calculation and enable **Count rows**. +## Configuration options -## Standard options +### Panel options + +{{< docs/shared lookup="visualizations/panel-options.md" source="grafana" version="" >}} + +### Table options + +{{% admonition type="note" %}} +If you are using a table created before Grafana 7.0, then you need to migrate to the new table version in order to see these options. To migrate, on the Panel tab, click **Table** visualization. Grafana updates the table version and you can then access all table options. +{{% /admonition %}} + +| Option | Description | +| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Show table header | Show or hide column names imported from your data source. | +| Cell height | Set the height of the cell. Choose from **Small**, **Medium**, and **Large**. | +| Enable pagination | Toggle the switch to control how many table rows are visible at once. When switched on, the page size automatically adjusts to the height of the table. This option doesn't affect queries. | +| Minimum column width | Define the lower limit of the column width, in pixels. By default, the minimum width of the table column is 150 pixels. For small-screen devices, such as smartphones or tablets, reduce the default `150` pixel value to `50` to allow table-based panels to render correctly in dashboards. | +| Column width | Define a column width, in pixels, rather than allowing the width to be set automatically. By default, Grafana calculates the column width based on the table size and the minimum column width. | +| Column alignment | Set how Grafana should align cell contents. Choose from: **Auto** (default), **Left**, **Center**, and **Right**. | +| Column filter | Temporarily change how column data is displayed. For example, you can order values from highest to lowest or hide specific values. For more information, refer to [Filter table columns](#filter-table-columns). | + +### Table footer options + +Toggle the **Show table footer** switch on and off to control the display of the footer. When the toggle is switched on, you can use the table footer to show [calculations](ref:calculations) on fields. + +After you activate the table footer, make selections in the following options: + +- **Calculation** - The calculation that you want to apply. +- **Fields** - The fields to which you want to apply the calculations. The system applies the calculation to all numeric fields if you do not select a field. +- **Count rows** - This options is displayed if you select the **Count** calculation. If you want to show the number of rows in the dataset instead of the number of values in the selected fields, toggle on the **Count rows** switch. + +### Cell options + +Cell options allow you to control how data is displayed in a table. + +#### Cell type + +By default, Grafana automatically chooses display settings. You can override the settings by choosing one of the following options to set the default for all fields. Additional configuration is available for some cell types. + +{{% admonition type="note" %}} +If you set these in the Field tab, then the type will apply to all fields, including the time field. Many options will work best if you set them in the Override tab so that they can be restricted to one or more fields. +{{% /admonition %}} + +| Cell type | Description | +| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Auto | The **Auto** cell type automatically displays values, with sensible defaults applied. | +| [Sparkline](#sparkline) | Shows values rendered as a sparkline. | +| [Colored text](#colored-text) | If thresholds are set, then the field text is displayed in the appropriate threshold color. | +| [Colored background](#colored-background) | If thresholds are set, then the field background is displayed in the appropriate threshold color. | +| [Gauge](#gauge) | Cells can be displayed as a graphical gauge, with several different presentation types. You can set the [Gauge display mode](#gauge-display-mode) and the [Value display](#value-display) options. | +| Data links | If you've configured data links, when the cell type is **Auto** mode, the cell text becomes clickable. If you change the cell type to **Data links**, the cell text reflects the titles of the configured data links. To control the application of data link text more granularly use a **Cell option > Cell type > Data links** field override. | +| [JSON View](#json-view) | Shows value formatted as code. | +| [Image](#image) | If you have a field value that is an image URL or a base64 encoded image you can configure the table to display it as an image. | + +##### Sparkline + +Shows values rendered as a sparkline. You can show sparklines using the [Time series to table transformation](ref:time-series-to-table-transformation) on data with multiple time series to process it into a format the table can show. + +{{< figure src="/static/img/docs/tables/sparkline2.png" max-width="500px" alt="Sparkline" class="docs-image--no-shadow" >}} + +You can customize sparklines with many of the same options as the [time series visualization](ref:time-series-panel) including line style and width, fill opacity, gradient mode, and more. You can also change the color of the sparkline by updating the [color scheme](ref:color-scheme) in the **Standard options** section of the panel configuration. + +##### Colored text + +If thresholds are set, then the field text is displayed in the appropriate threshold color. + +{{< figure src="/static/img/docs/tables/color-text.png" max-width="500px" alt="Color text" class="docs-image--no-shadow" >}} + +{{< admonition type="note" >}} +This is an experimental feature. +{{< /admonition >}} + +##### Colored background + +If thresholds are set, then the field background is displayed in the appropriate threshold color. + +{{< figure src="/static/img/docs/tables/color-background.png" max-width="500px" alt="Color background" class="docs-image--no-shadow" >}} + +Choose between **Basic** and **Gradient** to set the **Background display mode**. + +Toggle the **Apply to entire row** switch, to apply the background color that's configured for the cell to the whole row. + +{{< figure src="/static/img/docs/tables/colored-rows.png" max-width="500px" alt="Colored row background" class="docs-image--no-shadow" >}} + +##### Gauge + +Cells can be displayed as a graphical gauge, with several different presentation types controlled by the gauge display mode and the value display. + +{{< admonition type="note" >}} +The maximum and minimum values of the gauges are configured automatically from the smallest and largest values in your whole data set. If you don't want the max/min values to be pulled from the whole data set, you can configure them for each column with field overrides. +{{< /admonition >}} + +###### Gauge display mode + +You can set three gauge display modes. + +- **Basic** - Shows a simple gauge with the threshold levels defining the color of gauge. + + {{< figure src="/static/img/docs/tables/basic-gauge.png" max-width="500px" alt="Gradient gauge" class="docs-image--no-shadow" >}} + +- **Gradient** - The threshold levels define a gradient. + + {{< figure src="/static/img/docs/tables/gradient-gauge.png" max-width="500px" alt="Gradient gauge" class="docs-image--no-shadow" >}} + +- **Retro LCD** - The gauge is split up in small cells that are lit or unlit. + + {{< figure src="/static/img/docs/tables/lcd-gauge.png" max-width="500px" alt="LCD gauge" class="docs-image--no-shadow" >}} + +###### Value display + +Labels displayed alongside of the gauges can be set to be colored by value, match the theme text color, or be hidden. + +- **Value color** + + {{< figure src="/static/img/docs/tables/value-color-mode.png" max-width="500px" alt="Color Label by Value" class="docs-image--no-shadow" >}} + +- **Text color** + + {{< figure src="/static/img/docs/tables/text-color-mode.png" max-width="500px" alt="Color Label by theme color" class="docs-image--no-shadow" >}} + +- **Hidden** + + {{< figure src="/static/img/docs/tables/hidden-mode.png" max-width="500px" alt="Hide Label" class="docs-image--no-shadow" >}} + +##### JSON View + +Shows value formatted as code. If a value is an object the JSON view allowing browsing the JSON object will appear on hover. + +{{< figure src="/static/img/docs/tables/json-view.png" max-width="350px" alt="JSON view" class="docs-image--no-shadow" >}} + +##### Image + +{{< admonition type="note" >}} +Only available in Grafana 7.3+ +{{< /admonition >}} + +If you have a field value that is an image URL or a base64 encoded image you can configure the table to display it as an image. + +{{< figure src="/static/img/docs/v73/table_hover.gif" max-width="900px" alt="Table hover" >}} + +- **Alt text** - Set the alternative text of an image. The text will be available for screen readers and in cases when images can't be loaded. +- **Title text** - Set the text that's displayed when the image is hovered over with a cursor. + +#### Wrap text + +{{< admonition type="note" >}} +Text wrapping is in [public preview](https://grafana.com/docs/release-life-cycle/#public-preview), however, it’s available to use by default. We’d love hear from you about how this new feature is working. To provide feedback, you can open an issue in the [Grafana GitHub repository](https://github.com/grafana/grafana). +{{< /admonition >}} + +Toggle the **Wrap text** switch to wrap text in the cell with the longest content in your table. To wrap the text in a specific column only, use the Wrap Text option in a [field override](ref:field-override). + +This option isn't available when you set the cell type to **Gauge** or Data links,JSON View, Image. + +#### Cell value inspect + +Enables value inspection from table cells. When the **Cell inspect value** switch is toggled on, clicking the inspect icon in a cell opens the **Inspect value** drawer. + +The **Inspect value** drawer has two tabs, **Plain text** and **Code editor**. Grafana attempts to automatically detect the type of data in the cell and opens the drawer with the associated tab showing. However, you can switch back and forth between tabs. + +Cell value inspection is only available when the **Cell type** selection is **Auto**, **Colored text**, **Colored background**, or **JSON View**. + +This option isn't available when you set the cell type to **Gauge** or Data links, Image, . + +### Standard options {{< docs/shared lookup="visualizations/standard-options.md" source="grafana" version="" >}} -## Data links +### Data links {{< docs/shared lookup="visualizations/datalink-options.md" source="grafana" version="" >}} -## Value mappings +### Value mappings {{< docs/shared lookup="visualizations/value-mappings-options.md" source="grafana" version="" >}} -## Thresholds +### Thresholds {{< docs/shared lookup="visualizations/thresholds-options-2.md" source="grafana" version="" >}} -## Field overrides +### Field overrides {{< docs/shared lookup="visualizations/overrides-options.md" source="grafana" version="" >}} diff --git a/docs/sources/search/_index.md b/docs/sources/search/_index.md index 832a359ba41..9fb729a3c07 100644 --- a/docs/sources/search/_index.md +++ b/docs/sources/search/_index.md @@ -1,9 +1,11 @@ --- -description: Learn how to search for Grafana dashboards and folders +description: Use the command palette to search all of Grafana and initiate key actions keywords: - search - - dashboard - - folder + - dashboards + - alerts + - preferences + - sift labels: products: - cloud @@ -14,111 +16,24 @@ title: Search weight: 80 --- -# Search dashboards and folders +# Grafana search -You can search for dashboards and dashboard folders by name. - -When you search for dashboards, you can also do it by panel title. Whether you search by name or panel title, the system returns all dashboards available within the Grafana instance, even if you do not have permission to view the contents of the dashboard. - -## Search by name - -Begin typing any part of the dashboard or folder name in the search bar. The search returns results for any partial string match in real-time, as you type. - -The search is: - -- Real-time -- _Not_ case sensitive -- Functional across stored _and_ file based dashboards and folders. - -{{% admonition type="note" %}} -You can use your keyboard arrow keys to navigate the results and press `Enter` to open the selected dashboard or folder. -{{% /admonition %}} - -The following images show: - -Searching by dashboard name from the **Dashboards** page. - -{{< figure src="/media/docs/grafana/dashboards/search-for-dashboard.png" width="700px" >}} - -Searching by folder name from the **Dashboards** page. - -{{< figure src="/media/docs/grafana/dashboards/search-folder.png" width="700px" >}} - -Searching by dashboard name inside a folder. - -{{< figure src="/media/docs/grafana/dashboards/search-in-folder.png" width="700px" >}} - -{{% admonition type="note" %}} -When you search within a folder, its subfolders are not part of the results returned. You need to be on the **Dashboards** page (or the root level) to search for subfolders by name. -{{% /admonition %}} - -## Search dashboards using panel title - -You can search for a dashboard by the title of a panel that appears in a dashboard. -If a panel's title matches your search query, the dashboard appears in the search results. - -This feature is available by default in Grafana Cloud and in Grafana OSS v9.1 and higher, you access this feature by enabling the `panelTitleSearch` feature toggle. -For more information about enabling panel title search, refer to [Enable the panelTitleSearch feature toggle.](#enable-panelTitleSearch-feature-toggle) - -The following image shows the search results when you search using panel title. - -{{< figure src="/static/img/docs/v91/dashboard-features/search-by-panel-title.png" width="700px" >}} - -### Enable the panelTitleSearch feature toggle - -Complete the following steps to enable the `panelTitleSearch` feature toggle. - -**Before you begin:** - -- If you are running Grafana Enterprise with RBAC, enable [service accounts]({{< relref "../administration/service-accounts/" >}}). - -**To enable the panelTitleSearch feature toggle:** - -1. Open the Grafana [configuration file]({{< relref "../setup-grafana/configure-grafana/#configuration-file-location" >}}). - -1. Locate the [feature_toggles]({{< relref "../setup-grafana/configure-grafana/#feature_toggles" >}}) section. - -1. Add the following parameter to the `feature_toggles` section: - - ``` - [feature_toggles] - # enable features, separated by spaces - enable = panelTitleSearch - ``` - -1. Save your changes and restart the Grafana server. - -## Filter dashboard search results by tag(s) - -Tags are a great way to organize your dashboards, especially as the number of dashboards grow. You can add and manage tags in dashboard `Settings`. - -When you select multiple tags, Grafana shows dashboards that include all selected tags. - -To filter dashboard search result by a tag, complete one of the following steps: - -- To filter dashboard search results by tag, click a tag that appears in the right column of the search results. - - You can continue filtering by clicking additional tags. - -- To see a list of all available tags, click the **Filter by tags** dropdown menu and select a tag. - - All tags will be shown, and when you select a tag, the dashboard search will be instantly filtered. - -{{% admonition type="note" %}} -When using only a keyboard, press the `tab` key and navigate to the **Filter by tag** drop-down menu, press the down arrow key `▼` to activate the menu and locate a tag, and press `Enter` to select the tag. -{{% /admonition %}} +Use the command palette to search all of Grafana and to initiate key actions from any page in the application. ## Command palette -The command palette enables you to: +The command palette allows you to: - Search for and open dashboards and folders - Create dashboards and alert rules - Locate pages within Grafana - Change the theme to dark or light +- Run Sift investigations ![Command Palette screenshot](/media/docs/grafana/CommandPalette_doc_1.png) To open the command palette, press `cmd+K` in macOS or `ctrl+k` in Linux/Windows. You can also click on the input located in the navigation bar. -> **Note:** To go to the previous step, press `backspace` with the command palette input empty. +{{< admonition type="note">}} +To go to the previous step, press `backspace` with the command palette input empty. +{{< /admonition >}} diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md index 505b3b141a5..8e74e21186f 100644 --- a/docs/sources/setup-grafana/configure-grafana/_index.md +++ b/docs/sources/setup-grafana/configure-grafana/_index.md @@ -2613,8 +2613,8 @@ Format: ` = ` ## [public_dashboards] -This section configures the [public dashboards]({{< relref "../../dashboards/dashboard-public" >}}) feature. +This section configures the [shared dashboards](https://grafana.com/docs/grafana//dashboards/share-dashboards-panels/shared-dashboards/) feature. ### enabled -Set this to `false` to disable the public dashboards feature. This prevents users from creating new public dashboards and disables existing ones. +Set this to `false` to disable the shared dashboards feature. This prevents users from creating new shared dashboards and disables existing ones. diff --git a/docs/sources/setup-grafana/configure-grafana/configure-custom-branding/index.md b/docs/sources/setup-grafana/configure-grafana/configure-custom-branding/index.md index f38a142e429..4d39ca9b09e 100644 --- a/docs/sources/setup-grafana/configure-grafana/configure-custom-branding/index.md +++ b/docs/sources/setup-grafana/configure-grafana/configure-custom-branding/index.md @@ -112,22 +112,22 @@ The following two links are always present in the footer: If you specify `footer_links` or `GF_WHITE_LABELING_FOOTER_LINKS`, then all other default links are removed from the footer, and only what is specified is included. -## Custom branding for public dashboards +## Custom branding for shared dashboards -In addition to the customizations described below, you can customize the footer of your public dashboards. -To customize the footer of a public dashboard, add the following section to the `grafana.ini` file. +In addition to the customizations described below, you can customize the footer of your shared dashboards. +To customize the footer of a shared dashboard, add the following section to the `grafana.ini` file. ```ini [white_labeling.public_dashboards] -# Hides the footer for the public dashboards if set to `true`. +# Hides the footer for the shared dashboards if set to `true`. # example: footer_hide = "true" ;footer_hide = # Set to text shown in the footer ;footer_text = -# Set to complete url to override public dashboard footer logo. Default is `grafana-logo` and will display the Grafana logo. +# Set to complete url to override shared dashboard footer logo. Default is `grafana-logo` and will display the Grafana logo. # An empty value will hide the footer logo. ;footer_logo = diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 3102a626929..39ab938fb28 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -191,7 +191,6 @@ Experimental features might be changed or removed without prior notice. | `alertingCentralAlertHistory` | Enables the new central alert history. | | `pinNavItems` | Enables pinning of nav items | | `failWrongDSUID` | Throws an error if a datasource has an invalid UIDs | -| `databaseReadReplica` | Use a read replica for some database queries. | | `alertingApiServer` | Register Alerting APIs with the K8s API server | | `dashboardRestoreUI` | Enables the frontend to be able to restore a recently deleted dashboard | | `dataplaneAggregator` | Enable grafana dataplane aggregator | @@ -201,7 +200,10 @@ Experimental features might be changed or removed without prior notice. | `exploreLogsShardSplitting` | Used in Explore Logs to split queries into multiple queries based on the number of shards | | `exploreLogsAggregatedMetrics` | Used in Explore Logs to query by aggregated metrics | | `exploreLogsLimitedTimeRange` | Used in Explore Logs to limit the time range | +| `homeSetupGuide` | Used in Home for users who want to return to the onboarding flow or quickly find popular config pages | | `appSidecar` | Enable the app sidecar feature that allows rendering 2 apps at the same time | +| `alertingQueryAndExpressionsStepMode` | Enables step mode for alerting queries and expressions | +| `rolePickerDrawer` | Enables the new role picker drawer design | ## Development feature toggles diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/jwt/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/jwt/index.md index eecb143e524..c8ed2b24a89 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/jwt/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/jwt/index.md @@ -94,7 +94,7 @@ you can use JWT authentication to authenticate the iframe. {{% admonition type="note" %}} For Grafana Cloud, or scenarios where verifying viewer identity is not required, -embed [public dashboards]({{< relref "../../../../dashboards/dashboard-public" >}}). +embed [shared dashboards](https://grafana.com/docs/grafana//dashboards/share-dashboards-panels/shared-dashboards/). {{% /admonition %}} In this scenario, you will need to configure Grafana to accept a JWT diff --git a/docs/sources/setup-grafana/image-rendering/_index.md b/docs/sources/setup-grafana/image-rendering/_index.md index ed04d5cc64e..47a74351af8 100644 --- a/docs/sources/setup-grafana/image-rendering/_index.md +++ b/docs/sources/setup-grafana/image-rendering/_index.md @@ -26,7 +26,7 @@ While an image is being rendered, the PNG image is temporarily written to the fi A background job runs every 10 minutes and removes temporary images. You can configure how long an image should be stored before being removed by configuring the [temp_data_lifetime]({{< relref "../configure-grafana#temp_data_lifetime" >}}) setting. -You can also render a PNG by hovering over the panel to display the actions menu in the top right corner, and then clicking **Share > Direct link rendered image** in the Link tab. +You can also render a PNG by hovering over the panel to display the actions menu in the top-right corner, and then clicking **Share > Share link**. The **Render image** option is displayed in the link settings. ## Alerting and render limits @@ -82,8 +82,10 @@ AUTH_TOKEN=- ```json { - "security": { - "authToken": "-" + "service": { + "security": { + "authToken": "-" + } } } ``` diff --git a/docs/sources/shared/visualizations/thresholds-options-2.md b/docs/sources/shared/visualizations/thresholds-options-2.md index d2ea9c2a44a..f6defc3928e 100644 --- a/docs/sources/shared/visualizations/thresholds-options-2.md +++ b/docs/sources/shared/visualizations/thresholds-options-2.md @@ -8,11 +8,11 @@ comments: | A threshold is a value or limit you set for a metric that’s reflected visually when it’s met or exceeded. Thresholds are one way you can conditionally style and color your visualizations based on query results. -Set the following options: +For each threshold, set the following options: -- **Value** - Set the value for each threshold. -- **Thresholds mode** - Choose from: - - **Absolute** - - **Percentage** +| Option | Description | +| --------------- | -------------------------------------------- | +| Value | Set the value for each threshold. | +| Thresholds mode | Choose from **Absolute** and **Percentage**. | To learn more, refer to [Configure thresholds](../../configure-thresholds/). diff --git a/docs/sources/whatsnew/whats-new-in-v10-0.md b/docs/sources/whatsnew/whats-new-in-v10-0.md index df622eac402..9d5b08c7c44 100644 --- a/docs/sources/whatsnew/whats-new-in-v10-0.md +++ b/docs/sources/whatsnew/whats-new-in-v10-0.md @@ -225,7 +225,7 @@ With this update, we've made the following improvements: To try it out, enable the `publicDashboards` feature toggle. If you’re using Grafana Cloud, and would like to enable this feature, please contact customer support. -To learn more, refer to our [public dashboards documentation]({{< relref "../dashboards/dashboard-public" >}}). +To learn more, refer to our [public dashboards documentation](https://grafana.com/docs/grafana//dashboards/share-dashboards-panels/shared-dashboards/). ### Public dashboards insights @@ -255,7 +255,7 @@ This feature will have a cost by active users after being promoted into general {{% /admonition %}} -To learn more, refer to our [public dashboards documentation]({{< relref "../dashboards/dashboard-public" >}}). +To learn more, refer to our [public dashboards documentation](https://grafana.com/docs/grafana//dashboards/share-dashboards-panels/shared-dashboards/). ## Authentication and authorization diff --git a/docs/sources/whatsnew/whats-new-in-v10-2.md b/docs/sources/whatsnew/whats-new-in-v10-2.md index 1db3a3d2505..3b0425ec2bb 100644 --- a/docs/sources/whatsnew/whats-new-in-v10-2.md +++ b/docs/sources/whatsnew/whats-new-in-v10-2.md @@ -46,7 +46,7 @@ Learn how to upload images here: https://grafana.com/docs/writers-toolkit/write/ _Generally available in all editions of Grafana_ -Public dashboards allow you to share your visualizations and insights with a broader audience without the requirement of a login. You can effortlessly use our current sharing model and create a public dashboard URL to share with anyone using the generated public URL link. To learn more, refer to the [Public dashboards documentation](https://grafana.com/docs/grafana//dashboards/dashboard-public/), as well as the following video demo: +Public dashboards allow you to share your visualizations and insights with a broader audience without the requirement of a login. You can effortlessly use our current sharing model and create a public dashboard URL to share with anyone using the generated public URL link. To learn more, refer to the [Public dashboards documentation](https://grafana.com/docs/grafana//dashboards/share-dashboards-panels/shared-dashboards), as well as the following video demo: {{< youtube id="XHwwRCdxHMg?rel=0" >}} diff --git a/docs/sources/whatsnew/whats-new-in-v11-0.md b/docs/sources/whatsnew/whats-new-in-v11-0.md index 024e7a44c69..361d52a37ae 100644 --- a/docs/sources/whatsnew/whats-new-in-v11-0.md +++ b/docs/sources/whatsnew/whats-new-in-v11-0.md @@ -384,7 +384,7 @@ To learn more about Grafana's strong password policy, refer to the [documentatio _Generally available in Grafana Enterprise_ -We are announcing a license change to the anonymous access feature in Grafana 11. As you may already be aware, anonymous access allows users access to Grafana without login credentials. Anonymous access was an early feature of Grafana to share dashboards; however, we recently introduced [Public Dashboards](https://grafana.com/docs/grafana//dashboards/dashboard-public/) which allows you to share dashboards in a more secure manner. We also noticed that anonymous access inadvertently resulted in user licensing issues. After careful consideration, we have decided to charge for the continued use of anonymous access starting in Grafana 11. +We are announcing a license change to the anonymous access feature in Grafana 11. As you may already be aware, anonymous access allows users access to Grafana without login credentials. Anonymous access was an early feature of Grafana to share dashboards; however, we recently introduced [Public Dashboards](https://grafana.com/docs/grafana//dashboards/share-dashboards-panels/shared-dashboards) which allows you to share dashboards in a more secure manner. We also noticed that anonymous access inadvertently resulted in user licensing issues. After careful consideration, we have decided to charge for the continued use of anonymous access starting in Grafana 11. **Affected Grafana versions** diff --git a/docs/sources/whatsnew/whats-new-in-v9-1.md b/docs/sources/whatsnew/whats-new-in-v9-1.md index cab0a2696d4..7a6aeb9f44a 100644 --- a/docs/sources/whatsnew/whats-new-in-v9-1.md +++ b/docs/sources/whatsnew/whats-new-in-v9-1.md @@ -50,7 +50,7 @@ You can now easily embed Grafana in other applications by adding a JWT token dir When the JWT token is passed through the request URL to Grafana, Grafana validates and authenticates the token linked to a specific user, allowing access to dashboards which that user can view. To see JWT URL embedding in action, see the [sample project](https://github.com/grafana/grafana-iframe-oauth-sample). -> **Note:** JWT URL Embedding and `allow_embedding` are not available to Grafana Cloud users. For Grafana Cloud, use the [Public Dashboards]({{< relref "../dashboards/dashboard-public" >}}) feature. To enable that, [open a ticket with our Support team](/docs/grafana-cloud/account-management/support/). +> **Note:** JWT URL Embedding and `allow_embedding` are not available to Grafana Cloud users. For Grafana Cloud, use [public dashboards](https://grafana.com/docs/grafana//dashboards/share-dashboards-panels/shared-dashboards/). To enable that, [open a ticket with our Support team](/docs/grafana-cloud/account-management/support/). {{< figure src="/static/img/docs/dashboards/jwt-url-embedding-9-1.png" max-width="750px" caption="A JWT token used to embed Grafana" >}} @@ -156,7 +156,7 @@ The public view of a dashboard has a few restrictions: - The public dashboard is displayed in a read-only kiosk view. - The time range is fixed to the dashboard default time range. -To learn more, see the [documentation]({{< relref "../dashboards/dashboard-public" >}}). +To learn more, see the [documentation](https://grafana.com/docs/grafana//dashboards/share-dashboards-panels/shared-dashboards/). #### Provisioning improvements for Grafana Alerting diff --git a/docs/sources/whatsnew/whats-new-in-v9-2.md b/docs/sources/whatsnew/whats-new-in-v9-2.md index 4deeb3a386a..deb8c3f836d 100644 --- a/docs/sources/whatsnew/whats-new-in-v9-2.md +++ b/docs/sources/whatsnew/whats-new-in-v9-2.md @@ -95,7 +95,7 @@ External alertmanagers should now be configured as data sources using Grafana Co _Available in Experimental in Grafana Open Source, Enterprise, and Cloud._ _To enable public dashboards, you must enable a feature flag or request this feature from support._ -[Public dashboards](/docs/grafana/latest/dashboards/dashboard-public/) launched as an experimental feature in Grafana v9.1. +[Public dashboards](https://grafana.com/docs/grafana//dashboards/share-dashboards-panels/shared-dashboards) launched as an experimental feature in Grafana v9.1. We've received lots of great feedback on this much-anticipated feature and thank everyone who has helped us improve it. The team has been hard at work polishing public dashboards, and we've closed quite a few bugs related to community reports. If you're trying out public dashboards, let us know how it is going in the [open discussion in GitHub](https://github.com/grafana/grafana/discussions/49253). diff --git a/docs/sources/whatsnew/whats-new-in-v9-3.md b/docs/sources/whatsnew/whats-new-in-v9-3.md index f780b4890af..929381d679b 100644 --- a/docs/sources/whatsnew/whats-new-in-v9-3.md +++ b/docs/sources/whatsnew/whats-new-in-v9-3.md @@ -87,7 +87,7 @@ Annotations are now supported in public dashboards, with the exception of query Note that because Public Dashboards is an experimental feature, you need to enable it in Grafana using the `publicDashboards` [feature toggle]({{< relref "../setup-grafana/configure-grafana#feature_toggles" >}}), or open a support ticket requesting public dashboards if you are a Cloud Advanced customer. -To learn more about public dashboards, refer to [Public dashboards]({{< relref "../dashboards/dashboard-public" >}}). +To learn more about public dashboards, refer to [Public dashboards](https://grafana.com/docs/grafana//dashboards/share-dashboards-panels/shared-dashboards/). ## New transformation: Partition by values diff --git a/e2e/various-suite/prometheus-editor.spec.ts b/e2e/various-suite/prometheus-editor.spec.ts index a35ca282e50..d04d502a706 100644 --- a/e2e/various-suite/prometheus-editor.spec.ts +++ b/e2e/various-suite/prometheus-editor.spec.ts @@ -1,5 +1,3 @@ -import { selectors } from '@grafana/e2e-selectors'; - import { e2e } from '../utils'; import { getResources } from './helpers/prometheus-helpers'; @@ -67,9 +65,9 @@ describe('Prometheus query editor', () => { // check options e2e.components.DataSource.Prometheus.queryEditor.legend().scrollIntoView().should('exist'); e2e.components.DataSource.Prometheus.queryEditor.format().scrollIntoView().should('exist'); - cy.get(`#${selectors.components.DataSource.Prometheus.queryEditor.step}`).scrollIntoView().should('exist'); + cy.get(`[data-test-id="prometheus-step"]`).scrollIntoView().should('exist'); e2e.components.DataSource.Prometheus.queryEditor.type().scrollIntoView().should('exist'); - cy.get(`#${selectors.components.DataSource.Prometheus.queryEditor.exemplars}`).scrollIntoView().should('exist'); + cy.get(`[data-test-id="prometheus-exemplars"]`).scrollIntoView().should('exist'); }); describe('Code editor', () => { diff --git a/go.mod b/go.mod index 0e5c352c4ba..3d55ba923c1 100644 --- a/go.mod +++ b/go.mod @@ -73,7 +73,7 @@ require ( github.com/googleapis/gax-go/v2 v2.13.0 // @grafana/grafana-backend-group github.com/gorilla/mux v1.8.1 // @grafana/grafana-backend-group github.com/gorilla/websocket v1.5.0 // @grafana/grafana-app-platform-squad - github.com/grafana/alerting v0.0.0-20240917171353-6c25eb6eff10 // @grafana/alerting-backend + github.com/grafana/alerting v0.0.0-20240926144415-27f4e81b4b6b // @grafana/alerting-backend github.com/grafana/authlib v0.0.0-20240919120951-58259833c564 // @grafana/identity-access-team github.com/grafana/authlib/claims v0.0.0-20240827210201-19d5347dd8dd // @grafana/identity-access-team github.com/grafana/codejen v0.0.3 // @grafana/dataviz-squad diff --git a/go.sum b/go.sum index a375eb9ad37..143299e44c6 100644 --- a/go.sum +++ b/go.sum @@ -2256,8 +2256,8 @@ github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/grafana/alerting v0.0.0-20240917171353-6c25eb6eff10 h1:oDbLKM34O+JUF9EQFS+9aYhdYoeNfUpXqNjFCLIxwF4= -github.com/grafana/alerting v0.0.0-20240917171353-6c25eb6eff10/go.mod h1:GMLi6d09Xqo96fCVUjNk//rcjP5NKEdjOzfWIffD5r4= +github.com/grafana/alerting v0.0.0-20240926144415-27f4e81b4b6b h1:UO4mv94pG1kzKCgBKh20TXdACBCAK2vYjV3Q2MlcpEQ= +github.com/grafana/alerting v0.0.0-20240926144415-27f4e81b4b6b/go.mod h1:GMLi6d09Xqo96fCVUjNk//rcjP5NKEdjOzfWIffD5r4= github.com/grafana/authlib v0.0.0-20240919120951-58259833c564 h1:zYF/RBulpvMqPYR3gbzJZ8t/j/Eymn5FNidSYkueNCA= github.com/grafana/authlib v0.0.0-20240919120951-58259833c564/go.mod h1:PFzXbCrn0GIpN4KwT6NP1l5Z1CPLfmKHnYx8rZzQcyY= github.com/grafana/authlib/claims v0.0.0-20240827210201-19d5347dd8dd h1:sIlR7n38/MnZvX2qxDEszywXdI5soCwQ78aTDSARvus= diff --git a/go.work.sum b/go.work.sum index 39980fec8f0..ca6f13d695d 100644 --- a/go.work.sum +++ b/go.work.sum @@ -561,6 +561,8 @@ github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/grafana/alerting v0.0.0-20240830172655-aa466962ea18 h1:3cQ+d+fkNL2EqpARaBVG34KlVz7flDujYfDx3njvdh8= github.com/grafana/alerting v0.0.0-20240830172655-aa466962ea18/go.mod h1:GMLi6d09Xqo96fCVUjNk//rcjP5NKEdjOzfWIffD5r4= +github.com/grafana/alerting v0.0.0-20240917171353-6c25eb6eff10 h1:oDbLKM34O+JUF9EQFS+9aYhdYoeNfUpXqNjFCLIxwF4= +github.com/grafana/alerting v0.0.0-20240917171353-6c25eb6eff10/go.mod h1:GMLi6d09Xqo96fCVUjNk//rcjP5NKEdjOzfWIffD5r4= github.com/grafana/gomemcache v0.0.0-20240229205252-cd6a66d6fb56/go.mod h1:PGk3RjYHpxMM8HFPhKKo+vve3DdlPUELZLSDEFehPuU= github.com/grafana/pyroscope-go/godeltaprof v0.1.6/go.mod h1:Tk376Nbldo4Cha9RgiU7ik8WKFkNpfds98aUzS8omLE= github.com/grafana/thema v0.0.0-20230511182720-3146087fcc26 h1:HX927q4X1n451pnGb8U0wq74i8PCzuxVjzv7TyD10kc= diff --git a/package.json b/package.json index 6a8e3ecbebc..e2b7a7751f6 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "@emotion/eslint-plugin": "11.11.0", "@grafana/eslint-config": "7.0.0", "@grafana/eslint-plugin": "link:./packages/grafana-eslint-rules", - "@grafana/plugin-e2e": "1.8.0", + "@grafana/plugin-e2e": "1.8.1", "@grafana/tsconfig": "^2.0.0", "@manypkg/get-packages": "^2.2.0", "@playwright/test": "1.47.2", @@ -116,13 +116,13 @@ "@types/history": "4.7.11", "@types/ini": "^4", "@types/jest": "29.5.13", - "@types/jquery": "3.5.30", + "@types/jquery": "3.5.31", "@types/js-yaml": "^4.0.5", "@types/jsurl": "^1.2.28", "@types/lodash": "4.17.9", "@types/logfmt": "^1.2.3", "@types/lucene": "^2", - "@types/node": "20.16.6", + "@types/node": "20.16.9", "@types/node-forge": "^1", "@types/ol-ext": "npm:@siedlerchr/types-ol-ext@3.2.4", "@types/pluralize": "^0.0.33", @@ -156,7 +156,7 @@ "@typescript-eslint/eslint-plugin": "6.21.0", "@typescript-eslint/parser": "6.21.0", "autoprefixer": "10.4.20", - "babel-loader": "9.1.3", + "babel-loader": "9.2.1", "blob-polyfill": "9.0.20240710", "browserslist": "^4.21.4", "chance": "^1.0.10", @@ -222,7 +222,7 @@ "redux-mock-store": "1.5.4", "rimraf": "6.0.1", "rudder-sdk-js": "2.48.18", - "sass": "1.78.0", + "sass": "1.79.3", "sass-loader": "14.2.1", "smtp-tester": "^2.1.0", "style-loader": "4.0.0", @@ -234,7 +234,7 @@ "ts-jest": "29.2.5", "ts-node": "10.9.2", "typescript": "5.5.4", - "webpack": "5.94.0", + "webpack": "5.95.0", "webpack-assets-manifest": "^5.1.0", "webpack-bundle-analyzer": "4.10.2", "webpack-cli": "5.1.4", @@ -268,7 +268,7 @@ "@grafana/prometheus": "workspace:*", "@grafana/runtime": "workspace:*", "@grafana/saga-icons": "workspace:*", - "@grafana/scenes": "5.14.7", + "@grafana/scenes": "5.16.0", "@grafana/schema": "workspace:*", "@grafana/sql": "workspace:*", "@grafana/ui": "workspace:*", @@ -357,7 +357,7 @@ "rc-slider": "11.1.6", "rc-time-picker": "3.7.3", "rc-tree": "5.9.0", - "re-resizable": "6.9.18", + "re-resizable": "6.10.0", "react": "18.2.0", "react-diff-viewer": "^3.1.1", "react-dom": "18.2.0", @@ -368,7 +368,7 @@ "react-hook-form": "^7.49.2", "react-i18next": "^14.0.0", "react-inlinesvg": "3.0.2", - "react-loading-skeleton": "3.4.0", + "react-loading-skeleton": "3.5.0", "react-moveable": "0.56.0", "react-redux": "9.1.2", "react-resizable": "3.0.5", diff --git a/packages/grafana-data/package.json b/packages/grafana-data/package.json index c71f738a903..45378752c73 100644 --- a/packages/grafana-data/package.json +++ b/packages/grafana-data/package.json @@ -62,11 +62,11 @@ }, "devDependencies": { "@grafana/tsconfig": "^2.0.0", - "@rollup/plugin-node-resolve": "15.2.4", + "@rollup/plugin-node-resolve": "15.3.0", "@types/dompurify": "^3.0.0", "@types/history": "4.7.11", "@types/lodash": "4.17.9", - "@types/node": "20.16.6", + "@types/node": "20.16.9", "@types/papaparse": "5.3.14", "@types/react": "18.3.3", "@types/react-dom": "18.2.25", diff --git a/packages/grafana-data/src/transformations/fieldReducer.test.ts b/packages/grafana-data/src/transformations/fieldReducer.test.ts index baa5d704911..120a416d81a 100644 --- a/packages/grafana-data/src/transformations/fieldReducer.test.ts +++ b/packages/grafana-data/src/transformations/fieldReducer.test.ts @@ -55,13 +55,14 @@ describe('Stats Calculators', () => { it('should calculate basic stats', () => { const stats = reduceField({ field: basicTable.fields[0], - reducers: [ReducerID.first, ReducerID.last, ReducerID.mean, ReducerID.count], + reducers: [ReducerID.first, ReducerID.last, ReducerID.mean, ReducerID.count, ReducerID.diffperc], }); expect(stats.first).toEqual(10); expect(stats.last).toEqual(20); expect(stats.mean).toEqual(15); expect(stats.count).toEqual(2); + expect(stats.diffperc).toEqual(100); }); it('should handle undefined field data without crashing', () => { diff --git a/packages/grafana-data/src/transformations/fieldReducer.ts b/packages/grafana-data/src/transformations/fieldReducer.ts index a032619aeb3..a7d1f0c7242 100644 --- a/packages/grafana-data/src/transformations/fieldReducer.ts +++ b/packages/grafana-data/src/transformations/fieldReducer.ts @@ -582,7 +582,7 @@ export function doStandardCalcs(field: Field, ignoreNulls: boolean, nullAsZero: } if (isNumber(calcs.firstNotNull) && isNumber(calcs.diff)) { - calcs.diffperc = calcs.diff / calcs.firstNotNull; + calcs.diffperc = (calcs.diff / calcs.firstNotNull) * 100; } return calcs; } diff --git a/packages/grafana-data/src/types/dataLink.ts b/packages/grafana-data/src/types/dataLink.ts index 47cba535029..476f2441ed7 100644 --- a/packages/grafana-data/src/types/dataLink.ts +++ b/packages/grafana-data/src/types/dataLink.ts @@ -51,6 +51,10 @@ export interface DataLink { internal?: InternalDataLink; origin?: DataLinkConfigOrigin; + meta?: { + correlationData?: ExploreCorrelationHelperData; + transformations?: DataLinkTransformationConfig[]; + }; } /** @@ -78,10 +82,6 @@ export interface InternalDataLink { datasourceUid: string; datasourceName: string; // used as a title if `DataLink.title` is empty panelsState?: ExplorePanelsState; - meta?: { - correlationData?: ExploreCorrelationHelperData; - }; - transformations?: DataLinkTransformationConfig[]; range?: TimeRange; } diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index fe6aceaadec..7104c77c50e 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -193,7 +193,6 @@ export interface FeatureToggles { openSearchBackendFlowEnabled?: boolean; ssoSettingsLDAP?: boolean; failWrongDSUID?: boolean; - databaseReadReplica?: boolean; zanzana?: boolean; passScopeToDashboardApi?: boolean; alertingApiServer?: boolean; @@ -209,9 +208,12 @@ export interface FeatureToggles { exploreLogsShardSplitting?: boolean; exploreLogsAggregatedMetrics?: boolean; exploreLogsLimitedTimeRange?: boolean; + homeSetupGuide?: boolean; appPlatformAccessTokens?: boolean; appSidecar?: boolean; groupAttributeSync?: boolean; + alertingQueryAndExpressionsStepMode?: boolean; improvedExternalSessionHandling?: boolean; useSessionStorageForRedirection?: boolean; + rolePickerDrawer?: boolean; } diff --git a/packages/grafana-data/src/utils/dataLinks.ts b/packages/grafana-data/src/utils/dataLinks.ts index 8c8084cee34..7f4a71f6aeb 100644 --- a/packages/grafana-data/src/utils/dataLinks.ts +++ b/packages/grafana-data/src/utils/dataLinks.ts @@ -40,11 +40,7 @@ export function mapInternalLinkToExplore(options: LinkToExploreOptions): LinkMod const interpolatedQuery = interpolateObject(link.internal?.query, scopedVars, replaceVariables); const interpolatedPanelsState = interpolateObject(link.internal?.panelsState, scopedVars, replaceVariables); - const interpolatedCorrelationData = interpolateObject( - link.internal?.meta?.correlationData, - scopedVars, - replaceVariables - ); + const interpolatedCorrelationData = interpolateObject(link.meta?.correlationData, scopedVars, replaceVariables); const title = link.title ? link.title : internalLink.datasourceName; return { diff --git a/packages/grafana-e2e-selectors/package.json b/packages/grafana-e2e-selectors/package.json index ccb6c46f1e5..022a12f9c4a 100644 --- a/packages/grafana-e2e-selectors/package.json +++ b/packages/grafana-e2e-selectors/package.json @@ -39,8 +39,8 @@ "postpack": "mv package.json.bak package.json" }, "devDependencies": { - "@rollup/plugin-node-resolve": "15.2.4", - "@types/node": "20.16.6", + "@rollup/plugin-node-resolve": "15.3.0", + "@types/node": "20.16.9", "esbuild": "0.24.0", "rimraf": "6.0.1", "rollup": "2.79.1", diff --git a/packages/grafana-flamegraph/package.json b/packages/grafana-flamegraph/package.json index 71e97d340a0..0849c031faf 100644 --- a/packages/grafana-flamegraph/package.json +++ b/packages/grafana-flamegraph/package.json @@ -60,7 +60,7 @@ "@babel/preset-env": "7.25.4", "@babel/preset-react": "7.24.7", "@grafana/tsconfig": "^2.0.0", - "@rollup/plugin-node-resolve": "15.2.4", + "@rollup/plugin-node-resolve": "15.3.0", "@testing-library/dom": "10.0.0", "@testing-library/jest-dom": "^6.1.2", "@testing-library/react": "15.0.2", @@ -68,7 +68,7 @@ "@types/d3": "^7", "@types/jest": "^29.5.4", "@types/lodash": "4.17.9", - "@types/node": "20.16.6", + "@types/node": "20.16.9", "@types/react": "18.3.3", "@types/react-virtualized-auto-sizer": "1.0.4", "@types/tinycolor2": "1.4.6", diff --git a/packages/grafana-icons/package.json b/packages/grafana-icons/package.json index 639d00f6d22..056688c86d7 100644 --- a/packages/grafana-icons/package.json +++ b/packages/grafana-icons/package.json @@ -45,7 +45,7 @@ "@svgr/plugin-prettier": "^8.1.0", "@svgr/plugin-svgo": "^8.1.0", "@types/babel__core": "^7", - "@types/node": "20.16.6", + "@types/node": "20.16.9", "@types/react": "18.3.3", "@types/react-dom": "18.2.25", "esbuild": "0.24.0", diff --git a/packages/grafana-o11y-ds-frontend/package.json b/packages/grafana-o11y-ds-frontend/package.json index d5daba87c91..f6c5c6cb8ca 100644 --- a/packages/grafana-o11y-ds-frontend/package.json +++ b/packages/grafana-o11y-ds-frontend/package.json @@ -36,7 +36,7 @@ "@testing-library/react": "15.0.2", "@testing-library/user-event": "14.5.2", "@types/jest": "^29.5.4", - "@types/node": "20.16.6", + "@types/node": "20.16.9", "@types/react": "18.3.3", "@types/systemjs": "6.15.1", "@types/testing-library__jest-dom": "5.14.9", diff --git a/packages/grafana-plugin-configs/package.json b/packages/grafana-plugin-configs/package.json index 46e4e985bcb..fa204ae3c75 100644 --- a/packages/grafana-plugin-configs/package.json +++ b/packages/grafana-plugin-configs/package.json @@ -18,7 +18,7 @@ "replace-in-file-webpack-plugin": "1.0.6", "swc-loader": "0.2.6", "typescript": "5.5.4", - "webpack": "5.94.0" + "webpack": "5.95.0" }, "packageManager": "yarn@4.5.0" } diff --git a/packages/grafana-prometheus/package.json b/packages/grafana-prometheus/package.json index 418e5140555..ad5afa4225b 100644 --- a/packages/grafana-prometheus/package.json +++ b/packages/grafana-prometheus/package.json @@ -40,7 +40,7 @@ "@floating-ui/react": "0.26.24", "@grafana/data": "11.3.0-pre", "@grafana/experimental": "1.8.0", - "@grafana/faro-web-sdk": "1.10.0", + "@grafana/faro-web-sdk": "1.10.1", "@grafana/runtime": "11.3.0-pre", "@grafana/schema": "11.3.0-pre", "@grafana/ui": "11.3.0-pre", @@ -79,7 +79,7 @@ "@grafana/e2e-selectors": "11.3.0-pre", "@grafana/tsconfig": "^2.0.0", "@rollup/plugin-image": "3.0.3", - "@rollup/plugin-node-resolve": "15.2.4", + "@rollup/plugin-node-resolve": "15.3.0", "@swc/core": "1.4.2", "@swc/helpers": "0.5.13", "@testing-library/dom": "10.0.0", @@ -90,9 +90,9 @@ "@types/debounce-promise": "3.1.9", "@types/eslint": "8.56.10", "@types/jest": "29.5.13", - "@types/jquery": "3.5.30", + "@types/jquery": "3.5.31", "@types/lodash": "4.17.9", - "@types/node": "20.16.6", + "@types/node": "20.16.9", "@types/pluralize": "^0.0.33", "@types/prismjs": "1.26.4", "@types/react": "18.3.3", @@ -131,13 +131,13 @@ "rollup-plugin-dts": "^5.0.0", "rollup-plugin-esbuild": "5.0.0", "rollup-plugin-node-externals": "^5.0.0", - "sass": "1.78.0", + "sass": "1.79.3", "sass-loader": "14.2.1", "style-loader": "4.0.0", "testing-library-selector": "0.3.1", "ts-node": "10.9.2", "typescript": "5.5.4", - "webpack": "5.94.0", + "webpack": "5.95.0", "webpack-cli": "5.1.4" }, "peerDependencies": { diff --git a/packages/grafana-prometheus/src/datasource.ts b/packages/grafana-prometheus/src/datasource.ts index f75aa985b1a..cb416fa8f0c 100644 --- a/packages/grafana-prometheus/src/datasource.ts +++ b/packages/grafana-prometheus/src/datasource.ts @@ -141,6 +141,7 @@ export class PrometheusDatasource this.cache = new QueryCache({ getTargetSignature: this.getPrometheusTargetSignature.bind(this), overlapString: instanceSettings.jsonData.incrementalQueryOverlapWindow ?? defaultPrometheusQueryOverlapWindow, + applyInterpolation: this.interpolateString.bind(this), }); // This needs to be here and cannot be static because of how annotations typing affects casting of data source diff --git a/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderOptions.tsx b/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderOptions.tsx index 60c73709c68..d79bb1716e0 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderOptions.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilderOptions.tsx @@ -92,7 +92,7 @@ export const PromQueryBuilderOptions = React.memo( minWidth={10} onCommitChange={onChangeStep} defaultValue={query.interval} - id={selectors.components.DataSource.Prometheus.queryEditor.step} + data-test-id="prometheus-step" /> @@ -112,7 +112,7 @@ export const PromQueryBuilderOptions = React.memo( )} diff --git a/packages/grafana-prometheus/src/querycache/QueryCache.test.ts b/packages/grafana-prometheus/src/querycache/QueryCache.test.ts index 8d6fd349a84..01cd25fd14f 100644 --- a/packages/grafana-prometheus/src/querycache/QueryCache.test.ts +++ b/packages/grafana-prometheus/src/querycache/QueryCache.test.ts @@ -6,7 +6,7 @@ import { DataFrame, DataQueryRequest, DateTime, dateTime, TimeRange } from '@gra import { QueryEditorMode } from '../querybuilder/shared/types'; import { PromQuery } from '../types'; -import { CacheRequestInfo, QueryCache } from './QueryCache'; +import { CacheRequestInfo, findDatapointStep, QueryCache } from './QueryCache'; import { IncrementalStorageDataFrameScenarios, trimmedFirstPointInPromFrames } from './QueryCacheTestData'; // Will not interpolate vars! @@ -559,3 +559,14 @@ describe('QueryCache: Prometheus', function () { expect(cacheRequest.shouldCache).toBe(true); }); }); + +describe('findDataPointStep', () => { + it('should interpolate custom interval', () => { + const mockApplyInterpolation = jest.fn().mockImplementation(() => '1m'); + const req = mockPromRequest(); + req.targets[0].interval = '$interval'; + const respFrames = trimmedFirstPointInPromFrames as unknown as DataFrame[]; + findDatapointStep(req, respFrames, mockApplyInterpolation); + expect(mockApplyInterpolation).toBeCalledTimes(1); + }); +}); diff --git a/packages/grafana-prometheus/src/querycache/QueryCache.ts b/packages/grafana-prometheus/src/querycache/QueryCache.ts index 9b9c308e022..9b06746b7cb 100644 --- a/packages/grafana-prometheus/src/querycache/QueryCache.ts +++ b/packages/grafana-prometheus/src/querycache/QueryCache.ts @@ -10,6 +10,7 @@ import { isValidDuration, parseDuration, rangeUtil, + ScopedVars, Table, trimTable, } from '@grafana/data'; @@ -23,10 +24,9 @@ type TargetIdent = string; // query + template variables + interval + raw time range // used for full target cache busting -> full range re-query type TargetSig = string; - type TimestampMs = number; - type SupportedQueryTypes = PromQuery; +type ApplyInterpolation = (str: string, scopedVars?: ScopedVars) => string; // string matching requirements defined in durationutil.ts export const defaultPrometheusQueryOverlapWindow = '10m'; @@ -59,12 +59,14 @@ export const getFieldIdent = (field: Field) => `${field.type}|${field.name}|${JS export class QueryCache { private overlapWindowMs: number; private getTargetSignature: (request: DataQueryRequest, target: T) => string; + private applyInterpolation = (str: string, scopedVars?: ScopedVars) => str; cache = new Map(); constructor(options: { getTargetSignature: (request: DataQueryRequest, target: T) => string; overlapString: string; + applyInterpolation?: ApplyInterpolation; }) { const unverifiedOverlap = options.overlapString; if (isValidDuration(unverifiedOverlap)) { @@ -76,6 +78,9 @@ export class QueryCache { } this.getTargetSignature = options.getTargetSignature; + if (options.applyInterpolation) { + this.applyInterpolation = options.applyInterpolation; + } } // can be used to change full range request to partial, split into multiple requests @@ -221,7 +226,7 @@ export class QueryCache { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions let table: Table = frame.fields.map((field) => field.values) as Table; - const dataPointStep = findDatapointStep(request, respFrames); + const dataPointStep = findDatapointStep(request, respFrames, this.applyInterpolation); // query interval is greater than request.intervalMs, use query interval to make sure we've always got one datapoint outside the panel viewport let trimmed = trimTable(table, newFrom - dataPointStep, newTo); @@ -260,7 +265,11 @@ export class QueryCache { } } -function findDatapointStep(request: DataQueryRequest, respFrames: DataFrame[]): number { +export function findDatapointStep( + request: DataQueryRequest, + respFrames: DataFrame[], + applyInterpolation: ApplyInterpolation +): number { // Prometheus specific logic below if (request.targets[0].datasource?.type !== 'prometheus') { return 0; @@ -270,7 +279,7 @@ function findDatapointStep(request: DataQueryRequest, respFrames: Dat let dataPointStep = request.intervalMs; if (target?.interval) { - const minStepMs = rangeUtil.intervalToMs(target.interval); + const minStepMs = rangeUtil.intervalToMs(applyInterpolation(target.interval)); if (minStepMs > request.intervalMs) { dataPointStep = minStepMs; } diff --git a/packages/grafana-runtime/package.json b/packages/grafana-runtime/package.json index 631c9128195..f773eb75175 100644 --- a/packages/grafana-runtime/package.json +++ b/packages/grafana-runtime/package.json @@ -49,7 +49,7 @@ }, "devDependencies": { "@grafana/tsconfig": "^2.0.0", - "@rollup/plugin-node-resolve": "15.2.4", + "@rollup/plugin-node-resolve": "15.3.0", "@rollup/plugin-terser": "0.4.4", "@testing-library/dom": "10.0.0", "@testing-library/react": "15.0.2", diff --git a/packages/grafana-schema/package.json b/packages/grafana-schema/package.json index 1e5338c51b5..39c8d34ab20 100644 --- a/packages/grafana-schema/package.json +++ b/packages/grafana-schema/package.json @@ -37,7 +37,7 @@ }, "devDependencies": { "@grafana/tsconfig": "^2.0.0", - "@rollup/plugin-node-resolve": "15.2.4", + "@rollup/plugin-node-resolve": "15.3.0", "esbuild": "0.24.0", "glob": "^11.0.0", "rimraf": "6.0.1", diff --git a/packages/grafana-sql/package.json b/packages/grafana-sql/package.json index 51c6ff2ad75..409e725d56e 100644 --- a/packages/grafana-sql/package.json +++ b/packages/grafana-sql/package.json @@ -42,7 +42,7 @@ "@testing-library/user-event": "14.5.2", "@types/jest": "^29.5.4", "@types/lodash": "4.17.9", - "@types/node": "20.16.6", + "@types/node": "20.16.9", "@types/react": "18.3.3", "@types/react-dom": "18.2.25", "@types/react-virtualized-auto-sizer": "1.0.4", diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 0abc18b87d8..3c82160a6fc 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -63,7 +63,7 @@ "@react-aria/overlays": "3.23.2", "@react-aria/utils": "3.25.2", "@tanstack/react-virtual": "^3.5.1", - "@types/jquery": "3.5.30", + "@types/jquery": "3.5.31", "@types/lodash": "4.17.9", "@types/react-table": "7.7.20", "ansicolor": "1.1.100", @@ -97,7 +97,7 @@ "react-hook-form": "^7.49.2", "react-i18next": "^14.0.0", "react-inlinesvg": "3.0.2", - "react-loading-skeleton": "3.4.0", + "react-loading-skeleton": "3.5.0", "react-router-dom-v5-compat": "^6.26.1", "react-select": "5.8.1", "react-table": "7.8.0", @@ -117,7 +117,7 @@ "@babel/core": "7.25.2", "@faker-js/faker": "^8.4.1", "@grafana/tsconfig": "^2.0.0", - "@rollup/plugin-node-resolve": "15.2.4", + "@rollup/plugin-node-resolve": "15.3.0", "@storybook/addon-a11y": "^8.1.6", "@storybook/addon-actions": "^8.1.6", "@storybook/addon-docs": "^8.1.6", @@ -145,7 +145,7 @@ "@types/is-hotkey": "0.1.10", "@types/jest": "29.5.13", "@types/mock-raf": "1.0.6", - "@types/node": "20.16.6", + "@types/node": "20.16.9", "@types/prismjs": "1.26.4", "@types/react": "18.3.3", "@types/react-color": "3.0.12", @@ -185,7 +185,7 @@ "storybook-dark-mode": "^4.0.1", "style-loader": "4.0.0", "typescript": "5.5.4", - "webpack": "5.94.0" + "webpack": "5.95.0" }, "peerDependencies": { "react": "^18.0.0", diff --git a/packages/grafana-ui/src/components/BigValue/BigValue.test.tsx b/packages/grafana-ui/src/components/BigValue/BigValue.test.tsx index ff682372578..e7819cd110d 100644 --- a/packages/grafana-ui/src/components/BigValue/BigValue.test.tsx +++ b/packages/grafana-ui/src/components/BigValue/BigValue.test.tsx @@ -41,7 +41,7 @@ describe('BigValue', () => { /> ); - expect(screen.getByText('50%')).toBeInTheDocument(); + expect(screen.getByText('0.5%')).toBeInTheDocument(); }); it('should render without percent change', () => { diff --git a/packages/grafana-ui/src/components/BigValue/PercentChange.tsx b/packages/grafana-ui/src/components/BigValue/PercentChange.tsx index 24de61cbbc6..1e9c97a2ee2 100644 --- a/packages/grafana-ui/src/components/BigValue/PercentChange.tsx +++ b/packages/grafana-ui/src/components/BigValue/PercentChange.tsx @@ -27,6 +27,7 @@ export const PercentChange = ({ percentChange, styles }: Props) => { ); }; +// percentChange is expected to be a value between 0-100 export const percentChangeString = (percentChange: number) => { - return percentChange.toLocaleString(undefined, { style: 'percent', maximumSignificantDigits: 3 }); + return (percentChange / 100).toLocaleString(undefined, { style: 'percent', maximumSignificantDigits: 3 }); }; diff --git a/packages/grafana-ui/src/components/Combobox/Combobox.tsx b/packages/grafana-ui/src/components/Combobox/Combobox.tsx index cb8a5f7bbba..33eeed308eb 100644 --- a/packages/grafana-ui/src/components/Combobox/Combobox.tsx +++ b/packages/grafana-ui/src/components/Combobox/Combobox.tsx @@ -1,8 +1,7 @@ import { cx } from '@emotion/css'; -import { autoUpdate, flip, size, useFloating } from '@floating-ui/react'; import { useVirtualizer } from '@tanstack/react-virtual'; import { useCombobox } from 'downshift'; -import { SetStateAction, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; +import { useCallback, useId, useMemo, useState } from 'react'; import { useStyles2 } from '../../themes'; import { t } from '../../utils/i18n'; @@ -10,6 +9,7 @@ import { Icon } from '../Icon/Icon'; import { Input, Props as InputProps } from '../Input/Input'; import { getComboboxStyles } from './getComboboxStyles'; +import { estimateSize, useComboboxFloat } from './useComboboxFloat'; export type ComboboxOption = { label: string; @@ -42,16 +42,6 @@ function itemFilter(inputValue: string) { }; } -function estimateSize() { - return 45; -} - -const MIN_HEIGHT = 400; -// On every 100th index we will recalculate the width of the popover. -const INDEX_WIDTH_CALCULATION = 100; -// A multiplier guesstimate times the amount of characters. If any padding or image support etc. is added this will need to be updated. -const WIDTH_MULTIPLIER = 7.3; - /** * A performant Select replacement. * @@ -97,15 +87,10 @@ export const Combobox = ({ return null; }, [selectedItemIndex, options, value]); - const inputRef = useRef(null); - const floatingRef = useRef(null); - const menuId = `downshift-${useId().replace(/:/g, '--')}-menu`; const labelId = `downshift-${useId().replace(/:/g, '--')}-label`; const styles = useStyles2(getComboboxStyles); - const [popoverMaxWidth, setPopoverMaxWidth] = useState(undefined); - const [popoverWidth, setPopoverWidth] = useState(undefined); const virtualizerOptions = { count: items.length, @@ -167,38 +152,12 @@ export const Combobox = ({ } }, }); + const { inputRef, floatingRef, floatStyles } = useComboboxFloat(items, rowVirtualizer.range, isOpen); const onBlur = useCallback(() => { setInputValue(selectedItem?.label ?? value?.toString() ?? ''); }, [selectedItem, setInputValue, value]); - // the order of middleware is important! - const middleware = [ - flip({ - // see https://floating-ui.com/docs/flip#combining-with-shift - crossAxis: true, - boundary: document.body, - }), - size({ - apply({ availableWidth }) { - setPopoverMaxWidth(availableWidth); - }, - }), - ]; - const elements = { reference: inputRef.current, floating: floatingRef.current }; - const { floatingStyles } = useFloating({ - strategy: 'fixed', - open: isOpen, - placement: 'bottom-start', - middleware, - elements, - whileElementsMounted: autoUpdate, - }); - - const hasMinHeight = isOpen && rowVirtualizer.getTotalSize() >= MIN_HEIGHT; - - useDynamicWidth(items, rowVirtualizer.range, setPopoverWidth); - return (
({ })} />
({
); }; - -const useDynamicWidth = ( - items: Array>, - range: { startIndex: number; endIndex: number } | null, - setPopoverWidth: { (value: SetStateAction): void } -) => { - useEffect(() => { - if (range === null) { - return; - } - const startVisibleIndex = range?.startIndex; - const endVisibleIndex = range?.endIndex; - - if (typeof startVisibleIndex === 'undefined' || typeof endVisibleIndex === 'undefined') { - return; - } - - // Scroll down and default case - if ( - startVisibleIndex === 0 || - (startVisibleIndex % INDEX_WIDTH_CALCULATION === 0 && startVisibleIndex >= INDEX_WIDTH_CALCULATION) - ) { - let maxLength = 0; - const calculationEnd = Math.min(items.length, endVisibleIndex + INDEX_WIDTH_CALCULATION); - - for (let i = startVisibleIndex; i < calculationEnd; i++) { - maxLength = Math.max(maxLength, items[i].label.length); - } - - setPopoverWidth(maxLength * WIDTH_MULTIPLIER); - } else if (endVisibleIndex % INDEX_WIDTH_CALCULATION === 0 && endVisibleIndex >= INDEX_WIDTH_CALCULATION) { - // Scroll up case - let maxLength = 0; - const calculationStart = Math.max(0, startVisibleIndex - INDEX_WIDTH_CALCULATION); - - for (let i = calculationStart; i < endVisibleIndex; i++) { - maxLength = Math.max(maxLength, items[i].label.length); - } - - setPopoverWidth(maxLength * WIDTH_MULTIPLIER); - } - }, [items, range, setPopoverWidth]); -}; diff --git a/packages/grafana-ui/src/components/Combobox/getComboboxStyles.ts b/packages/grafana-ui/src/components/Combobox/getComboboxStyles.ts index ae5c94bc409..d6de3c76dfd 100644 --- a/packages/grafana-ui/src/components/Combobox/getComboboxStyles.ts +++ b/packages/grafana-ui/src/components/Combobox/getComboboxStyles.ts @@ -2,6 +2,8 @@ import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; +const MAX_HEIGHT = 400; + export const getComboboxStyles = (theme: GrafanaTheme2) => { return { menuClosed: css({ @@ -12,10 +14,8 @@ export const getComboboxStyles = (theme: GrafanaTheme2) => { background: theme.components.dropdown.background, boxShadow: theme.shadows.z3, zIndex: theme.zIndex.dropdown, - }), - menuHeight: css({ - height: 400, - overflowY: 'scroll', + maxHeight: MAX_HEIGHT, + overflowY: 'auto', position: 'relative', }), menuUlContainer: css({ diff --git a/packages/grafana-ui/src/components/Combobox/useComboboxFloat.ts b/packages/grafana-ui/src/components/Combobox/useComboboxFloat.ts new file mode 100644 index 00000000000..bdac3088f96 --- /dev/null +++ b/packages/grafana-ui/src/components/Combobox/useComboboxFloat.ts @@ -0,0 +1,96 @@ +import { autoUpdate, flip, size, useFloating } from '@floating-ui/react'; +import { useEffect, useRef, useState } from 'react'; + +import { ComboboxOption } from './Combobox'; + +// On every 100th index we will recalculate the width of the popover. +const INDEX_WIDTH_CALCULATION = 100; +// A multiplier guesstimate times the amount of characters. If any padding or image support etc. is added this will need to be updated. +const WIDTH_MULTIPLIER = 7.3; + +/** + * Used with Downshift to get the height of each item + */ +export function estimateSize() { + return 45; +} + +export const useComboboxFloat = ( + items: Array>, + range: { startIndex: number; endIndex: number } | null, + isOpen: boolean +) => { + const inputRef = useRef(null); + const floatingRef = useRef(null); + const [popoverWidth, setPopoverWidth] = useState(undefined); + const [popoverMaxWidth, setPopoverMaxWidth] = useState(undefined); + + // the order of middleware is important! + const middleware = [ + flip({ + // see https://floating-ui.com/docs/flip#combining-with-shift + crossAxis: true, + boundary: document.body, + }), + size({ + apply({ availableWidth }) { + setPopoverMaxWidth(availableWidth); + }, + }), + ]; + const elements = { reference: inputRef.current, floating: floatingRef.current }; + const { floatingStyles } = useFloating({ + strategy: 'fixed', + open: isOpen, + placement: 'bottom-start', + middleware, + elements, + whileElementsMounted: autoUpdate, + }); + + useEffect(() => { + if (range === null) { + return; + } + const startVisibleIndex = range?.startIndex; + const endVisibleIndex = range?.endIndex; + + if (typeof startVisibleIndex === 'undefined' || typeof endVisibleIndex === 'undefined') { + return; + } + + // Scroll down and default case + if ( + startVisibleIndex === 0 || + (startVisibleIndex % INDEX_WIDTH_CALCULATION === 0 && startVisibleIndex >= INDEX_WIDTH_CALCULATION) + ) { + let maxLength = 0; + const calculationEnd = Math.min(items.length, endVisibleIndex + INDEX_WIDTH_CALCULATION); + + for (let i = startVisibleIndex; i < calculationEnd; i++) { + maxLength = Math.max(maxLength, items[i].label.length); + } + + setPopoverWidth(maxLength * WIDTH_MULTIPLIER); + } else if (endVisibleIndex % INDEX_WIDTH_CALCULATION === 0 && endVisibleIndex >= INDEX_WIDTH_CALCULATION) { + // Scroll up case + let maxLength = 0; + const calculationStart = Math.max(0, startVisibleIndex - INDEX_WIDTH_CALCULATION); + + for (let i = calculationStart; i < endVisibleIndex; i++) { + maxLength = Math.max(maxLength, items[i].label.length); + } + + setPopoverWidth(maxLength * WIDTH_MULTIPLIER); + } + }, [items, range, setPopoverWidth]); + + const floatStyles = { + ...floatingStyles, + width: popoverWidth, + maxWidth: popoverMaxWidth, + minWidth: inputRef.current?.offsetWidth, + }; + + return { inputRef, floatingRef, floatStyles }; +}; diff --git a/packages/grafana-ui/src/components/ConfirmModal/ConfirmContent.tsx b/packages/grafana-ui/src/components/ConfirmModal/ConfirmContent.tsx index b63f2ffe7ad..27b94d395c0 100644 --- a/packages/grafana-ui/src/components/ConfirmModal/ConfirmContent.tsx +++ b/packages/grafana-ui/src/components/ConfirmModal/ConfirmContent.tsx @@ -77,11 +77,15 @@ export const ConfirmContent = ({ }, [confirmPromptText, disabled]); const onConfirmClick = async () => { - setIsDisabled(true); + if (disabled === undefined) { + setIsDisabled(true); + } try { await onConfirm(); } finally { - setIsDisabled(false); + if (disabled === undefined) { + setIsDisabled(false); + } } }; diff --git a/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.test.tsx b/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.test.tsx index 53ac68e20ad..a667bc2ee80 100644 --- a/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.test.tsx +++ b/packages/grafana-ui/src/components/ConfirmModal/ConfirmModal.test.tsx @@ -1,8 +1,11 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; import { ConfirmModal } from './ConfirmModal'; +jest.useFakeTimers(); + describe('ConfirmModal', () => { const mockOnConfirm = jest.fn(); @@ -170,4 +173,56 @@ describe('ConfirmModal', () => { return expect(screen.getByRole('button', { name: 'Please Confirm' })).toBeEnabled(); }); }); + + it('should disable the confirm button when disabled prop changes from false to true', async () => { + const TestComponent = () => { + const [disabled, setDisabled] = useState(false); + + const handleConfirm = async () => { + act(() => { + setDisabled(true); + setTimeout(() => { + setDisabled(false); + }, 4000); + }); + }; + + return ( + {}} + onAlternative={() => {}} + disabled={disabled} + /> + ); + }; + + render(); + + const confirmButton = screen.getByRole('button', { name: 'Please Confirm' }); + + expect(confirmButton).toBeEnabled(); + + fireEvent.click(confirmButton); + + // Ensure React processes the state update and calls useEffect in ConfirmModal + await act(() => { + jest.advanceTimersByTime(0); + }); + + expect(confirmButton).toBeDisabled(); + + // Fast-forward time by 4 seconds + await act(() => { + jest.advanceTimersByTime(4000); + }); + + await waitFor(() => { + expect(confirmButton).toBeEnabled(); + }); + }); }); diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.test.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.test.tsx index d6a7aa1c35e..99f9df99a20 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.test.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.test.tsx @@ -76,6 +76,23 @@ describe('TimeRangeForm', () => { expect(getByLabelText('To')).toHaveValue(toValue); }); + it('should parse UTC iso strings and render in current timezone', () => { + const { getByLabelText } = setup( + { + from: defaultTimeRange.from, + to: defaultTimeRange.to, + raw: { + from: defaultTimeRange.from.toISOString(), + to: defaultTimeRange.to.toISOString(), + }, + }, + 'America/New_York' + ); + + expect(getByLabelText('From')).toHaveValue('2021-06-16 20:00:00'); + expect(getByLabelText('To')).toHaveValue('2021-06-19 19:59:00'); + }); + it('should close calendar when clicking the close icon', () => { const { queryByLabelText, getAllByRole, getByRole } = setup(); const { TimePicker } = selectors.components; diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.tsx index 87f5572c586..a23da6f7a62 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimeRangeContent.tsx @@ -267,6 +267,12 @@ function valueAsString(value: DateTime | string, timeZone?: TimeZone): string { if (isDateTime(value)) { return dateTimeFormat(value, { timeZone }); } + + if (value.endsWith('Z')) { + const dt = dateTimeParse(value, { timeZone: 'utc' }); + return dateTimeFormat(dt, { timeZone }); + } + return value; } diff --git a/packages/grafana-ui/src/components/Drawer/Drawer.tsx b/packages/grafana-ui/src/components/Drawer/Drawer.tsx index a929cb59c22..df030a19d28 100644 --- a/packages/grafana-ui/src/components/Drawer/Drawer.tsx +++ b/packages/grafana-ui/src/components/Drawer/Drawer.tsx @@ -256,17 +256,7 @@ const getStyles = (theme: GrafanaTheme2) => { position: 'relative', }), drawer: css({ - '.main-view &': { - top: 80, - }, - - '.main-view--search-bar-hidden &': { - top: 40, - }, - - '.main-view--chrome-hidden &': { - top: 0, - }, + top: 0, '.rc-drawer-content-wrapper': { boxShadow: theme.shadows.z3, @@ -307,18 +297,7 @@ const getStyles = (theme: GrafanaTheme2) => { left: 0, position: 'fixed', right: 0, - - '.main-view &': { - top: 80, - }, - - '.main-view--search-bar-hidden &': { - top: 40, - }, - - '.main-view--chrome-hidden &': { - top: 0, - }, + top: 0, }, }), maskMotion: css({ diff --git a/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.test.ts b/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.test.ts index 0fc3a03ab5b..490945ed908 100644 --- a/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.test.ts +++ b/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.test.ts @@ -249,3 +249,29 @@ describe('sharedSingleStatMigrationHandler', () => { expect(panel.fieldConfig.defaults.max).toBe(1); }); }); + +describe('BarGauge migrations', () => { + it('Should migrate from old graph', () => { + const old = { + angular: { + xaxis: { + mode: 'series', + }, + legend: { + show: true, + values: true, + min: false, + max: true, + current: true, + total: false, + avg: false, + }, + }, + }; + + const panel = {} as PanelModel; + panel.options = sharedSingleStatPanelChangedHandler(panel, 'graph', old); + expect(panel.options.legend.showLegend).toBe(true); + expect(panel.options.legend.calcs).toHaveLength(2); + }); +}); diff --git a/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.ts b/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.ts index 49ce29f6818..37f4fe00e04 100644 --- a/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.ts +++ b/packages/grafana-ui/src/components/SingleStatShared/SingleStatBaseOptions.ts @@ -1,4 +1,4 @@ -import { cloneDeep, isNumber, omit } from 'lodash'; +import { cloneDeep, identity, isNumber, omit, pickBy } from 'lodash'; import { convertOldAngularValueMappings, @@ -16,7 +16,7 @@ import { ValueMapping, VizOrientation, } from '@grafana/data'; -import { OptionsWithTextFormatting } from '@grafana/schema'; +import { LegendDisplayMode, OptionsWithLegend, OptionsWithTextFormatting } from '@grafana/schema'; export interface SingleStatBaseOptions extends OptionsWithTextFormatting { reduceOptions: ReduceDataOptions; @@ -25,6 +25,7 @@ export interface SingleStatBaseOptions extends OptionsWithTextFormatting { const optionsToKeep = ['reduceOptions', 'orientation']; +// eslint-disable-next-line @typescript-eslint/no-explicit-any export function sharedSingleStatPanelChangedHandler( panel: PanelModel> | any, prevPluginId: string, @@ -40,6 +41,9 @@ export function sharedSingleStatPanelChangedHandler( // Migrating from angular singlestat if (prevPluginId === 'singlestat' && prevOptions.angular) { return migrateFromAngularSinglestat(panel, prevOptions); + } else if (prevPluginId === 'graph') { + // Migrating from Graph panel + return migrateFromGraphPanel(panel, prevOptions); } for (const k of optionsToKeep) { @@ -51,6 +55,65 @@ export function sharedSingleStatPanelChangedHandler( return options; } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function migrateFromGraphPanel(panel: PanelModel> | any, prevOptions: any) { + const graphOptions: GraphOptions = prevOptions.angular; + + const options: SingleStatBaseOptions & OptionsWithLegend = { + orientation: VizOrientation.Auto, + reduceOptions: { + values: false, + calcs: [], + }, + legend: { + displayMode: LegendDisplayMode.List, + showLegend: true, + placement: 'bottom', + calcs: [], + }, + }; + + if (graphOptions.xaxis?.mode === 'series') { + panel.fieldConfig = { + ...panel.fieldConfig, + defaults: { + ...panel.fieldConfig.defaults, + color: { mode: 'palette-classic' }, + }, + }; + + // Value options calculation migration + if (graphOptions.xaxis.values) { + options.reduceOptions.calcs = getReducerForMigration(graphOptions.xaxis.values); + } + + // Legend migration + const legendConfig = graphOptions.legend; + if (legendConfig) { + if (legendConfig.show) { + options.legend.displayMode = legendConfig.alignAsTable ? LegendDisplayMode.Table : LegendDisplayMode.List; + } else { + options.legend.showLegend = false; + } + + if (legendConfig.rightSide) { + options.legend.placement = 'right'; + } + + if (legendConfig.values) { + const enabledLegendValues = pickBy(legendConfig, identity); + options.legend.calcs = getReducersFromLegend(enabledLegendValues); + } + + if (legendConfig.sideWidth) { + options.legend.width = legendConfig.sideWidth; + } + } + } + + return options; +} + function migrateFromAngularSinglestat(panel: PanelModel> | any, prevOptions: any) { const prevPanel = prevOptions.angular; const reducer = fieldReducers.getIfExists(prevPanel.valueName); @@ -331,3 +394,55 @@ export function migrateOldThresholds(thresholds?: any[]): Threshold[] | undefine export function convertOldAngularValueMapping(panel: any): ValueMapping[] { return convertOldAngularValueMappings(panel); } + +interface GraphOptions { + xaxis: { + mode: 'series' | 'time' | 'histogram'; + values?: string[]; + }; + legend: { + show: boolean; + alignAsTable: boolean; + rightSide: boolean; + values: boolean; + min?: boolean; + max?: boolean; + avg?: boolean; + current?: boolean; + total?: boolean; + sideWidth?: number; + }; +} + +function getReducersFromLegend(obj: Record): string[] { + const ids: string[] = []; + for (const key in obj) { + const reducer = fieldReducers.getIfExists(key); + if (reducer) { + ids.push(reducer.id); + } + } + return ids; +} + +// same as public/app/plugins/panel/barchart/migrations.ts +function getReducerForMigration(reducers: string[] | undefined) { + const transformReducers: string[] = []; + + reducers?.forEach((reducer) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + if (!Object.values(ReducerID).includes(reducer as ReducerID)) { + if (reducer === 'current') { + transformReducers.push(ReducerID.lastNotNull); + } else if (reducer === 'total') { + transformReducers.push(ReducerID.sum); + } else if (reducer === 'avg') { + transformReducers.push(ReducerID.mean); + } + } else { + transformReducers.push(reducer); + } + }); + + return reducers ? transformReducers : [ReducerID.sum]; +} diff --git a/packages/grafana-ui/src/components/Table/SparklineCell.tsx b/packages/grafana-ui/src/components/Table/SparklineCell.tsx index 3c047c0fdf0..0d6c26c4a1d 100644 --- a/packages/grafana-ui/src/components/Table/SparklineCell.tsx +++ b/packages/grafana-ui/src/components/Table/SparklineCell.tsx @@ -8,6 +8,7 @@ import { isDataFrame, Field, isDataFrameWithValue, + formattedValueToString, } from '@grafana/data'; import { BarAlignment, @@ -92,9 +93,7 @@ export const SparklineCell = (props: TableCellProps) => { const displayValue = field.display!(value); const alignmentFactor = getAlignmentFactor(field, displayValue, cell.row.index); - valueWidth = - measureText(`${alignmentFactor.prefix ?? ''}${alignmentFactor.text}${alignmentFactor.suffix ?? ''}`, 16).width + - theme.spacing.gridSize; + valueWidth = measureText(formattedValueToString(alignmentFactor), 16).width + theme.spacing.gridSize; valueElement = ( alignmentFactor.text.length) { + if (formattedValueToString(alignmentFactor).length > formattedValueToString(nextDisplayValue).length) { alignmentFactor.text = displayValue.text; } } diff --git a/packages/grafana-ui/src/components/ToolbarButton/ToolbarButton.tsx b/packages/grafana-ui/src/components/ToolbarButton/ToolbarButton.tsx index b99e176a142..05a3c5c5bdb 100644 --- a/packages/grafana-ui/src/components/ToolbarButton/ToolbarButton.tsx +++ b/packages/grafana-ui/src/components/ToolbarButton/ToolbarButton.tsx @@ -224,6 +224,7 @@ const getStyles = (theme: GrafanaTheme2) => { flexGrow: 1, }), content: css({ + display: 'flex', flexGrow: 1, }), contentWithIcon: css({ diff --git a/packages/grafana-ui/src/components/VizTooltip/VizTooltipContent.tsx b/packages/grafana-ui/src/components/VizTooltip/VizTooltipContent.tsx index 66fa1d71b44..89273ea291e 100644 --- a/packages/grafana-ui/src/components/VizTooltip/VizTooltipContent.tsx +++ b/packages/grafana-ui/src/components/VizTooltip/VizTooltipContent.tsx @@ -59,7 +59,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ display: 'flex', flexDirection: 'column', flex: 1, - gap: 4, + gap: 2, borderTop: `1px solid ${theme.colors.border.medium}`, padding: theme.spacing(1), }), diff --git a/packages/grafana-ui/src/components/VizTooltip/VizTooltipHeader.tsx b/packages/grafana-ui/src/components/VizTooltip/VizTooltipHeader.tsx index d10180ac823..52a294b82ec 100644 --- a/packages/grafana-ui/src/components/VizTooltip/VizTooltipHeader.tsx +++ b/packages/grafana-ui/src/components/VizTooltip/VizTooltipHeader.tsx @@ -36,5 +36,6 @@ const getStyles = (theme: GrafanaTheme2) => ({ flexDirection: 'column', flex: 1, padding: theme.spacing(1), + lineHeight: 1, }), }); diff --git a/packages/grafana-ui/src/components/VizTooltip/VizTooltipWrapper.tsx b/packages/grafana-ui/src/components/VizTooltip/VizTooltipWrapper.tsx new file mode 100644 index 00000000000..4c803abc085 --- /dev/null +++ b/packages/grafana-ui/src/components/VizTooltip/VizTooltipWrapper.tsx @@ -0,0 +1,24 @@ +import { css, cx } from '@emotion/css'; +import React, { HTMLAttributes } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; + +import { useStyles2 } from '../../themes'; + +export interface Props extends HTMLAttributes { + children?: React.ReactNode; +} + +export const VizTooltipWrapper = ({ children, className }: Props) => { + const styles = useStyles2(getStyles); + + return
{children}
; +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + wrapper: css({ + display: 'flex', + flexDirection: 'column', + fontSize: theme.typography.bodySmall.fontSize, + }), +}); diff --git a/packages/grafana-ui/src/components/uPlot/plugins/CloseButton.tsx b/packages/grafana-ui/src/components/uPlot/plugins/CloseButton.tsx index 30212dcedc3..2430ae6a8b3 100644 --- a/packages/grafana-ui/src/components/uPlot/plugins/CloseButton.tsx +++ b/packages/grafana-ui/src/components/uPlot/plugins/CloseButton.tsx @@ -24,6 +24,6 @@ const getStyles = (theme: GrafanaTheme2) => css({ position: 'absolute', margin: '0px', - right: theme.spacing(1), - top: theme.spacing(1.25), + right: 5, + top: 6, }); diff --git a/pkg/api/api.go b/pkg/api/api.go index e79d338a912..a774627ba5d 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -443,39 +443,7 @@ func (hs *HTTPServer) registerRoutes() { apiRoute.Any("/datasources/uid/:uid/health", requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow), authorize(ac.EvalPermission(datasources.ActionQuery)), routing.Wrap(hs.CheckDatasourceHealthWithUID)) // Folders - // #TODO kubernetes folders: move this to its own function, add back auth part, add other routes - apiRoute.Group("/folders", func(folderRoute routing.RouteRegister) { - if hs.Features.IsEnabledGlobally(featuremgmt.FlagKubernetesFolders) { - // Use k8s client to implement legacy API - handler := newFolderK8sHandler(hs) - folderRoute.Get("/", handler.searchFolders) - folderRoute.Post("/", handler.createFolder) - folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) { - folderUidRoute.Get("/", handler.getFolder) - folderUidRoute.Delete("/", handler.deleteFolder) - folderUidRoute.Put("/:uid", handler.updateFolder) - }) - } else { - idScope := dashboards.ScopeFoldersProvider.GetResourceScope(ac.Parameter(":id")) - uidScope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(ac.Parameter(":uid")) - folderRoute.Get("/", authorize(ac.EvalPermission(dashboards.ActionFoldersRead)), routing.Wrap(hs.GetFolders)) - folderRoute.Get("/id/:id", authorize(ac.EvalPermission(dashboards.ActionFoldersRead, idScope)), routing.Wrap(hs.GetFolderByID)) - folderRoute.Post("/", authorize(ac.EvalPermission(dashboards.ActionFoldersCreate)), routing.Wrap(hs.CreateFolder)) - - folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) { - folderUidRoute.Get("/", authorize(ac.EvalPermission(dashboards.ActionFoldersRead, uidScope)), routing.Wrap(hs.GetFolderByUID)) - folderUidRoute.Put("/", authorize(ac.EvalPermission(dashboards.ActionFoldersWrite, uidScope)), routing.Wrap(hs.UpdateFolder)) - folderUidRoute.Post("/move", authorize(ac.EvalPermission(dashboards.ActionFoldersWrite, uidScope)), routing.Wrap(hs.MoveFolder)) - folderUidRoute.Delete("/", authorize(ac.EvalPermission(dashboards.ActionFoldersDelete, uidScope)), routing.Wrap(hs.DeleteFolder)) - folderUidRoute.Get("/counts", authorize(ac.EvalPermission(dashboards.ActionFoldersRead, uidScope)), routing.Wrap(hs.GetFolderDescendantCounts)) - - folderUidRoute.Group("/permissions", func(folderPermissionRoute routing.RouteRegister) { - folderPermissionRoute.Get("/", authorize(ac.EvalPermission(dashboards.ActionFoldersPermissionsRead, uidScope)), routing.Wrap(hs.GetFolderPermissionList)) - folderPermissionRoute.Post("/", authorize(ac.EvalPermission(dashboards.ActionFoldersPermissionsWrite, uidScope)), routing.Wrap(hs.UpdateFolderPermissions)) - }) - }) - } - }) + hs.registerFolderAPI(apiRoute, authorize) // Dashboard apiRoute.Group("/dashboards", func(dashboardRoute routing.RouteRegister) { diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go index f94fb4dd29d..6d0dc92df4a 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -191,6 +191,7 @@ func getContextHandler(t *testing.T, cfg *setting.Cfg) *contexthandler.ContextHa cfg, tracing.InitializeTracerForTest(), &authntest.FakeService{ExpectedIdentity: &authn.Identity{ID: "0", Type: claims.TypeAnonymous, SessionToken: &usertoken.UserToken{}}}, + featuremgmt.WithFeatures(), ) } diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index 617d836f22a..2ad87a053c6 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -820,7 +820,7 @@ func getDashboardShouldReturn200WithConfig(t *testing.T, sc *scenarioContext, pr features := featuremgmt.WithFeatures() var err error if dashboardStore == nil { - sql, cfg := db.InitTestReplDBWithCfg(t) + sql, cfg := db.InitTestDBWithCfg(t) quotaService := quotatest.New(false, nil) dashboardStore, err = database.ProvideDashboardStore(sql, cfg, features, tagimpl.ProvideService(sql), quotaService) require.NoError(t, err) diff --git a/pkg/api/folder.go b/pkg/api/folder.go index 1433e12422b..5456100cd0f 100644 --- a/pkg/api/folder.go +++ b/pkg/api/folder.go @@ -16,6 +16,7 @@ import ( "github.com/grafana/grafana/pkg/api/apierrors" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/response" + "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/apimachinery/identity" folderalpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" "github.com/grafana/grafana/pkg/infra/metrics" @@ -39,6 +40,42 @@ import ( const REDACTED = "redacted" +func (hs *HTTPServer) registerFolderAPI(apiRoute routing.RouteRegister, authorize func(accesscontrol.Evaluator) web.Handler) { + // #TODO add back auth part + apiRoute.Group("/folders", func(folderRoute routing.RouteRegister) { + if hs.Features.IsEnabledGlobally(featuremgmt.FlagKubernetesFolders) { + // Use k8s client to implement legacy API + handler := newFolderK8sHandler(hs) + folderRoute.Get("/", handler.searchFolders) + folderRoute.Post("/", handler.createFolder) + folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) { + folderUidRoute.Get("/", handler.getFolder) + folderUidRoute.Delete("/", handler.deleteFolder) + folderUidRoute.Put("/:uid", handler.updateFolder) + }) + } else { + idScope := dashboards.ScopeFoldersProvider.GetResourceScope(accesscontrol.Parameter(":id")) + uidScope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(accesscontrol.Parameter(":uid")) + folderRoute.Get("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead)), routing.Wrap(hs.GetFolders)) + folderRoute.Get("/id/:id", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, idScope)), routing.Wrap(hs.GetFolderByID)) + folderRoute.Post("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersCreate)), routing.Wrap(hs.CreateFolder)) + + folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) { + folderUidRoute.Get("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, uidScope)), routing.Wrap(hs.GetFolderByUID)) + folderUidRoute.Put("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersWrite, uidScope)), routing.Wrap(hs.UpdateFolder)) + folderUidRoute.Post("/move", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersWrite, uidScope)), routing.Wrap(hs.MoveFolder)) + folderUidRoute.Delete("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersDelete, uidScope)), routing.Wrap(hs.DeleteFolder)) + folderUidRoute.Get("/counts", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, uidScope)), routing.Wrap(hs.GetFolderDescendantCounts)) + + folderUidRoute.Group("/permissions", func(folderPermissionRoute routing.RouteRegister) { + folderPermissionRoute.Get("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersPermissionsRead, uidScope)), routing.Wrap(hs.GetFolderPermissionList)) + folderPermissionRoute.Post("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersPermissionsWrite, uidScope)), routing.Wrap(hs.UpdateFolderPermissions)) + }) + }) + } + }) +} + // swagger:route GET /folders folders getFolders // // Get all folders. @@ -680,18 +717,28 @@ func (fk8s *folderK8sHandler) createFolder(c *contextmodel.ReqContext) { if !ok { return // error is already sent } - cmd := folder.UpdateFolderCommand{} + cmd := folder.CreateFolderCommand{} if err := web.Bind(c.Req, &cmd); err != nil { c.JsonApiErr(http.StatusBadRequest, "bad request data", err) return } - obj := internalfolders.LegacyUpdateCommandToUnstructured(cmd) + obj, err := internalfolders.LegacyCreateCommandToUnstructured(cmd) + if err != nil { + fk8s.writeError(c, err) + return + } out, err := client.Create(c.Req.Context(), &obj, v1.CreateOptions{}) if err != nil { fk8s.writeError(c, err) return } - c.JSON(http.StatusOK, internalfolders.UnstructuredToLegacyFolderDTO(*out)) + + f, err := internalfolders.UnstructuredToLegacyFolderDTO(*out) + if err != nil { + fk8s.writeError(c, err) + return + } + c.JSON(http.StatusOK, f) } func (fk8s *folderK8sHandler) getFolder(c *contextmodel.ReqContext) { @@ -705,7 +752,14 @@ func (fk8s *folderK8sHandler) getFolder(c *contextmodel.ReqContext) { fk8s.writeError(c, err) return } - c.JSON(http.StatusOK, internalfolders.UnstructuredToLegacyFolderDTO(*out)) + + f, err := internalfolders.UnstructuredToLegacyFolderDTO(*out) + if err != nil { + fk8s.writeError(c, err) + return + } + + c.JSON(http.StatusOK, f) } func (fk8s *folderK8sHandler) deleteFolder(c *contextmodel.ReqContext) { @@ -740,7 +794,14 @@ func (fk8s *folderK8sHandler) updateFolder(c *contextmodel.ReqContext) { fk8s.writeError(c, err) return } - c.JSON(http.StatusOK, internalfolders.UnstructuredToLegacyFolderDTO(*out)) + + f, err := internalfolders.UnstructuredToLegacyFolderDTO(*out) + if err != nil { + fk8s.writeError(c, err) + return + } + + c.JSON(http.StatusOK, f) } //----------------------------------------------------------------------------------------- diff --git a/pkg/api/folder_bench_test.go b/pkg/api/folder_bench_test.go index 059a0260287..58b4b7040d1 100644 --- a/pkg/api/folder_bench_test.go +++ b/pkg/api/folder_bench_test.go @@ -69,7 +69,7 @@ const ( ) type benchScenario struct { - db db.ReplDB + db db.DB // signedInUser is the user that is signed in to the server cfg *setting.Cfg signedInUser *user.SignedInUser @@ -202,7 +202,7 @@ func BenchmarkFolderListAndSearch(b *testing.B) { func setupDB(b testing.TB) benchScenario { b.Helper() - db, cfg := sqlstore.InitTestReplDB(b) + db, cfg := sqlstore.InitTestDB(b) IDs := map[int64]struct{}{} opts := sqlstore.NativeSettingsForDialect(db.GetDialect()) @@ -451,26 +451,26 @@ func setupServer(b testing.TB, sc benchScenario, features featuremgmt.FeatureTog quotaSrv := quotatest.New(false, nil) - dashStore, err := database.ProvideDashboardStore(sc.db, sc.cfg, features, tagimpl.ProvideService(sc.db.DB()), quotaSrv) + dashStore, err := database.ProvideDashboardStore(sc.db, sc.cfg, features, tagimpl.ProvideService(sc.db), quotaSrv) require.NoError(b, err) - folderStore := folderimpl.ProvideDashboardFolderStore(sc.db.DB()) + folderStore := folderimpl.ProvideDashboardFolderStore(sc.db) ac := acimpl.ProvideAccessControl(featuremgmt.WithFeatures(), zanzana.NewNoopClient()) - folderServiceWithFlagOn := folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashStore, folderStore, sc.db.DB(), features, supportbundlestest.NewFakeBundleService(), nil, tracing.InitializeTracerForTest()) + folderServiceWithFlagOn := folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashStore, folderStore, sc.db, features, supportbundlestest.NewFakeBundleService(), nil, tracing.InitializeTracerForTest()) cfg := setting.NewCfg() actionSets := resourcepermissions.NewActionSetService(features) acSvc := acimpl.ProvideOSSService( sc.cfg, acdb.ProvideService(sc.db), actionSets, localcache.ProvideService(), - features, tracing.InitializeTracerForTest(), zanzana.NewNoopClient(), sc.db.DB(), permreg.ProvidePermissionRegistry(), + features, tracing.InitializeTracerForTest(), zanzana.NewNoopClient(), sc.db, permreg.ProvidePermissionRegistry(), ) folderPermissions, err := ossaccesscontrol.ProvideFolderPermissions( - cfg, features, routing.NewRouteRegister(), sc.db.DB(), ac, license, &dashboards.FakeDashboardStore{}, folderServiceWithFlagOn, acSvc, sc.teamSvc, sc.userSvc, actionSets) + cfg, features, routing.NewRouteRegister(), sc.db, ac, license, &dashboards.FakeDashboardStore{}, folderServiceWithFlagOn, acSvc, sc.teamSvc, sc.userSvc, actionSets) require.NoError(b, err) dashboardPermissions, err := ossaccesscontrol.ProvideDashboardPermissions( - cfg, features, routing.NewRouteRegister(), sc.db.DB(), ac, license, &dashboards.FakeDashboardStore{}, folderServiceWithFlagOn, acSvc, sc.teamSvc, sc.userSvc, actionSets) + cfg, features, routing.NewRouteRegister(), sc.db, ac, license, &dashboards.FakeDashboardStore{}, folderServiceWithFlagOn, acSvc, sc.teamSvc, sc.userSvc, actionSets) require.NoError(b, err) dashboardSvc, err := dashboardservice.ProvideDashboardServiceImpl( @@ -486,10 +486,10 @@ func setupServer(b testing.TB, sc benchScenario, features featuremgmt.FeatureTog hs := &HTTPServer{ CacheService: localcache.New(5*time.Minute, 10*time.Minute), Cfg: sc.cfg, - SQLStore: sc.db.DB(), + SQLStore: sc.db, Features: features, QuotaService: quotaSrv, - SearchService: search.ProvideService(sc.cfg, sc.db.DB(), starSvc, dashboardSvc), + SearchService: search.ProvideService(sc.cfg, sc.db, starSvc, dashboardSvc), folderService: folderServiceWithFlagOn, DashboardService: dashboardSvc, } diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index a7a3f914a8c..6566b02acfc 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -41,7 +41,6 @@ func (hs *HTTPServer) GetFrontendAssets(c *contextmodel.ReqContext) { hash.Reset() _, _ = hash.Write([]byte(setting.BuildVersion)) _, _ = hash.Write([]byte(setting.BuildCommit)) - _, _ = hash.Write([]byte(fmt.Sprintf("%d", setting.BuildStamp))) keys["version"] = fmt.Sprintf("%x", hash.Sum(nil)) // Plugin configs diff --git a/pkg/api/login.go b/pkg/api/login.go index d28ccfb8377..de3bacdf7dc 100644 --- a/pkg/api/login.go +++ b/pkg/api/login.go @@ -137,7 +137,12 @@ func (hs *HTTPServer) LoginView(c *contextmodel.ReqContext) { } } - c.Redirect(hs.GetRedirectURL(c)) + if !c.UseSessionStorageRedirect { + c.Redirect(hs.GetRedirectURL(c)) + return + } + + c.Redirect(hs.Cfg.AppSubURL + "/") return } @@ -211,7 +216,7 @@ func (hs *HTTPServer) LoginPost(c *contextmodel.ReqContext) response.Response { } metrics.MApiLoginPost.Inc() - return authn.HandleLoginResponse(c.Req, c.Resp, hs.Cfg, identity, hs.ValidateRedirectTo) + return authn.HandleLoginResponse(c.Req, c.Resp, hs.Cfg, identity, hs.ValidateRedirectTo, hs.Features) } func (hs *HTTPServer) loginUserWithUser(user *user.User, c *contextmodel.ReqContext) error { diff --git a/pkg/api/login_oauth.go b/pkg/api/login_oauth.go index 61cfa7d99e1..1fb9aaa3ce3 100644 --- a/pkg/api/login_oauth.go +++ b/pkg/api/login_oauth.go @@ -57,5 +57,5 @@ func (hs *HTTPServer) OAuthLogin(reqCtx *contextmodel.ReqContext) { } metrics.MApiLoginOAuth.Inc() - authn.HandleLoginRedirect(reqCtx.Req, reqCtx.Resp, hs.Cfg, identity, hs.ValidateRedirectTo) + authn.HandleLoginRedirect(reqCtx.Req, reqCtx.Resp, hs.Cfg, identity, hs.ValidateRedirectTo, hs.Features) } diff --git a/pkg/api/org_users_test.go b/pkg/api/org_users_test.go index 3c4caa64ecc..ea5f4930661 100644 --- a/pkg/api/org_users_test.go +++ b/pkg/api/org_users_test.go @@ -41,7 +41,7 @@ func setUpGetOrgUsersDB(t *testing.T, sqlStore db.DB, cfg *setting.Cfg) { cfg.AutoAssignOrg = true cfg.AutoAssignOrgId = int(testOrgID) - quotaService := quotaimpl.ProvideService(db.FakeReplDBFromDB(sqlStore), cfg) + quotaService := quotaimpl.ProvideService(sqlStore, cfg) orgService, err := orgimpl.ProvideService(sqlStore, cfg, quotaService) require.NoError(t, err) usrSvc, err := userimpl.ProvideService( diff --git a/pkg/api/playlist.go b/pkg/api/playlist.go index 2f9f5fb1d96..e7fce8598d2 100644 --- a/pkg/api/playlist.go +++ b/pkg/api/playlist.go @@ -423,6 +423,12 @@ func (pk8s *playlistK8sHandler) updatePlaylist(c *contextmodel.ReqContext) { } obj := internalplaylist.LegacyUpdateCommandToUnstructured(cmd) obj.SetName(uid) + existing, err := client.Get(c.Req.Context(), uid, v1.GetOptions{}) + if err != nil { + pk8s.writeError(c, err) + return + } + obj.SetResourceVersion(existing.GetResourceVersion()) out, err := client.Update(c.Req.Context(), &obj, v1.UpdateOptions{}) if err != nil { pk8s.writeError(c, err) diff --git a/pkg/api/user_token.go b/pkg/api/user_token.go index 166412292fe..7aedc5f0d5e 100644 --- a/pkg/api/user_token.go +++ b/pkg/api/user_token.go @@ -84,7 +84,11 @@ func (hs *HTTPServer) RotateUserAuthTokenRedirect(c *contextmodel.ReqContext) re return response.Redirect(hs.Cfg.AppSubURL + "/login") } - return response.Redirect(hs.GetRedirectURL(c)) + if !c.UseSessionStorageRedirect { + return response.Redirect(hs.GetRedirectURL(c)) + } + + return response.Redirect(hs.Cfg.AppSubURL + "/") } // swagger:route POST /user/auth-tokens/rotate diff --git a/pkg/apis/alerting_notifications/v0alpha1/register.go b/pkg/apis/alerting_notifications/v0alpha1/register.go index d59b6fdd1da..05eab2f7ba3 100644 --- a/pkg/apis/alerting_notifications/v0alpha1/register.go +++ b/pkg/apis/alerting_notifications/v0alpha1/register.go @@ -67,6 +67,25 @@ var ( }, }, ) + TemplateGroupResourceInfo = utils.NewResourceInfo(GROUP, VERSION, + "templategroups", "templategroup", "TemplateGroup", + func() runtime.Object { return &TemplateGroup{} }, + func() runtime.Object { return &TemplateGroupList{} }, + utils.TableColumns{ + Definition: []metav1.TableColumnDefinition{ + {Name: "Name", Type: "string", Format: "name"}, + }, + Reader: func(obj any) ([]interface{}, error) { + r, ok := obj.(*TemplateGroup) + if !ok { + return nil, fmt.Errorf("expected resource or info") + } + return []interface{}{ + r.Name, + }, nil + }, + }, + ) // SchemeGroupVersion is group version used to register these objects SchemeGroupVersion = schema.GroupVersion{Group: GROUP, Version: VERSION} // SchemaBuilder is used by standard codegen @@ -87,6 +106,8 @@ func AddKnownTypesGroup(scheme *runtime.Scheme, g schema.GroupVersion) error { &TimeIntervalList{}, &Receiver{}, &ReceiverList{}, + &TemplateGroup{}, + &TemplateGroupList{}, ) metav1.AddToGroupVersion(scheme, g) @@ -122,6 +143,22 @@ func AddKnownTypesGroup(scheme *runtime.Scheme, g schema.GroupVersion) error { return err } + err = scheme.AddFieldLabelConversionFunc( + TemplateGroupResourceInfo.GroupVersionKind(), + func(label, value string) (string, string, error) { + fieldSet := SelectableTemplateGroupFields(&TemplateGroup{}) + for key := range fieldSet { + if label == key { + return label, value, nil + } + } + return "", "", fmt.Errorf("field label not supported for %s: %s", scope.ScopeNodeResourceInfo.GroupVersionKind(), label) + }, + ) + if err != nil { + return err + } + return nil } @@ -145,6 +182,16 @@ func SelectableReceiverFields(obj *Receiver) fields.Set { }) } +func SelectableTemplateGroupFields(obj *TemplateGroup) fields.Set { + if obj == nil { + return nil + } + return generic.MergeFieldsSets(generic.ObjectMetaFieldsSet(&obj.ObjectMeta, false), fields.Set{ + "metadata.provenance": obj.GetProvenanceStatus(), + "spec.title": obj.Spec.Title, + }) +} + // Resource takes an unqualified resource and returns a Group qualified GroupResource func Resource(resource string) schema.GroupResource { return SchemeGroupVersion.WithResource(resource).GroupResource() diff --git a/pkg/apis/alerting_notifications/v0alpha1/templategroup_spec.go b/pkg/apis/alerting_notifications/v0alpha1/templategroup_spec.go new file mode 100644 index 00000000000..0731380f602 --- /dev/null +++ b/pkg/apis/alerting_notifications/v0alpha1/templategroup_spec.go @@ -0,0 +1,8 @@ +package v0alpha1 + +// TemplateGroupSpec defines model for TemplateGroupSpec. +// +k8s:openapi-gen=true +type TemplateGroupSpec struct { + Title string `json:"title"` + Content string `json:"content"` +} diff --git a/pkg/apis/alerting_notifications/v0alpha1/types.go b/pkg/apis/alerting_notifications/v0alpha1/types.go index ec5edaf6e6e..e674f58ebd7 100644 --- a/pkg/apis/alerting_notifications/v0alpha1/types.go +++ b/pkg/apis/alerting_notifications/v0alpha1/types.go @@ -7,6 +7,8 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +// region TimeInterval + // +genclient // +k8s:openapi-gen=true // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object @@ -86,7 +88,9 @@ type TimeIntervalList struct { Items []TimeInterval `json:"items"` } -// Receivers --------------------------------- +// endregion + +// region Receivers // +genclient // +k8s:openapi-gen=true @@ -166,3 +170,88 @@ type ReceiverList struct { metav1.ListMeta `json:"metadata"` Items []Receiver `json:"items"` } + +// endregion + +// region Templates + +// +genclient +// +k8s:openapi-gen=true +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type TemplateGroup struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata"` + Spec TemplateGroupSpec `json:"spec"` +} + +func (o *TemplateGroup) GetSpec() any { + return o.Spec +} + +func (o *TemplateGroup) SetSpec(spec any) error { + cast, ok := spec.(TemplateGroupSpec) + if !ok { + return fmt.Errorf("cannot set spec type %#v, not of type Spec", spec) + } + o.Spec = cast + return nil +} + +func (o *TemplateGroup) GetCreatedBy() string { + if o.ObjectMeta.Annotations == nil { + o.ObjectMeta.Annotations = make(map[string]string) + } + + return o.ObjectMeta.Annotations["grafana.com/createdBy"] +} + +func (o *TemplateGroup) SetCreatedBy(createdBy string) { + if o.ObjectMeta.Annotations == nil { + o.ObjectMeta.Annotations = make(map[string]string) + } + + o.ObjectMeta.Annotations["grafana.com/createdBy"] = createdBy +} + +func (o *TemplateGroup) GetUpdateTimestamp() time.Time { + if o.ObjectMeta.Annotations == nil { + o.ObjectMeta.Annotations = make(map[string]string) + } + + parsed, _ := time.Parse(o.ObjectMeta.Annotations["grafana.com/updateTimestamp"], time.RFC3339) + return parsed +} + +func (o *TemplateGroup) SetUpdateTimestamp(updateTimestamp time.Time) { + if o.ObjectMeta.Annotations == nil { + o.ObjectMeta.Annotations = make(map[string]string) + } + + o.ObjectMeta.Annotations["grafana.com/updateTimestamp"] = updateTimestamp.Format(time.RFC3339) +} + +func (o *TemplateGroup) GetUpdatedBy() string { + if o.ObjectMeta.Annotations == nil { + o.ObjectMeta.Annotations = make(map[string]string) + } + + return o.ObjectMeta.Annotations["grafana.com/updatedBy"] +} + +func (o *TemplateGroup) SetUpdatedBy(updatedBy string) { + if o.ObjectMeta.Annotations == nil { + o.ObjectMeta.Annotations = make(map[string]string) + } + + o.ObjectMeta.Annotations["grafana.com/updatedBy"] = updatedBy +} + +// +k8s:openapi-gen=true +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type TemplateGroupList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata"` + Items []TemplateGroup `json:"items"` +} + +// endregion diff --git a/pkg/apis/alerting_notifications/v0alpha1/types_ext.go b/pkg/apis/alerting_notifications/v0alpha1/types_ext.go index 70a3e9a8b1d..d3b97edcd19 100644 --- a/pkg/apis/alerting_notifications/v0alpha1/types_ext.go +++ b/pkg/apis/alerting_notifications/v0alpha1/types_ext.go @@ -76,3 +76,24 @@ func (o *Receiver) SetInUse(routesCnt int, rules []string) { func InUseAnnotation(resource string) string { return fmt.Sprintf("%s%s/%s", InternalPrefix, "inUse", resource) } + +func (o *TemplateGroup) GetProvenanceStatus() string { + if o == nil || o.Annotations == nil { + return "" + } + s, ok := o.Annotations[ProvenanceStatusAnnotationKey] + if !ok || s == "" { + return ProvenanceStatusNone + } + return s +} + +func (o *TemplateGroup) SetProvenanceStatus(status string) { + if o.Annotations == nil { + o.Annotations = make(map[string]string, 1) + } + if status == "" { + status = ProvenanceStatusNone + } + o.Annotations[ProvenanceStatusAnnotationKey] = status +} diff --git a/pkg/apis/alerting_notifications/v0alpha1/zz_generated.deepcopy.go b/pkg/apis/alerting_notifications/v0alpha1/zz_generated.deepcopy.go index ca7b6aa8a6d..b92ad6d360d 100644 --- a/pkg/apis/alerting_notifications/v0alpha1/zz_generated.deepcopy.go +++ b/pkg/apis/alerting_notifications/v0alpha1/zz_generated.deepcopy.go @@ -174,6 +174,82 @@ func (in *ReceiverSpec) DeepCopy() *ReceiverSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TemplateGroup) DeepCopyInto(out *TemplateGroup) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TemplateGroup. +func (in *TemplateGroup) DeepCopy() *TemplateGroup { + if in == nil { + return nil + } + out := new(TemplateGroup) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *TemplateGroup) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TemplateGroupList) DeepCopyInto(out *TemplateGroupList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]TemplateGroup, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TemplateGroupList. +func (in *TemplateGroupList) DeepCopy() *TemplateGroupList { + if in == nil { + return nil + } + out := new(TemplateGroupList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *TemplateGroupList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TemplateGroupSpec) DeepCopyInto(out *TemplateGroupSpec) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TemplateGroupSpec. +func (in *TemplateGroupSpec) DeepCopy() *TemplateGroupSpec { + if in == nil { + return nil + } + out := new(TemplateGroupSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TimeInterval) DeepCopyInto(out *TimeInterval) { *out = *in diff --git a/pkg/apis/alerting_notifications/v0alpha1/zz_generated.openapi.go b/pkg/apis/alerting_notifications/v0alpha1/zz_generated.openapi.go index c2ce1fbc925..6703e64406c 100644 --- a/pkg/apis/alerting_notifications/v0alpha1/zz_generated.openapi.go +++ b/pkg/apis/alerting_notifications/v0alpha1/zz_generated.openapi.go @@ -14,15 +14,18 @@ import ( func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { return map[string]common.OpenAPIDefinition{ - "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.Integration": schema_pkg_apis_alerting_notifications_v0alpha1_Integration(ref), - "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.Interval": schema_pkg_apis_alerting_notifications_v0alpha1_Interval(ref), - "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.Receiver": schema_pkg_apis_alerting_notifications_v0alpha1_Receiver(ref), - "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.ReceiverList": schema_pkg_apis_alerting_notifications_v0alpha1_ReceiverList(ref), - "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.ReceiverSpec": schema_pkg_apis_alerting_notifications_v0alpha1_ReceiverSpec(ref), - "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.TimeInterval": schema_pkg_apis_alerting_notifications_v0alpha1_TimeInterval(ref), - "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.TimeIntervalList": schema_pkg_apis_alerting_notifications_v0alpha1_TimeIntervalList(ref), - "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.TimeIntervalSpec": schema_pkg_apis_alerting_notifications_v0alpha1_TimeIntervalSpec(ref), - "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.TimeRange": schema_pkg_apis_alerting_notifications_v0alpha1_TimeRange(ref), + "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.Integration": schema_pkg_apis_alerting_notifications_v0alpha1_Integration(ref), + "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.Interval": schema_pkg_apis_alerting_notifications_v0alpha1_Interval(ref), + "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.Receiver": schema_pkg_apis_alerting_notifications_v0alpha1_Receiver(ref), + "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.ReceiverList": schema_pkg_apis_alerting_notifications_v0alpha1_ReceiverList(ref), + "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.ReceiverSpec": schema_pkg_apis_alerting_notifications_v0alpha1_ReceiverSpec(ref), + "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.TemplateGroup": schema_pkg_apis_alerting_notifications_v0alpha1_TemplateGroup(ref), + "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.TemplateGroupList": schema_pkg_apis_alerting_notifications_v0alpha1_TemplateGroupList(ref), + "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.TemplateGroupSpec": schema_pkg_apis_alerting_notifications_v0alpha1_TemplateGroupSpec(ref), + "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.TimeInterval": schema_pkg_apis_alerting_notifications_v0alpha1_TimeInterval(ref), + "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.TimeIntervalList": schema_pkg_apis_alerting_notifications_v0alpha1_TimeIntervalList(ref), + "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.TimeIntervalSpec": schema_pkg_apis_alerting_notifications_v0alpha1_TimeIntervalSpec(ref), + "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.TimeRange": schema_pkg_apis_alerting_notifications_v0alpha1_TimeRange(ref), } } @@ -336,6 +339,123 @@ func schema_pkg_apis_alerting_notifications_v0alpha1_ReceiverSpec(ref common.Ref } } +func schema_pkg_apis_alerting_notifications_v0alpha1_TemplateGroup(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + 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{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"), + }, + }, + "spec": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.TemplateGroupSpec"), + }, + }, + }, + Required: []string{"metadata", "spec"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.TemplateGroupSpec", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, + } +} + +func schema_pkg_apis_alerting_notifications_v0alpha1_TemplateGroupList(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + 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{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"), + }, + }, + "items": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.TemplateGroup"), + }, + }, + }, + }, + }, + }, + Required: []string{"metadata", "items"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1.TemplateGroup", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, + } +} + +func schema_pkg_apis_alerting_notifications_v0alpha1_TemplateGroupSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "TemplateGroupSpec defines model for TemplateGroupSpec.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "title": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "content": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + Required: []string{"title", "content"}, + }, + }, + } +} + func schema_pkg_apis_alerting_notifications_v0alpha1_TimeInterval(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ diff --git a/pkg/apiserver/registry/generic/storage.go b/pkg/apiserver/registry/generic/storage.go new file mode 100644 index 00000000000..945d51fbd58 --- /dev/null +++ b/pkg/apiserver/registry/generic/storage.go @@ -0,0 +1,31 @@ +package generic + +import ( + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/generic" + "k8s.io/apiserver/pkg/registry/generic/registry" + + "github.com/grafana/grafana/pkg/apimachinery/utils" +) + +func NewRegistryStore(scheme *runtime.Scheme, resourceInfo utils.ResourceInfo, optsGetter generic.RESTOptionsGetter) (*registry.Store, error) { + strategy := NewStrategy(scheme, resourceInfo.GroupVersion()) + store := ®istry.Store{ + NewFunc: resourceInfo.NewFunc, + NewListFunc: resourceInfo.NewListFunc, + KeyRootFunc: KeyRootFunc(resourceInfo.GroupResource()), + KeyFunc: NamespaceKeyFunc(resourceInfo.GroupResource()), + PredicateFunc: Matcher, + DefaultQualifiedResource: resourceInfo.GroupResource(), + SingularQualifiedResource: resourceInfo.SingularGroupResource(), + TableConvertor: resourceInfo.TableConverter(), + CreateStrategy: strategy, + UpdateStrategy: strategy, + DeleteStrategy: strategy, + } + options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: GetAttrs} + if err := store.CompleteWithOptions(options); err != nil { + return nil, err + } + return store, nil +} diff --git a/pkg/cmd/grafana-cli/commands/conflict_user_command.go b/pkg/cmd/grafana-cli/commands/conflict_user_command.go index 01eb1336ec4..eb74c6f11f6 100644 --- a/pkg/cmd/grafana-cli/commands/conflict_user_command.go +++ b/pkg/cmd/grafana-cli/commands/conflict_user_command.go @@ -71,7 +71,7 @@ func initializeConflictResolver(cmd *utils.ContextCommandLine, f Formatter, ctx if err != nil { return nil, fmt.Errorf("%v: %w", "failed to load configuration", err) } - s, replstore, err := getSqlStore(cfg, tracer, features) + s, err := getSqlStore(cfg, tracer, features) if err != nil { return nil, fmt.Errorf("%v: %w", "failed to get to sql", err) } @@ -79,14 +79,14 @@ func initializeConflictResolver(cmd *utils.ContextCommandLine, f Formatter, ctx if err != nil { return nil, fmt.Errorf("%v: %w", "failed to get users with conflicting logins", err) } - quotaService := quotaimpl.ProvideService(replstore, cfg) + quotaService := quotaimpl.ProvideService(s, cfg) userService, err := userimpl.ProvideService(s, nil, cfg, nil, nil, tracer, quotaService, supportbundlestest.NewFakeBundleService()) if err != nil { return nil, fmt.Errorf("%v: %w", "failed to get user service", err) } routing := routing.ProvideRegister() - acService, err := acimpl.ProvideService(cfg, replstore, routing, nil, nil, nil, features, tracer, zanzana.NewNoopClient(), permreg.ProvidePermissionRegistry()) + acService, err := acimpl.ProvideService(cfg, s, routing, nil, nil, nil, features, tracer, zanzana.NewNoopClient(), permreg.ProvidePermissionRegistry()) if err != nil { return nil, fmt.Errorf("%v: %w", "failed to get access control", err) } @@ -95,15 +95,9 @@ func initializeConflictResolver(cmd *utils.ContextCommandLine, f Formatter, ctx return &resolver, nil } -func getSqlStore(cfg *setting.Cfg, tracer tracing.Tracer, features featuremgmt.FeatureToggles) (*sqlstore.SQLStore, *sqlstore.ReplStore, error) { +func getSqlStore(cfg *setting.Cfg, tracer tracing.Tracer, features featuremgmt.FeatureToggles) (*sqlstore.SQLStore, error) { bus := bus.ProvideBus(tracer) - ss, err := sqlstore.ProvideService(cfg, features, &migrations.OSSMigrations{}, bus, tracer) - if err != nil { - return nil, nil, err - } - - replStore, err := sqlstore.ProvideServiceWithReadReplica(ss, cfg, features, &migrations.OSSMigrations{}, bus, tracer) - return ss, replStore, err + return sqlstore.ProvideService(cfg, features, &migrations.OSSMigrations{}, bus, tracer) } func runListConflictUsers() func(context *cli.Context) error { diff --git a/pkg/cmd/grafana-cli/commands/conflict_user_command_test.go b/pkg/cmd/grafana-cli/commands/conflict_user_command_test.go index a43a07e1e53..c0aafacd3c0 100644 --- a/pkg/cmd/grafana-cli/commands/conflict_user_command_test.go +++ b/pkg/cmd/grafana-cli/commands/conflict_user_command_test.go @@ -636,7 +636,7 @@ func TestIntegrationMergeUser(t *testing.T) { } t.Run("should be able to merge user", func(t *testing.T) { // Restore after destructive operation - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) teamSvc, err := teamimpl.ProvideService(sqlStore, setting.NewCfg(), tracing.InitializeTracerForTest()) require.NoError(t, err) team1, err := teamSvc.CreateTeam(context.Background(), "team1 name", "", 1) @@ -714,10 +714,10 @@ func TestIntegrationMergeUser(t *testing.T) { } // get users - conflictUsers, err := GetUsersWithConflictingEmailsOrLogins(&cli.Context{Context: context.Background()}, sqlStore.SQLStore) + conflictUsers, err := GetUsersWithConflictingEmailsOrLogins(&cli.Context{Context: context.Background()}, sqlStore) require.NoError(t, err) r := ConflictResolver{ - Store: sqlStore.SQLStore, + Store: sqlStore, userService: usertest.NewUserServiceFake(), ac: actest.FakeService{}, } diff --git a/pkg/generated/applyconfiguration/alerting_notifications/v0alpha1/templategroup.go b/pkg/generated/applyconfiguration/alerting_notifications/v0alpha1/templategroup.go new file mode 100644 index 00000000000..b4340dcde50 --- /dev/null +++ b/pkg/generated/applyconfiguration/alerting_notifications/v0alpha1/templategroup.go @@ -0,0 +1,202 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + v1 "k8s.io/client-go/applyconfigurations/meta/v1" +) + +// TemplateGroupApplyConfiguration represents a declarative configuration of the TemplateGroup type for use +// with apply. +type TemplateGroupApplyConfiguration struct { + v1.TypeMetaApplyConfiguration `json:",inline"` + *v1.ObjectMetaApplyConfiguration `json:"metadata,omitempty"` + Spec *TemplateGroupSpecApplyConfiguration `json:"spec,omitempty"` +} + +// TemplateGroup constructs a declarative configuration of the TemplateGroup type for use with +// apply. +func TemplateGroup(name, namespace string) *TemplateGroupApplyConfiguration { + b := &TemplateGroupApplyConfiguration{} + b.WithName(name) + b.WithNamespace(namespace) + b.WithKind("TemplateGroup") + b.WithAPIVersion("notifications.alerting.grafana.app/v0alpha1") + return b +} + +// WithKind sets the Kind 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 Kind field is set to the value of the last call. +func (b *TemplateGroupApplyConfiguration) WithKind(value string) *TemplateGroupApplyConfiguration { + b.Kind = &value + return b +} + +// WithAPIVersion sets the APIVersion 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 APIVersion field is set to the value of the last call. +func (b *TemplateGroupApplyConfiguration) WithAPIVersion(value string) *TemplateGroupApplyConfiguration { + b.APIVersion = &value + return b +} + +// WithName sets the Name 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 Name field is set to the value of the last call. +func (b *TemplateGroupApplyConfiguration) WithName(value string) *TemplateGroupApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.Name = &value + return b +} + +// WithGenerateName sets the GenerateName 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 GenerateName field is set to the value of the last call. +func (b *TemplateGroupApplyConfiguration) WithGenerateName(value string) *TemplateGroupApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.GenerateName = &value + return b +} + +// WithNamespace sets the Namespace 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 Namespace field is set to the value of the last call. +func (b *TemplateGroupApplyConfiguration) WithNamespace(value string) *TemplateGroupApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.Namespace = &value + return b +} + +// WithUID sets the UID 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 UID field is set to the value of the last call. +func (b *TemplateGroupApplyConfiguration) WithUID(value types.UID) *TemplateGroupApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.UID = &value + return b +} + +// WithResourceVersion sets the ResourceVersion 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 ResourceVersion field is set to the value of the last call. +func (b *TemplateGroupApplyConfiguration) WithResourceVersion(value string) *TemplateGroupApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.ResourceVersion = &value + return b +} + +// WithGeneration sets the Generation 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 Generation field is set to the value of the last call. +func (b *TemplateGroupApplyConfiguration) WithGeneration(value int64) *TemplateGroupApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.Generation = &value + return b +} + +// WithCreationTimestamp sets the CreationTimestamp 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 CreationTimestamp field is set to the value of the last call. +func (b *TemplateGroupApplyConfiguration) WithCreationTimestamp(value metav1.Time) *TemplateGroupApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.CreationTimestamp = &value + return b +} + +// WithDeletionTimestamp sets the DeletionTimestamp 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 DeletionTimestamp field is set to the value of the last call. +func (b *TemplateGroupApplyConfiguration) WithDeletionTimestamp(value metav1.Time) *TemplateGroupApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.DeletionTimestamp = &value + return b +} + +// WithDeletionGracePeriodSeconds sets the DeletionGracePeriodSeconds 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 DeletionGracePeriodSeconds field is set to the value of the last call. +func (b *TemplateGroupApplyConfiguration) WithDeletionGracePeriodSeconds(value int64) *TemplateGroupApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + b.DeletionGracePeriodSeconds = &value + return b +} + +// WithLabels puts the entries into the Labels field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, the entries provided by each call will be put on the Labels field, +// overwriting an existing map entries in Labels field with the same key. +func (b *TemplateGroupApplyConfiguration) WithLabels(entries map[string]string) *TemplateGroupApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + if b.Labels == nil && len(entries) > 0 { + b.Labels = make(map[string]string, len(entries)) + } + for k, v := range entries { + b.Labels[k] = v + } + return b +} + +// WithAnnotations puts the entries into the Annotations field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, the entries provided by each call will be put on the Annotations field, +// overwriting an existing map entries in Annotations field with the same key. +func (b *TemplateGroupApplyConfiguration) WithAnnotations(entries map[string]string) *TemplateGroupApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + if b.Annotations == nil && len(entries) > 0 { + b.Annotations = make(map[string]string, len(entries)) + } + for k, v := range entries { + b.Annotations[k] = v + } + return b +} + +// WithOwnerReferences adds the given value to the OwnerReferences 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 OwnerReferences field. +func (b *TemplateGroupApplyConfiguration) WithOwnerReferences(values ...*v1.OwnerReferenceApplyConfiguration) *TemplateGroupApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + for i := range values { + if values[i] == nil { + panic("nil value passed to WithOwnerReferences") + } + b.OwnerReferences = append(b.OwnerReferences, *values[i]) + } + return b +} + +// WithFinalizers adds the given value to the Finalizers 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 Finalizers field. +func (b *TemplateGroupApplyConfiguration) WithFinalizers(values ...string) *TemplateGroupApplyConfiguration { + b.ensureObjectMetaApplyConfigurationExists() + for i := range values { + b.Finalizers = append(b.Finalizers, values[i]) + } + return b +} + +func (b *TemplateGroupApplyConfiguration) ensureObjectMetaApplyConfigurationExists() { + if b.ObjectMetaApplyConfiguration == nil { + b.ObjectMetaApplyConfiguration = &v1.ObjectMetaApplyConfiguration{} + } +} + +// WithSpec sets the Spec 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 Spec field is set to the value of the last call. +func (b *TemplateGroupApplyConfiguration) WithSpec(value *TemplateGroupSpecApplyConfiguration) *TemplateGroupApplyConfiguration { + b.Spec = value + return b +} + +// GetName retrieves the value of the Name field in the declarative configuration. +func (b *TemplateGroupApplyConfiguration) GetName() *string { + b.ensureObjectMetaApplyConfigurationExists() + return b.Name +} diff --git a/pkg/generated/applyconfiguration/alerting_notifications/v0alpha1/templategroupspec.go b/pkg/generated/applyconfiguration/alerting_notifications/v0alpha1/templategroupspec.go new file mode 100644 index 00000000000..42fcad447be --- /dev/null +++ b/pkg/generated/applyconfiguration/alerting_notifications/v0alpha1/templategroupspec.go @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v0alpha1 + +// TemplateGroupSpecApplyConfiguration represents a declarative configuration of the TemplateGroupSpec type for use +// with apply. +type TemplateGroupSpecApplyConfiguration struct { + Title *string `json:"title,omitempty"` + Content *string `json:"content,omitempty"` +} + +// TemplateGroupSpecApplyConfiguration constructs a declarative configuration of the TemplateGroupSpec type for use with +// apply. +func TemplateGroupSpec() *TemplateGroupSpecApplyConfiguration { + return &TemplateGroupSpecApplyConfiguration{} +} + +// WithTitle sets the Title 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 Title field is set to the value of the last call. +func (b *TemplateGroupSpecApplyConfiguration) WithTitle(value string) *TemplateGroupSpecApplyConfiguration { + b.Title = &value + return b +} + +// WithContent sets the Content 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 Content field is set to the value of the last call. +func (b *TemplateGroupSpecApplyConfiguration) WithContent(value string) *TemplateGroupSpecApplyConfiguration { + b.Content = &value + return b +} diff --git a/pkg/generated/applyconfiguration/utils.go b/pkg/generated/applyconfiguration/utils.go index 55ac136e4fc..8221f95b1da 100644 --- a/pkg/generated/applyconfiguration/utils.go +++ b/pkg/generated/applyconfiguration/utils.go @@ -28,6 +28,10 @@ func ForKind(kind schema.GroupVersionKind) interface{} { return &alertingnotificationsv0alpha1.ReceiverApplyConfiguration{} case v0alpha1.SchemeGroupVersion.WithKind("ReceiverSpec"): return &alertingnotificationsv0alpha1.ReceiverSpecApplyConfiguration{} + case v0alpha1.SchemeGroupVersion.WithKind("TemplateGroup"): + return &alertingnotificationsv0alpha1.TemplateGroupApplyConfiguration{} + case v0alpha1.SchemeGroupVersion.WithKind("TemplateGroupSpec"): + return &alertingnotificationsv0alpha1.TemplateGroupSpecApplyConfiguration{} case v0alpha1.SchemeGroupVersion.WithKind("TimeInterval"): return &alertingnotificationsv0alpha1.TimeIntervalApplyConfiguration{} case v0alpha1.SchemeGroupVersion.WithKind("TimeIntervalSpec"): diff --git a/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/alerting_notifications_client.go b/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/alerting_notifications_client.go index 53dfb5ad0d8..f77cbc0c51a 100644 --- a/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/alerting_notifications_client.go +++ b/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/alerting_notifications_client.go @@ -15,6 +15,7 @@ import ( type NotificationsV0alpha1Interface interface { RESTClient() rest.Interface ReceiversGetter + TemplateGroupsGetter TimeIntervalsGetter } @@ -27,6 +28,10 @@ func (c *NotificationsV0alpha1Client) Receivers(namespace string) ReceiverInterf return newReceivers(c, namespace) } +func (c *NotificationsV0alpha1Client) TemplateGroups(namespace string) TemplateGroupInterface { + return newTemplateGroups(c, namespace) +} + func (c *NotificationsV0alpha1Client) TimeIntervals(namespace string) TimeIntervalInterface { return newTimeIntervals(c, namespace) } diff --git a/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/fake/fake_alerting_notifications_client.go b/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/fake/fake_alerting_notifications_client.go index 84bcadcd8e2..f2695e6e8a3 100644 --- a/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/fake/fake_alerting_notifications_client.go +++ b/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/fake/fake_alerting_notifications_client.go @@ -18,6 +18,10 @@ func (c *FakeNotificationsV0alpha1) Receivers(namespace string) v0alpha1.Receive return &FakeReceivers{c, namespace} } +func (c *FakeNotificationsV0alpha1) TemplateGroups(namespace string) v0alpha1.TemplateGroupInterface { + return &FakeTemplateGroups{c, namespace} +} + func (c *FakeNotificationsV0alpha1) TimeIntervals(namespace string) v0alpha1.TimeIntervalInterface { return &FakeTimeIntervals{c, namespace} } diff --git a/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/fake/fake_templategroup.go b/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/fake/fake_templategroup.go new file mode 100644 index 00000000000..ffbd0668e4b --- /dev/null +++ b/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/fake/fake_templategroup.go @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by client-gen. DO NOT EDIT. + +package fake + +import ( + "context" + json "encoding/json" + "fmt" + + v0alpha1 "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1" + alertingnotificationsv0alpha1 "github.com/grafana/grafana/pkg/generated/applyconfiguration/alerting_notifications/v0alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + labels "k8s.io/apimachinery/pkg/labels" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + testing "k8s.io/client-go/testing" +) + +// FakeTemplateGroups implements TemplateGroupInterface +type FakeTemplateGroups struct { + Fake *FakeNotificationsV0alpha1 + ns string +} + +var templategroupsResource = v0alpha1.SchemeGroupVersion.WithResource("templategroups") + +var templategroupsKind = v0alpha1.SchemeGroupVersion.WithKind("TemplateGroup") + +// Get takes name of the templateGroup, and returns the corresponding templateGroup object, and an error if there is any. +func (c *FakeTemplateGroups) Get(ctx context.Context, name string, options v1.GetOptions) (result *v0alpha1.TemplateGroup, err error) { + emptyResult := &v0alpha1.TemplateGroup{} + obj, err := c.Fake. + Invokes(testing.NewGetActionWithOptions(templategroupsResource, c.ns, name, options), emptyResult) + + if obj == nil { + return emptyResult, err + } + return obj.(*v0alpha1.TemplateGroup), err +} + +// List takes label and field selectors, and returns the list of TemplateGroups that match those selectors. +func (c *FakeTemplateGroups) List(ctx context.Context, opts v1.ListOptions) (result *v0alpha1.TemplateGroupList, err error) { + emptyResult := &v0alpha1.TemplateGroupList{} + obj, err := c.Fake. + Invokes(testing.NewListActionWithOptions(templategroupsResource, templategroupsKind, c.ns, opts), emptyResult) + + if obj == nil { + return emptyResult, err + } + + label, _, _ := testing.ExtractFromListOptions(opts) + if label == nil { + label = labels.Everything() + } + list := &v0alpha1.TemplateGroupList{ListMeta: obj.(*v0alpha1.TemplateGroupList).ListMeta} + for _, item := range obj.(*v0alpha1.TemplateGroupList).Items { + if label.Matches(labels.Set(item.Labels)) { + list.Items = append(list.Items, item) + } + } + return list, err +} + +// Watch returns a watch.Interface that watches the requested templateGroups. +func (c *FakeTemplateGroups) Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) { + return c.Fake. + InvokesWatch(testing.NewWatchActionWithOptions(templategroupsResource, c.ns, opts)) + +} + +// Create takes the representation of a templateGroup and creates it. Returns the server's representation of the templateGroup, and an error, if there is any. +func (c *FakeTemplateGroups) Create(ctx context.Context, templateGroup *v0alpha1.TemplateGroup, opts v1.CreateOptions) (result *v0alpha1.TemplateGroup, err error) { + emptyResult := &v0alpha1.TemplateGroup{} + obj, err := c.Fake. + Invokes(testing.NewCreateActionWithOptions(templategroupsResource, c.ns, templateGroup, opts), emptyResult) + + if obj == nil { + return emptyResult, err + } + return obj.(*v0alpha1.TemplateGroup), err +} + +// Update takes the representation of a templateGroup and updates it. Returns the server's representation of the templateGroup, and an error, if there is any. +func (c *FakeTemplateGroups) Update(ctx context.Context, templateGroup *v0alpha1.TemplateGroup, opts v1.UpdateOptions) (result *v0alpha1.TemplateGroup, err error) { + emptyResult := &v0alpha1.TemplateGroup{} + obj, err := c.Fake. + Invokes(testing.NewUpdateActionWithOptions(templategroupsResource, c.ns, templateGroup, opts), emptyResult) + + if obj == nil { + return emptyResult, err + } + return obj.(*v0alpha1.TemplateGroup), err +} + +// Delete takes name of the templateGroup and deletes it. Returns an error if one occurs. +func (c *FakeTemplateGroups) Delete(ctx context.Context, name string, opts v1.DeleteOptions) error { + _, err := c.Fake. + Invokes(testing.NewDeleteActionWithOptions(templategroupsResource, c.ns, name, opts), &v0alpha1.TemplateGroup{}) + + return err +} + +// DeleteCollection deletes a collection of objects. +func (c *FakeTemplateGroups) DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error { + action := testing.NewDeleteCollectionActionWithOptions(templategroupsResource, c.ns, opts, listOpts) + + _, err := c.Fake.Invokes(action, &v0alpha1.TemplateGroupList{}) + return err +} + +// Patch applies the patch and returns the patched templateGroup. +func (c *FakeTemplateGroups) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *v0alpha1.TemplateGroup, err error) { + emptyResult := &v0alpha1.TemplateGroup{} + obj, err := c.Fake. + Invokes(testing.NewPatchSubresourceActionWithOptions(templategroupsResource, c.ns, name, pt, data, opts, subresources...), emptyResult) + + if obj == nil { + return emptyResult, err + } + return obj.(*v0alpha1.TemplateGroup), err +} + +// Apply takes the given apply declarative configuration, applies it and returns the applied templateGroup. +func (c *FakeTemplateGroups) Apply(ctx context.Context, templateGroup *alertingnotificationsv0alpha1.TemplateGroupApplyConfiguration, opts v1.ApplyOptions) (result *v0alpha1.TemplateGroup, err error) { + if templateGroup == nil { + return nil, fmt.Errorf("templateGroup provided to Apply must not be nil") + } + data, err := json.Marshal(templateGroup) + if err != nil { + return nil, err + } + name := templateGroup.Name + if name == nil { + return nil, fmt.Errorf("templateGroup.Name must be provided to Apply") + } + emptyResult := &v0alpha1.TemplateGroup{} + obj, err := c.Fake. + Invokes(testing.NewPatchSubresourceActionWithOptions(templategroupsResource, c.ns, *name, types.ApplyPatchType, data, opts.ToPatchOptions()), emptyResult) + + if obj == nil { + return emptyResult, err + } + return obj.(*v0alpha1.TemplateGroup), err +} diff --git a/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/generated_expansion.go b/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/generated_expansion.go index c30d1403330..f76e1bfa65d 100644 --- a/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/generated_expansion.go +++ b/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/generated_expansion.go @@ -6,4 +6,6 @@ package v0alpha1 type ReceiverExpansion interface{} +type TemplateGroupExpansion interface{} + type TimeIntervalExpansion interface{} diff --git a/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/templategroup.go b/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/templategroup.go new file mode 100644 index 00000000000..5eb06224934 --- /dev/null +++ b/pkg/generated/clientset/versioned/typed/alerting_notifications/v0alpha1/templategroup.go @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by client-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + "context" + + v0alpha1 "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1" + alertingnotificationsv0alpha1 "github.com/grafana/grafana/pkg/generated/applyconfiguration/alerting_notifications/v0alpha1" + scheme "github.com/grafana/grafana/pkg/generated/clientset/versioned/scheme" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + types "k8s.io/apimachinery/pkg/types" + watch "k8s.io/apimachinery/pkg/watch" + gentype "k8s.io/client-go/gentype" +) + +// TemplateGroupsGetter has a method to return a TemplateGroupInterface. +// A group's client should implement this interface. +type TemplateGroupsGetter interface { + TemplateGroups(namespace string) TemplateGroupInterface +} + +// TemplateGroupInterface has methods to work with TemplateGroup resources. +type TemplateGroupInterface interface { + Create(ctx context.Context, templateGroup *v0alpha1.TemplateGroup, opts v1.CreateOptions) (*v0alpha1.TemplateGroup, error) + Update(ctx context.Context, templateGroup *v0alpha1.TemplateGroup, opts v1.UpdateOptions) (*v0alpha1.TemplateGroup, error) + Delete(ctx context.Context, name string, opts v1.DeleteOptions) error + DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error + Get(ctx context.Context, name string, opts v1.GetOptions) (*v0alpha1.TemplateGroup, error) + List(ctx context.Context, opts v1.ListOptions) (*v0alpha1.TemplateGroupList, error) + Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) + Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *v0alpha1.TemplateGroup, err error) + Apply(ctx context.Context, templateGroup *alertingnotificationsv0alpha1.TemplateGroupApplyConfiguration, opts v1.ApplyOptions) (result *v0alpha1.TemplateGroup, err error) + TemplateGroupExpansion +} + +// templateGroups implements TemplateGroupInterface +type templateGroups struct { + *gentype.ClientWithListAndApply[*v0alpha1.TemplateGroup, *v0alpha1.TemplateGroupList, *alertingnotificationsv0alpha1.TemplateGroupApplyConfiguration] +} + +// newTemplateGroups returns a TemplateGroups +func newTemplateGroups(c *NotificationsV0alpha1Client, namespace string) *templateGroups { + return &templateGroups{ + gentype.NewClientWithListAndApply[*v0alpha1.TemplateGroup, *v0alpha1.TemplateGroupList, *alertingnotificationsv0alpha1.TemplateGroupApplyConfiguration]( + "templategroups", + c.RESTClient(), + scheme.ParameterCodec, + namespace, + func() *v0alpha1.TemplateGroup { return &v0alpha1.TemplateGroup{} }, + func() *v0alpha1.TemplateGroupList { return &v0alpha1.TemplateGroupList{} }), + } +} diff --git a/pkg/generated/informers/externalversions/alerting_notifications/v0alpha1/interface.go b/pkg/generated/informers/externalversions/alerting_notifications/v0alpha1/interface.go index 64c33c7fc5e..a84b5042189 100644 --- a/pkg/generated/informers/externalversions/alerting_notifications/v0alpha1/interface.go +++ b/pkg/generated/informers/externalversions/alerting_notifications/v0alpha1/interface.go @@ -12,6 +12,8 @@ import ( type Interface interface { // Receivers returns a ReceiverInformer. Receivers() ReceiverInformer + // TemplateGroups returns a TemplateGroupInformer. + TemplateGroups() TemplateGroupInformer // TimeIntervals returns a TimeIntervalInformer. TimeIntervals() TimeIntervalInformer } @@ -32,6 +34,11 @@ func (v *version) Receivers() ReceiverInformer { return &receiverInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} } +// TemplateGroups returns a TemplateGroupInformer. +func (v *version) TemplateGroups() TemplateGroupInformer { + return &templateGroupInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} +} + // TimeIntervals returns a TimeIntervalInformer. func (v *version) TimeIntervals() TimeIntervalInformer { return &timeIntervalInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions} diff --git a/pkg/generated/informers/externalversions/alerting_notifications/v0alpha1/templategroup.go b/pkg/generated/informers/externalversions/alerting_notifications/v0alpha1/templategroup.go new file mode 100644 index 00000000000..2a04ec9ea4e --- /dev/null +++ b/pkg/generated/informers/externalversions/alerting_notifications/v0alpha1/templategroup.go @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by informer-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + "context" + time "time" + + alertingnotificationsv0alpha1 "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1" + versioned "github.com/grafana/grafana/pkg/generated/clientset/versioned" + internalinterfaces "github.com/grafana/grafana/pkg/generated/informers/externalversions/internalinterfaces" + v0alpha1 "github.com/grafana/grafana/pkg/generated/listers/alerting_notifications/v0alpha1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + watch "k8s.io/apimachinery/pkg/watch" + cache "k8s.io/client-go/tools/cache" +) + +// TemplateGroupInformer provides access to a shared informer and lister for +// TemplateGroups. +type TemplateGroupInformer interface { + Informer() cache.SharedIndexInformer + Lister() v0alpha1.TemplateGroupLister +} + +type templateGroupInformer struct { + factory internalinterfaces.SharedInformerFactory + tweakListOptions internalinterfaces.TweakListOptionsFunc + namespace string +} + +// NewTemplateGroupInformer constructs a new informer for TemplateGroup type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewTemplateGroupInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer { + return NewFilteredTemplateGroupInformer(client, namespace, resyncPeriod, indexers, nil) +} + +// NewFilteredTemplateGroupInformer constructs a new informer for TemplateGroup type. +// Always prefer using an informer factory to get a shared informer instead of getting an independent +// one. This reduces memory footprint and number of connections to the server. +func NewFilteredTemplateGroupInformer(client versioned.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer { + return cache.NewSharedIndexInformer( + &cache.ListWatch{ + ListFunc: func(options v1.ListOptions) (runtime.Object, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.NotificationsV0alpha1().TemplateGroups(namespace).List(context.TODO(), options) + }, + WatchFunc: func(options v1.ListOptions) (watch.Interface, error) { + if tweakListOptions != nil { + tweakListOptions(&options) + } + return client.NotificationsV0alpha1().TemplateGroups(namespace).Watch(context.TODO(), options) + }, + }, + &alertingnotificationsv0alpha1.TemplateGroup{}, + resyncPeriod, + indexers, + ) +} + +func (f *templateGroupInformer) defaultInformer(client versioned.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer { + return NewFilteredTemplateGroupInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions) +} + +func (f *templateGroupInformer) Informer() cache.SharedIndexInformer { + return f.factory.InformerFor(&alertingnotificationsv0alpha1.TemplateGroup{}, f.defaultInformer) +} + +func (f *templateGroupInformer) Lister() v0alpha1.TemplateGroupLister { + return v0alpha1.NewTemplateGroupLister(f.Informer().GetIndexer()) +} diff --git a/pkg/generated/informers/externalversions/generic.go b/pkg/generated/informers/externalversions/generic.go index 36b920d2c47..9c16b6eab97 100644 --- a/pkg/generated/informers/externalversions/generic.go +++ b/pkg/generated/informers/externalversions/generic.go @@ -42,6 +42,8 @@ func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource // Group=notifications.alerting.grafana.app, Version=v0alpha1 case v0alpha1.SchemeGroupVersion.WithResource("receivers"): return &genericInformer{resource: resource.GroupResource(), informer: f.Notifications().V0alpha1().Receivers().Informer()}, nil + case v0alpha1.SchemeGroupVersion.WithResource("templategroups"): + return &genericInformer{resource: resource.GroupResource(), informer: f.Notifications().V0alpha1().TemplateGroups().Informer()}, nil case v0alpha1.SchemeGroupVersion.WithResource("timeintervals"): return &genericInformer{resource: resource.GroupResource(), informer: f.Notifications().V0alpha1().TimeIntervals().Informer()}, nil diff --git a/pkg/generated/listers/alerting_notifications/v0alpha1/expansion_generated.go b/pkg/generated/listers/alerting_notifications/v0alpha1/expansion_generated.go index ed4d4f239ae..8f4585658eb 100644 --- a/pkg/generated/listers/alerting_notifications/v0alpha1/expansion_generated.go +++ b/pkg/generated/listers/alerting_notifications/v0alpha1/expansion_generated.go @@ -12,6 +12,14 @@ type ReceiverListerExpansion interface{} // ReceiverNamespaceLister. type ReceiverNamespaceListerExpansion interface{} +// TemplateGroupListerExpansion allows custom methods to be added to +// TemplateGroupLister. +type TemplateGroupListerExpansion interface{} + +// TemplateGroupNamespaceListerExpansion allows custom methods to be added to +// TemplateGroupNamespaceLister. +type TemplateGroupNamespaceListerExpansion interface{} + // TimeIntervalListerExpansion allows custom methods to be added to // TimeIntervalLister. type TimeIntervalListerExpansion interface{} diff --git a/pkg/generated/listers/alerting_notifications/v0alpha1/templategroup.go b/pkg/generated/listers/alerting_notifications/v0alpha1/templategroup.go new file mode 100644 index 00000000000..67d7df50f96 --- /dev/null +++ b/pkg/generated/listers/alerting_notifications/v0alpha1/templategroup.go @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by lister-gen. DO NOT EDIT. + +package v0alpha1 + +import ( + v0alpha1 "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/listers" + "k8s.io/client-go/tools/cache" +) + +// TemplateGroupLister helps list TemplateGroups. +// All objects returned here must be treated as read-only. +type TemplateGroupLister interface { + // List lists all TemplateGroups in the indexer. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*v0alpha1.TemplateGroup, err error) + // TemplateGroups returns an object that can list and get TemplateGroups. + TemplateGroups(namespace string) TemplateGroupNamespaceLister + TemplateGroupListerExpansion +} + +// templateGroupLister implements the TemplateGroupLister interface. +type templateGroupLister struct { + listers.ResourceIndexer[*v0alpha1.TemplateGroup] +} + +// NewTemplateGroupLister returns a new TemplateGroupLister. +func NewTemplateGroupLister(indexer cache.Indexer) TemplateGroupLister { + return &templateGroupLister{listers.New[*v0alpha1.TemplateGroup](indexer, v0alpha1.Resource("templategroup"))} +} + +// TemplateGroups returns an object that can list and get TemplateGroups. +func (s *templateGroupLister) TemplateGroups(namespace string) TemplateGroupNamespaceLister { + return templateGroupNamespaceLister{listers.NewNamespaced[*v0alpha1.TemplateGroup](s.ResourceIndexer, namespace)} +} + +// TemplateGroupNamespaceLister helps list and get TemplateGroups. +// All objects returned here must be treated as read-only. +type TemplateGroupNamespaceLister interface { + // List lists all TemplateGroups in the indexer for a given namespace. + // Objects returned here must be treated as read-only. + List(selector labels.Selector) (ret []*v0alpha1.TemplateGroup, err error) + // Get retrieves the TemplateGroup from the indexer for a given namespace and name. + // Objects returned here must be treated as read-only. + Get(name string) (*v0alpha1.TemplateGroup, error) + TemplateGroupNamespaceListerExpansion +} + +// templateGroupNamespaceLister implements the TemplateGroupNamespaceLister +// interface. +type templateGroupNamespaceLister struct { + listers.ResourceIndexer[*v0alpha1.TemplateGroup] +} diff --git a/pkg/infra/db/db.go b/pkg/infra/db/db.go index c642a411840..5f61d5d3847 100644 --- a/pkg/infra/db/db.go +++ b/pkg/infra/db/db.go @@ -59,15 +59,6 @@ func InitTestDB(t sqlutil.ITestDB, opts ...InitTestDBOpt) *sqlstore.SQLStore { return db } -func InitTestReplDBWithCfg(t sqlutil.ITestDB, opts ...InitTestDBOpt) (*sqlstore.ReplStore, *setting.Cfg) { - return sqlstore.InitTestReplDB(t, opts...) -} - -func InitTestReplDB(t sqlutil.ITestDB, opts ...InitTestDBOpt) *sqlstore.ReplStore { - db, _ := InitTestReplDBWithCfg(t, opts...) - return db -} - func InitTestDBWithCfg(t sqlutil.ITestDB, opts ...InitTestDBOpt) (*sqlstore.SQLStore, *setting.Cfg) { return sqlstore.InitTestDB(t, opts...) } diff --git a/pkg/infra/db/dbrepl.go b/pkg/infra/db/dbrepl.go deleted file mode 100644 index 028989b44ec..00000000000 --- a/pkg/infra/db/dbrepl.go +++ /dev/null @@ -1,19 +0,0 @@ -package db - -import ( - "github.com/grafana/grafana/pkg/services/sqlstore" -) - -type ReplDB interface { - // DB is the primary database connection. - DB() *sqlstore.SQLStore - - // ReadReplica is the read-only database connection. If no read replica is configured, the implementation must return the primary DB. - ReadReplica() *sqlstore.SQLStore -} - -// FakeREplDBFromDBForTests returns a ReplDB that uses the given DB as the primary connection. It's a helper function for tests. -func FakeReplDBFromDB(primary DB) ReplDB { - ss := primary.(*sqlstore.SQLStore) - return sqlstore.FakeReplStoreFromStore(ss) -} diff --git a/pkg/infra/usagestats/statscollector/concurrent_users_test.go b/pkg/infra/usagestats/statscollector/concurrent_users_test.go index 919a74fa22e..decb1642657 100644 --- a/pkg/infra/usagestats/statscollector/concurrent_users_test.go +++ b/pkg/infra/usagestats/statscollector/concurrent_users_test.go @@ -24,7 +24,7 @@ func TestMain(m *testing.M) { } func TestConcurrentUsersMetrics(t *testing.T) { - sqlStore, cfg := db.InitTestReplDBWithCfg(t) + sqlStore, cfg := db.InitTestDBWithCfg(t) statsService := statsimpl.ProvideService(&setting.Cfg{}, sqlStore) s := createService(t, cfg, sqlStore, statsService) @@ -42,7 +42,7 @@ func TestConcurrentUsersMetrics(t *testing.T) { } func TestConcurrentUsersStats(t *testing.T) { - sqlStore, cfg := db.InitTestReplDBWithCfg(t) + sqlStore, cfg := db.InitTestDBWithCfg(t) statsService := statsimpl.ProvideService(&setting.Cfg{}, sqlStore) s := createService(t, cfg, sqlStore, statsService) diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index 7ca1b5ebf25..338ceb77f25 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -44,14 +44,26 @@ func notAuthorized(c *contextmodel.ReqContext) { return } - writeRedirectCookie(c) + if !c.UseSessionStorageRedirect { + writeRedirectCookie(c) + } if errors.Is(c.LookupTokenErr, authn.ErrTokenNeedsRotation) { - c.Redirect(setting.AppSubUrl + "/user/auth-tokens/rotate") + if !c.UseSessionStorageRedirect { + c.Redirect(setting.AppSubUrl + "/user/auth-tokens/rotate") + return + } + + c.Redirect(setting.AppSubUrl + "/user/auth-tokens/rotate" + getRedirectToQueryParam(c)) return } - c.Redirect(setting.AppSubUrl + "/login") + if !c.UseSessionStorageRedirect { + c.Redirect(setting.AppSubUrl + "/login") + return + } + + c.Redirect(setting.AppSubUrl + "/login" + getRedirectToQueryParam(c)) } func tokenRevoked(c *contextmodel.ReqContext, err *auth.TokenRevokedError) { @@ -66,8 +78,13 @@ func tokenRevoked(c *contextmodel.ReqContext, err *auth.TokenRevokedError) { return } - writeRedirectCookie(c) - c.Redirect(setting.AppSubUrl + "/login") + if !c.UseSessionStorageRedirect { + writeRedirectCookie(c) + c.Redirect(setting.AppSubUrl + "/login") + return + } + + c.Redirect(setting.AppSubUrl + "/login" + getRedirectToQueryParam(c)) } func writeRedirectCookie(c *contextmodel.ReqContext) { @@ -85,6 +102,21 @@ func writeRedirectCookie(c *contextmodel.ReqContext) { cookies.WriteCookie(c.Resp, "redirect_to", url.QueryEscape(redirectTo), 0, nil) } +func getRedirectToQueryParam(c *contextmodel.ReqContext) string { + redirectTo := c.Req.RequestURI + if setting.AppSubUrl != "" && strings.HasPrefix(redirectTo, setting.AppSubUrl) { + redirectTo = strings.TrimPrefix(redirectTo, setting.AppSubUrl) + } + + if redirectTo == "/" { + return "" + } + + // remove any forceLogin=true params + redirectTo = removeForceLoginParams(redirectTo) + return "?redirectTo=" + url.QueryEscape(redirectTo) +} + var forceLoginParamsRegexp = regexp.MustCompile(`&?forceLogin=true`) func removeForceLoginParams(str string) string { diff --git a/pkg/middleware/auth_test.go b/pkg/middleware/auth_test.go index dd63f6cfaa3..3530c90f211 100644 --- a/pkg/middleware/auth_test.go +++ b/pkg/middleware/auth_test.go @@ -33,7 +33,7 @@ func setupAuthMiddlewareTest(t *testing.T, identity *authn.Identity, authErr err return contexthandler.ProvideService(setting.NewCfg(), tracing.InitializeTracerForTest(), &authntest.FakeService{ ExpectedErr: authErr, ExpectedIdentity: identity, - }) + }, featuremgmt.WithFeatures()) } func TestAuth_Middleware(t *testing.T) { diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go index 1510e6caab1..bd447ab5779 100644 --- a/pkg/middleware/middleware_test.go +++ b/pkg/middleware/middleware_test.go @@ -19,6 +19,7 @@ import ( "github.com/grafana/grafana/pkg/services/authn/authntest" "github.com/grafana/grafana/pkg/services/contexthandler" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/navtree" "github.com/grafana/grafana/pkg/services/user/usertest" "github.com/grafana/grafana/pkg/setting" @@ -290,5 +291,5 @@ func getContextHandler(t *testing.T, cfg *setting.Cfg, authnService authn.Servic t.Helper() tracer := tracing.InitializeTracerForTest() - return contexthandler.ProvideService(cfg, tracer, authnService) + return contexthandler.ProvideService(cfg, tracer, authnService, featuremgmt.WithFeatures()) } diff --git a/pkg/registry/apis/alerting/notifications/receiver/storage.go b/pkg/registry/apis/alerting/notifications/receiver/storage.go index 5a2f5804e9b..36b8932604a 100644 --- a/pkg/registry/apis/alerting/notifications/receiver/storage.go +++ b/pkg/registry/apis/alerting/notifications/receiver/storage.go @@ -43,23 +43,11 @@ func NewStorage( metadata: metadata, } if optsGetter != nil && dualWriteBuilder != nil { - strategy := grafanaregistry.NewStrategy(scheme, resourceInfo.GroupVersion()) - s := &genericregistry.Store{ - NewFunc: resourceInfo.NewFunc, - NewListFunc: resourceInfo.NewListFunc, - PredicateFunc: Matcher, - DefaultQualifiedResource: resourceInfo.GroupResource(), - SingularQualifiedResource: resourceInfo.SingularGroupResource(), - TableConvertor: legacyStore.tableConverter, - CreateStrategy: strategy, - UpdateStrategy: strategy, - DeleteStrategy: strategy, - } - options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: grafanaregistry.GetAttrs} - if err := s.CompleteWithOptions(options); err != nil { + store, err := grafanaregistry.NewRegistryStore(scheme, resourceInfo, optsGetter) + if err != nil { return nil, err } - return dualWriteBuilder(resourceInfo.GroupResource(), legacyStore, storage{Store: s}) + return dualWriteBuilder(resourceInfo.GroupResource(), legacyStore, store) } return legacyStore, nil } diff --git a/pkg/registry/apis/alerting/notifications/register.go b/pkg/registry/apis/alerting/notifications/register.go index be565852adc..f073a3c7d93 100644 --- a/pkg/registry/apis/alerting/notifications/register.go +++ b/pkg/registry/apis/alerting/notifications/register.go @@ -16,6 +16,7 @@ import ( notificationsModels "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" receiver "github.com/grafana/grafana/pkg/registry/apis/alerting/notifications/receiver" + "github.com/grafana/grafana/pkg/registry/apis/alerting/notifications/template_group" timeInterval "github.com/grafana/grafana/pkg/registry/apis/alerting/notifications/timeinterval" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/apiserver/builder" @@ -81,9 +82,15 @@ func (t *NotificationsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiser return fmt.Errorf("failed to initialize receiver storage: %w", err) } + templ, err := template_group.NewStorage(t.ng.Api.Templates, t.namespacer, scheme, optsGetter, dualWriteBuilder) + if err != nil { + return fmt.Errorf("failed to initialize templates group storage: %w", err) + } + apiGroupInfo.VersionedResourcesStorageMap[notificationsModels.VERSION] = map[string]rest.Storage{ - notificationsModels.TimeIntervalResourceInfo.StoragePath(): intervals, - notificationsModels.ReceiverResourceInfo.StoragePath(): recvStorage, + notificationsModels.TimeIntervalResourceInfo.StoragePath(): intervals, + notificationsModels.ReceiverResourceInfo.StoragePath(): recvStorage, + notificationsModels.TemplateGroupResourceInfo.StoragePath(): templ, } return nil } @@ -107,6 +114,7 @@ func (t *NotificationsAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3 // Hide the ability to list or watch across all tenants delete(oas.Paths.Paths, root+notificationsModels.ReceiverResourceInfo.GroupResource().Resource) delete(oas.Paths.Paths, root+notificationsModels.TimeIntervalResourceInfo.GroupResource().Resource) + delete(oas.Paths.Paths, root+notificationsModels.TemplateGroupResourceInfo.GroupResource().Resource) // The root API discovery list sub := oas.Paths.Paths[root] @@ -120,6 +128,8 @@ func (t *NotificationsAPIBuilder) GetAuthorizer() authorizer.Authorizer { return authorizer.AuthorizerFunc( func(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) { switch a.GetResource() { + case notificationsModels.TemplateGroupResourceInfo.GroupResource().Resource: + return template_group.Authorize(ctx, t.authz, a) case notificationsModels.TimeIntervalResourceInfo.GroupResource().Resource: return timeInterval.Authorize(ctx, t.authz, a) case notificationsModels.ReceiverResourceInfo.GroupResource().Resource: diff --git a/pkg/registry/apis/alerting/notifications/template_group/authorize.go b/pkg/registry/apis/alerting/notifications/template_group/authorize.go new file mode 100644 index 00000000000..b9a3f92224a --- /dev/null +++ b/pkg/registry/apis/alerting/notifications/template_group/authorize.go @@ -0,0 +1,54 @@ +package template_group + +import ( + "context" + + "k8s.io/apiserver/pkg/authorization/authorizer" + + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/services/accesscontrol" +) + +func Authorize(ctx context.Context, ac accesscontrol.AccessControl, attr authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) { + if attr.GetResource() != resourceInfo.GroupResource().Resource { + return authorizer.DecisionNoOpinion, "", nil + } + user, err := identity.GetRequester(ctx) + if err != nil { + return authorizer.DecisionDeny, "valid user is required", err + } + + var action accesscontrol.Evaluator + switch attr.GetVerb() { + case "patch": + fallthrough + case "create": + fallthrough + case "update": + action = accesscontrol.EvalAny( + accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsWrite), + accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsTemplatesWrite), + ) + case "deletecollection": + fallthrough + case "delete": + action = accesscontrol.EvalAny( + accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsWrite), + accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsTemplatesDelete), + ) + } + + eval := accesscontrol.EvalAny( + accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsRead), + accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsTemplatesRead), + ) + if action != nil { + eval = accesscontrol.EvalAll(eval, action) + } + + ok, err := ac.Evaluate(ctx, user, eval) + if ok { + return authorizer.DecisionAllow, "", nil + } + return authorizer.DecisionDeny, "", err +} diff --git a/pkg/registry/apis/alerting/notifications/template_group/conversions.go b/pkg/registry/apis/alerting/notifications/template_group/conversions.go new file mode 100644 index 00000000000..4988f3187f5 --- /dev/null +++ b/pkg/registry/apis/alerting/notifications/template_group/conversions.go @@ -0,0 +1,52 @@ +package template_group + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/types" + + model "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" +) + +func convertToK8sResources(orgID int64, list []definitions.NotificationTemplate, namespacer request.NamespaceMapper, selector fields.Selector) (*model.TemplateGroupList, error) { + result := &model.TemplateGroupList{} + for _, t := range list { + item := convertToK8sResource(orgID, t, namespacer) + if selector != nil && !selector.Empty() && !selector.Matches(model.SelectableTemplateGroupFields(item)) { + continue + } + result.Items = append(result.Items, *item) + } + return result, nil +} + +func convertToK8sResource(orgID int64, template definitions.NotificationTemplate, namespacer request.NamespaceMapper) *model.TemplateGroup { + result := &model.TemplateGroup{ + TypeMeta: resourceInfo.TypeMeta(), + ObjectMeta: metav1.ObjectMeta{ + UID: types.UID(template.UID), + Name: template.UID, + Namespace: namespacer(orgID), + ResourceVersion: template.ResourceVersion, + }, + Spec: model.TemplateGroupSpec{ + Title: template.Name, + Content: template.Template, + }, + } + result.SetProvenanceStatus(string(template.Provenance)) + return result +} + +func convertToDomainModel(template *model.TemplateGroup) definitions.NotificationTemplate { + return definitions.NotificationTemplate{ + UID: template.ObjectMeta.Name, + Name: template.Spec.Title, + Template: template.Spec.Content, + ResourceVersion: template.ResourceVersion, + Provenance: definitions.Provenance(ngmodels.ProvenanceNone), + } +} diff --git a/pkg/registry/apis/alerting/notifications/template_group/legacy_storage.go b/pkg/registry/apis/alerting/notifications/template_group/legacy_storage.go new file mode 100644 index 00000000000..09a783e2af1 --- /dev/null +++ b/pkg/registry/apis/alerting/notifications/template_group/legacy_storage.go @@ -0,0 +1,187 @@ +package template_group + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/internalversion" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/rest" + + notifications "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1" + grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/models" +) + +var ( + _ grafanarest.LegacyStorage = (*legacyStorage)(nil) +) + +type TemplateService interface { + GetTemplate(ctx context.Context, orgID int64, nameOrUid string) (definitions.NotificationTemplate, error) + GetTemplates(ctx context.Context, orgID int64) ([]definitions.NotificationTemplate, error) + CreateTemplate(ctx context.Context, orgID int64, tmpl definitions.NotificationTemplate) (definitions.NotificationTemplate, error) + UpdateTemplate(ctx context.Context, orgID int64, tmpl definitions.NotificationTemplate) (definitions.NotificationTemplate, error) + DeleteTemplate(ctx context.Context, orgID int64, nameOrUid string, provenance definitions.Provenance, version string) error +} + +var resourceInfo = notifications.TemplateGroupResourceInfo + +type legacyStorage struct { + service TemplateService + namespacer request.NamespaceMapper + tableConverter rest.TableConvertor +} + +func (s *legacyStorage) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) { + return nil, errors.NewMethodNotSupported(resourceInfo.GroupResource(), "deleteCollection") +} + +func (s *legacyStorage) New() runtime.Object { + return resourceInfo.NewFunc() +} + +func (s *legacyStorage) Destroy() {} + +func (s *legacyStorage) NamespaceScoped() bool { + return true // namespace == org +} + +func (s *legacyStorage) GetSingularName() string { + return resourceInfo.GetSingularName() +} + +func (s *legacyStorage) NewList() runtime.Object { + return resourceInfo.NewListFunc() +} + +func (s *legacyStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) { + return s.tableConverter.ConvertToTable(ctx, object, tableOptions) +} + +func (s *legacyStorage) List(ctx context.Context, opts *internalversion.ListOptions) (runtime.Object, error) { + orgId, err := request.OrgIDForList(ctx) + if err != nil { + return nil, err + } + + res, err := s.service.GetTemplates(ctx, orgId) + if err != nil { + return nil, err + } + + return convertToK8sResources(orgId, res, s.namespacer, opts.FieldSelector) +} + +func (s *legacyStorage) Get(ctx context.Context, name string, _ *metav1.GetOptions) (runtime.Object, error) { + info, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, err + } + + dto, err := s.service.GetTemplate(ctx, info.OrgID, name) + if err != nil { + return nil, err + } + return convertToK8sResource(info.OrgID, dto, s.namespacer), nil +} + +func (s *legacyStorage) Create(ctx context.Context, + obj runtime.Object, + createValidation rest.ValidateObjectFunc, + _ *metav1.CreateOptions, +) (runtime.Object, error) { + info, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, err + } + if createValidation != nil { + if err := createValidation(ctx, obj.DeepCopyObject()); err != nil { + return nil, err + } + } + p, ok := obj.(*notifications.TemplateGroup) + if !ok { + return nil, fmt.Errorf("expected template but got %s", obj.GetObjectKind().GroupVersionKind()) + } + if p.ObjectMeta.Name != "" { // TODO remove when metadata.name can be defined by user + return nil, errors.NewBadRequest("object's metadata.name should be empty") + } + out, err := s.service.CreateTemplate(ctx, info.OrgID, convertToDomainModel(p)) + if err != nil { + return nil, err + } + return convertToK8sResource(info.OrgID, out, s.namespacer), nil +} + +func (s *legacyStorage) Update(ctx context.Context, + name string, + objInfo rest.UpdatedObjectInfo, + createValidation rest.ValidateObjectFunc, + updateValidation rest.ValidateObjectUpdateFunc, + _ bool, + _ *metav1.UpdateOptions, +) (runtime.Object, bool, error) { + info, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, false, err + } + + dto, err := s.service.GetTemplate(ctx, info.OrgID, name) + if err != nil { + return nil, false, err + } + old := convertToK8sResource(info.OrgID, dto, s.namespacer) + + obj, err := objInfo.UpdatedObject(ctx, old) + if err != nil { + return old, false, err + } + + if updateValidation != nil { + if err := updateValidation(ctx, obj, old); err != nil { + return nil, false, err + } + } + + p, ok := obj.(*notifications.TemplateGroup) + if !ok { + return nil, false, fmt.Errorf("expected template but got %s", obj.GetObjectKind().GroupVersionKind()) + } + + domainModel := convertToDomainModel(p) + updated, err := s.service.UpdateTemplate(ctx, info.OrgID, domainModel) + if err != nil { + return nil, false, err + } + + r := convertToK8sResource(info.OrgID, updated, s.namespacer) + return r, false, nil +} + +// GracefulDeleter +func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) { + info, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, false, err + } + old, err := s.Get(ctx, name, nil) + if err != nil { + return old, false, err + } + version := "" + if options.Preconditions != nil && options.Preconditions.ResourceVersion != nil { + version = *options.Preconditions.ResourceVersion + } + if deleteValidation != nil { + if err = deleteValidation(ctx, old); err != nil { + return nil, false, err + } + } + err = s.service.DeleteTemplate(ctx, info.OrgID, name, definitions.Provenance(models.ProvenanceNone), version) // TODO add support for dry-run option + return old, false, err // false - will be deleted async +} diff --git a/pkg/registry/apis/alerting/notifications/template_group/storage.go b/pkg/registry/apis/alerting/notifications/template_group/storage.go new file mode 100644 index 00000000000..49c14ea2863 --- /dev/null +++ b/pkg/registry/apis/alerting/notifications/template_group/storage.go @@ -0,0 +1,67 @@ +package template_group + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/generic" + genericregistry "k8s.io/apiserver/pkg/registry/generic/registry" + "k8s.io/apiserver/pkg/registry/rest" + apistore "k8s.io/apiserver/pkg/storage" + + model "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1" + grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" + grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" +) + +var _ grafanarest.Storage = (*storage)(nil) + +type storage struct { + *genericregistry.Store +} + +func (s storage) Compare(storageObj, legacyObj runtime.Object) bool { + // TODO implement when supported dual write mode is not Mode0 + return false +} + +func NewStorage( + legacySvc TemplateService, + namespacer request.NamespaceMapper, + scheme *runtime.Scheme, + optsGetter generic.RESTOptionsGetter, + dualWriteBuilder grafanarest.DualWriteBuilder, +) (rest.Storage, error) { + legacyStore := &legacyStorage{ + service: legacySvc, + namespacer: namespacer, + tableConverter: resourceInfo.TableConverter(), + } + if optsGetter != nil && dualWriteBuilder != nil { + store, err := grafanaregistry.NewRegistryStore(scheme, resourceInfo, optsGetter) + if err != nil { + return nil, err + } + return dualWriteBuilder(resourceInfo.GroupResource(), legacyStore, store) + } + return legacyStore, nil +} + +func GetAttrs(obj runtime.Object) (labels.Set, fields.Set, error) { + if s, ok := obj.(*model.TemplateGroup); ok { + return s.Labels, model.SelectableTemplateGroupFields(s), nil + } + return nil, nil, fmt.Errorf("object of type %T is not supported", obj) +} + +// Matcher returns a generic.SelectionPredicate that matches on label and field selectors. +func Matcher(label labels.Selector, field fields.Selector) apistore.SelectionPredicate { + return apistore.SelectionPredicate{ + Label: label, + Field: field, + GetAttrs: GetAttrs, + } +} diff --git a/pkg/registry/apis/alerting/notifications/timeinterval/storage.go b/pkg/registry/apis/alerting/notifications/timeinterval/storage.go index 3bf20b03ddd..63e1943faf8 100644 --- a/pkg/registry/apis/alerting/notifications/timeinterval/storage.go +++ b/pkg/registry/apis/alerting/notifications/timeinterval/storage.go @@ -41,25 +41,11 @@ func NewStorage( tableConverter: resourceInfo.TableConverter(), } if optsGetter != nil && dualWriteBuilder != nil { - strategy := grafanaregistry.NewStrategy(scheme, resourceInfo.GroupVersion()) - s := &genericregistry.Store{ - NewFunc: resourceInfo.NewFunc, - NewListFunc: resourceInfo.NewListFunc, - KeyRootFunc: grafanaregistry.KeyRootFunc(resourceInfo.GroupResource()), - KeyFunc: grafanaregistry.NamespaceKeyFunc(resourceInfo.GroupResource()), - PredicateFunc: Matcher, - DefaultQualifiedResource: resourceInfo.GroupResource(), - SingularQualifiedResource: resourceInfo.SingularGroupResource(), - TableConvertor: legacyStore.tableConverter, - CreateStrategy: strategy, - UpdateStrategy: strategy, - DeleteStrategy: strategy, - } - options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: GetAttrs} - if err := s.CompleteWithOptions(options); err != nil { + store, err := grafanaregistry.NewRegistryStore(scheme, resourceInfo, optsGetter) + if err != nil { return nil, err } - return dualWriteBuilder(resourceInfo.GroupResource(), legacyStore, storage{Store: s}) + return dualWriteBuilder(resourceInfo.GroupResource(), legacyStore, store) } return legacyStore, nil } diff --git a/pkg/registry/apis/dashboard/legacy_storage.go b/pkg/registry/apis/dashboard/legacy_storage.go index 6d3c29eb876..f8607e2a75c 100644 --- a/pkg/registry/apis/dashboard/legacy_storage.go +++ b/pkg/registry/apis/dashboard/legacy_storage.go @@ -1,16 +1,16 @@ package dashboard import ( + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/registry/generic" + "k8s.io/apiserver/pkg/registry/rest" + "github.com/grafana/grafana/pkg/apimachinery/utils" grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" "github.com/grafana/grafana/pkg/storage/unified/apistore" "github.com/grafana/grafana/pkg/storage/unified/resource" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apiserver/pkg/registry/generic" - genericregistry "k8s.io/apiserver/pkg/registry/generic/registry" - "k8s.io/apiserver/pkg/registry/rest" ) type dashboardStorage struct { @@ -46,24 +46,5 @@ func (s *dashboardStorage) newStore(scheme *runtime.Scheme, defaultOptsGetter ge defaultOpts.StorageConfig.Config, ) - strategy := grafanaregistry.NewStrategy(scheme, resourceInfo.GroupVersion()) - store := &genericregistry.Store{ - NewFunc: resourceInfo.NewFunc, - NewListFunc: resourceInfo.NewListFunc, - KeyRootFunc: grafanaregistry.KeyRootFunc(resourceInfo.GroupResource()), - KeyFunc: grafanaregistry.NamespaceKeyFunc(resourceInfo.GroupResource()), - PredicateFunc: grafanaregistry.Matcher, - DefaultQualifiedResource: resourceInfo.GroupResource(), - SingularQualifiedResource: resourceInfo.SingularGroupResource(), - CreateStrategy: strategy, - UpdateStrategy: strategy, - DeleteStrategy: strategy, - TableConvertor: s.tableConverter, - } - - options := &generic.StoreOptions{RESTOptions: optsGetter} - if err := store.CompleteWithOptions(options); err != nil { - return nil, err - } - return store, err + return grafanaregistry.NewRegistryStore(scheme, resourceInfo, optsGetter) } diff --git a/pkg/registry/apis/dashboard/register.go b/pkg/registry/apis/dashboard/register.go index 10a5826a392..ae57adfe147 100644 --- a/pkg/registry/apis/dashboard/register.go +++ b/pkg/registry/apis/dashboard/register.go @@ -140,15 +140,10 @@ func (b *DashboardsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver // Dual writes if a RESTOptionsGetter is provided if optsGetter != nil && dualWriteBuilder != nil { - store, err := newStorage(scheme) + store, err := grafanaregistry.NewRegistryStore(scheme, dash, optsGetter) if err != nil { return err } - - options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: grafanaregistry.GetAttrs} - if err := store.CompleteWithOptions(options); err != nil { - return err - } storage[dash.StoragePath()], err = dualWriteBuilder(dash.GroupResource(), legacyStore, store) if err != nil { return err diff --git a/pkg/registry/apis/dashboard/storage.go b/pkg/registry/apis/dashboard/storage.go deleted file mode 100644 index 91ae97525aa..00000000000 --- a/pkg/registry/apis/dashboard/storage.go +++ /dev/null @@ -1,35 +0,0 @@ -package dashboard - -import ( - dashboard "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1" - grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" - grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" - "k8s.io/apimachinery/pkg/runtime" - genericregistry "k8s.io/apiserver/pkg/registry/generic/registry" -) - -var _ grafanarest.Storage = (*storage)(nil) - -type storage struct { - *genericregistry.Store -} - -func newStorage(scheme *runtime.Scheme) (*storage, error) { - resourceInfo := dashboard.DashboardResourceInfo - strategy := grafanaregistry.NewStrategy(scheme, resourceInfo.GroupVersion()) - store := &genericregistry.Store{ - NewFunc: resourceInfo.NewFunc, - NewListFunc: resourceInfo.NewListFunc, - KeyRootFunc: grafanaregistry.KeyRootFunc(resourceInfo.GroupResource()), - KeyFunc: grafanaregistry.NamespaceKeyFunc(resourceInfo.GroupResource()), - PredicateFunc: grafanaregistry.Matcher, - DefaultQualifiedResource: resourceInfo.GroupResource(), - SingularQualifiedResource: resourceInfo.SingularGroupResource(), - CreateStrategy: strategy, - UpdateStrategy: strategy, - DeleteStrategy: strategy, - TableConvertor: resourceInfo.TableConverter(), - } - - return &storage{Store: store}, nil -} diff --git a/pkg/registry/apis/datasource/queryconvert.go b/pkg/registry/apis/datasource/queryconvert.go new file mode 100644 index 00000000000..4381dc13484 --- /dev/null +++ b/pkg/registry/apis/datasource/queryconvert.go @@ -0,0 +1,137 @@ +package datasource + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/registry/rest" + + query "github.com/grafana/grafana/pkg/apis/query/v0alpha1" + "github.com/grafana/grafana/pkg/web" +) + +type pluginClientConversion interface { + backend.ConversionHandler +} + +type queryConvertREST struct { + client pluginClientConversion + contextProvider PluginContextWrapper +} + +var ( + _ rest.Storage = (*queryConvertREST)(nil) + _ rest.Connecter = (*queryConvertREST)(nil) + _ rest.Scoper = (*queryConvertREST)(nil) + _ rest.SingularNameProvider = (*queryConvertREST)(nil) +) + +func registerQueryConvert(client pluginClientConversion, contextProvider PluginContextWrapper, storage map[string]rest.Storage) { + store := &queryConvertREST{ + client: client, + contextProvider: contextProvider, + } + storage["queryconvert"] = store +} + +func (r *queryConvertREST) New() runtime.Object { + return &query.QueryDataRequest{} +} + +func (r *queryConvertREST) Destroy() {} + +func (r *queryConvertREST) NamespaceScoped() bool { + return true +} + +func (r *queryConvertREST) GetSingularName() string { + return "queryconvert" +} + +func (r *queryConvertREST) ConnectMethods() []string { + return []string{"POST"} +} + +func (r *queryConvertREST) NewConnectOptions() (runtime.Object, bool, string) { + return nil, false, "" // true means you can use the trailing path as a variable +} + +func (r *queryConvertREST) convertQueryDataRequest(ctx context.Context, req *http.Request) (*query.QueryDataRequest, error) { + dqr := data.QueryDataRequest{} + err := web.Bind(req, &dqr) + if err != nil { + return nil, err + } + + ds := dqr.Queries[0].Datasource + pluginCtx, err := r.contextProvider.PluginContextForDataSource(ctx, &backend.DataSourceInstanceSettings{ + Type: ds.Type, + UID: ds.UID, + APIVersion: ds.APIVersion, + }) + if err != nil { + return nil, err + } + + ctx = backend.WithGrafanaConfig(ctx, pluginCtx.GrafanaConfig) + raw, err := json.Marshal(dqr) + if err != nil { + return nil, fmt.Errorf("marshal: %w", err) + } + convertRequest := &backend.ConversionRequest{ + PluginContext: pluginCtx, + Objects: []backend.RawObject{ + { + Raw: raw, + ContentType: "application/json", + }, + }, + } + + convertResponse, err := r.client.ConvertObjects(ctx, convertRequest) + if err != nil { + if convertResponse != nil && convertResponse.Result != nil { + return nil, fmt.Errorf("conversion failed. Err: %w. Result: %s", err, convertResponse.Result.Message) + } + return nil, err + } + + qr := &query.QueryDataRequest{} + for _, obj := range convertResponse.Objects { + if obj.ContentType != "application/json" { + return nil, fmt.Errorf("unexpected content type: %s", obj.ContentType) + } + q := &data.DataQuery{} + err = json.Unmarshal(obj.Raw, q) + if err != nil { + return nil, fmt.Errorf("unmarshal: %w", err) + } + qr.Queries = append(qr.Queries, *q) + } + + return qr, nil +} + +func (r *queryConvertREST) Connect(ctx context.Context, name string, _ runtime.Object, responder rest.Responder) (http.Handler, error) { + // See: /pkg/services/apiserver/builder/helper.go#L34 + // The name is set with a rewriter hack + if name != "name" { + return nil, errors.NewNotFound(schema.GroupResource{}, name) + } + + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + r, err := r.convertQueryDataRequest(ctx, req) + if err != nil { + responder.Error(err) + return + } + responder.Object(http.StatusOK, r) + }), nil +} diff --git a/pkg/registry/apis/datasource/queryconvert_test.go b/pkg/registry/apis/datasource/queryconvert_test.go new file mode 100644 index 00000000000..41707104ad7 --- /dev/null +++ b/pkg/registry/apis/datasource/queryconvert_test.go @@ -0,0 +1,90 @@ +package datasource + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/runtime" +) + +func TestSubQueryConvertConnect(t *testing.T) { + originReq := `{"from":"","to":"","queries":[{"refId":"A","datasource":{"type":"","uid":"dsuid"},"rawSql":"SELECT * FROM table"}]}` + converted := `{"refId":"A","datasource":{"type":"","uid":"dsuid"},"SQL":"SELECT * FROM table"}` + convertedReq := `{"from":"","to":"","queries":[` + converted + `]}` + + sqr := queryConvertREST{ + client: mockConvertClient{ + t: t, + expectedInput: backend.RawObject{Raw: []byte(originReq), ContentType: "application/json"}, + convertObject: backend.RawObject{Raw: []byte(converted), ContentType: "application/json"}, + }, + contextProvider: mockContextProvider{}, + } + rr := httptest.NewRecorder() + mr := &mockResponderConvert{ + writer: rr, + } + handler, err := sqr.Connect(context.Background(), "name", nil, mr) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "/", bytes.NewReader([]byte(originReq))) + req.Header.Set("Content-Type", "application/json") + handler.ServeHTTP(rr, req) + + require.Equal(t, http.StatusOK, rr.Code) + require.Contains(t, rr.Body.String(), convertedReq) +} + +type mockConvertClient struct { + t *testing.T + expectedInput backend.RawObject + convertObject backend.RawObject +} + +func (m mockConvertClient) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { + return nil, nil +} + +func (m mockConvertClient) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error { + return nil +} + +func (m mockConvertClient) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { + return nil, nil +} + +func (m mockConvertClient) ConvertObjects(ctx context.Context, req *backend.ConversionRequest) (*backend.ConversionResponse, error) { + require.Equal(m.t, string(m.expectedInput.Raw), string(req.Objects[0].Raw)) + return &backend.ConversionResponse{ + Objects: []backend.RawObject{m.convertObject}, + }, nil +} + +type mockResponderConvert struct { + writer http.ResponseWriter +} + +// Object writes the provided object to the response. Invoking this method multiple times is undefined. +func (m mockResponderConvert) Object(statusCode int, obj runtime.Object) { + m.writer.WriteHeader(statusCode) + err := json.NewEncoder(m.writer).Encode(obj) + if err != nil { + panic(err) + } +} + +// Error writes the provided error to the response. This method may only be invoked once. +func (m mockResponderConvert) Error(err error) { + m.writer.WriteHeader(http.StatusInternalServerError) + errStr := err.Error() + _, err = m.writer.Write([]byte(errStr)) + if err != nil { + panic(err) + } +} diff --git a/pkg/registry/apis/datasource/register.go b/pkg/registry/apis/datasource/register.go index e45c403a4d8..d7a3fd963ea 100644 --- a/pkg/registry/apis/datasource/register.go +++ b/pkg/registry/apis/datasource/register.go @@ -104,6 +104,7 @@ type PluginClient interface { backend.QueryDataHandler backend.CheckHealthHandler backend.CallResourceHandler + backend.ConversionHandler } func NewDataSourceAPIBuilder( @@ -225,6 +226,11 @@ func (b *DataSourceAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver // Register hardcoded query schemas err := queryschema.RegisterQueryTypes(b.queryTypes, storage) + if err != nil { + return err + } + + registerQueryConvert(b.client, b.contextProvider, storage) apiGroupInfo.VersionedResourcesStorageMap[conn.GroupVersion().Version] = storage return err diff --git a/pkg/registry/apis/datasource/sub_query_test.go b/pkg/registry/apis/datasource/sub_query_test.go index dc08c74afe4..7657d45b719 100644 --- a/pkg/registry/apis/datasource/sub_query_test.go +++ b/pkg/registry/apis/datasource/sub_query_test.go @@ -71,6 +71,10 @@ func (m mockClient) CheckHealth(ctx context.Context, req *backend.CheckHealthReq return nil, nil } +func (m mockClient) ConvertObjects(ctx context.Context, req *backend.ConversionRequest) (*backend.ConversionResponse, error) { + return nil, nil +} + type mockResponder struct { } diff --git a/pkg/registry/apis/folders/conversions.go b/pkg/registry/apis/folders/conversions.go index 75c13777af1..e70a5dff7d0 100644 --- a/pkg/registry/apis/folders/conversions.go +++ b/pkg/registry/apis/folders/conversions.go @@ -2,6 +2,8 @@ package folders import ( "fmt" + "strconv" + "time" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -9,11 +11,32 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" + "github.com/grafana/grafana/pkg/infra/slugify" "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/folder" ) +func LegacyCreateCommandToUnstructured(cmd folder.CreateFolderCommand) (unstructured.Unstructured, error) { + obj := unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "title": cmd.Title, + "description": cmd.Description, + }, + }, + } + // #TODO: let's see if we need to set the json field to "-" + obj.SetName(cmd.UID) + + if err := setParentUID(&obj, cmd.ParentUID); err != nil { + return unstructured.Unstructured{}, err + } + + return obj, nil +} + func LegacyUpdateCommandToUnstructured(cmd folder.UpdateFolderCommand) unstructured.Unstructured { // #TODO add other fields obj := unstructured.Unstructured{ @@ -36,17 +59,55 @@ func UnstructuredToLegacyFolder(item unstructured.Unstructured) *folder.Folder { } } -func UnstructuredToLegacyFolderDTO(item unstructured.Unstructured) *dtos.Folder { +func UnstructuredToLegacyFolderDTO(item unstructured.Unstructured) (*dtos.Folder, error) { spec := item.Object["spec"].(map[string]any) - dto := &dtos.Folder{ - UID: item.GetName(), - Title: spec["title"].(string), - // #TODO add other fields + uid := item.GetName() + title := spec["title"].(string) + + meta, err := utils.MetaAccessor(&item) + if err != nil { + return nil, err } - return dto + + id, err := getLegacyID(meta) + if err != nil { + return nil, err + } + + created, err := getCreated(meta) + if err != nil { + return nil, err + } + + dto := &dtos.Folder{ + UID: uid, + Title: title, + ID: id, + ParentUID: meta.GetFolder(), + // #TODO add back CreatedBy, UpdatedBy once we figure out how to access userService + // to translate user ID into user login. meta.GetCreatedBy() only stores user ID + // Could convert meta.GetCreatedBy() return value to a struct--id and name + // CreatedBy: meta.GetCreatedBy(), + // UpdatedBy: meta.GetCreatedBy(), + URL: getURL(meta, title), + // #TODO get Created in format "2024-09-12T15:37:41.09466+02:00" + Created: *created, + // #TODO figure out whether we want to set "updated" and "updated by". Could replace with + // meta.GetUpdatedTimestamp() but it currently gets overwritten in prepareObjectForStorage(). + Updated: *created, + // #TODO figure out how to set these properly + CanSave: true, + CanEdit: true, + CanAdmin: true, + CanDelete: true, + HasACL: false, + + // #TODO figure out about adding version, parents, orgID fields + } + return dto, nil } -func convertToK8sResource(v *folder.Folder, namespacer request.NamespaceMapper) *v0alpha1.Folder { +func convertToK8sResource(v *folder.Folder, namespacer request.NamespaceMapper) (*v0alpha1.Folder, error) { f := &v0alpha1.Folder{ TypeMeta: v0alpha1.FolderResourceInfo.TypeMeta(), ObjectMeta: metav1.ObjectMeta{ @@ -62,24 +123,67 @@ func convertToK8sResource(v *folder.Folder, namespacer request.NamespaceMapper) } meta, err := utils.MetaAccessor(f) - if err == nil { - meta.SetUpdatedTimestamp(&v.Updated) - if v.ID > 0 { // nolint:staticcheck - meta.SetOriginInfo(&utils.ResourceOriginInfo{ - Name: "SQL", - Path: fmt.Sprintf("%d", v.ID), // nolint:staticcheck - }) - } - if v.CreatedBy > 0 { - meta.SetCreatedBy(fmt.Sprintf("user:%d", v.CreatedBy)) - } - if v.UpdatedBy > 0 { - meta.SetUpdatedBy(fmt.Sprintf("user:%d", v.UpdatedBy)) - } + if err != nil { + return nil, err + } + + meta.SetUpdatedTimestamp(&v.Updated) + if v.ID > 0 { // nolint:staticcheck + meta.SetOriginInfo(&utils.ResourceOriginInfo{ + Name: "SQL", + Path: fmt.Sprintf("%d", v.ID), // nolint:staticcheck + Timestamp: &v.Created, + }) + } + if v.CreatedBy > 0 { + meta.SetCreatedBy(fmt.Sprintf("user:%d", v.CreatedBy)) + } + if v.UpdatedBy > 0 { + meta.SetUpdatedBy(fmt.Sprintf("user:%d", v.UpdatedBy)) } if v.ParentUID != "" { meta.SetFolder(v.ParentUID) } f.UID = gapiutil.CalculateClusterWideUID(f) - return f + return f, nil +} + +func setParentUID(u *unstructured.Unstructured, parentUid string) error { + meta, err := utils.MetaAccessor(u) + if err != nil { + return err + } + meta.SetFolder(parentUid) + return nil +} + +func getLegacyID(meta utils.GrafanaMetaAccessor) (int64, error) { + var i int64 + + info, err := meta.GetOriginInfo() + if err != nil { + return i, err + } + + if info != nil && info.Name == "SQL" { + i, err = strconv.ParseInt(info.Path, 10, 64) + if err != nil { + return i, err + } + } + return i, nil +} + +func getURL(meta utils.GrafanaMetaAccessor, title string) string { + slug := slugify.Slugify(title) + uid := meta.GetName() + return dashboards.GetFolderURL(uid, slug) +} + +func getCreated(meta utils.GrafanaMetaAccessor) (*time.Time, error) { + created, err := meta.GetOriginTimestamp() + if err != nil { + return nil, err + } + return created, nil } diff --git a/pkg/registry/apis/folders/legacy_storage.go b/pkg/registry/apis/folders/legacy_storage.go index e6986d51c75..283c5d47887 100644 --- a/pkg/registry/apis/folders/legacy_storage.go +++ b/pkg/registry/apis/folders/legacy_storage.go @@ -101,7 +101,11 @@ func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListO list := &v0alpha1.FolderList{} for _, v := range hits { - list.Items = append(list.Items, *convertToK8sResource(v, s.namespacer)) + r, err := convertToK8sResource(v, s.namespacer) + if err != nil { + return nil, err + } + list.Items = append(list.Items, *r) } if len(list.Items) >= int(paging.limit) { list.Continue = paging.GetNextPageToken() @@ -132,7 +136,12 @@ func (s *legacyStorage) Get(ctx context.Context, name string, options *metav1.Ge return nil, err } - return convertToK8sResource(dto, s.namespacer), nil + r, err := convertToK8sResource(dto, s.namespacer) + if err != nil { + return nil, err + } + + return r, nil } func (s *legacyStorage) Create(ctx context.Context, @@ -178,7 +187,15 @@ func (s *legacyStorage) Create(ctx context.Context, if err != nil { return nil, err } - return s.Get(ctx, out.UID, nil) + // #TODO can we directly convert instead of doing a Get? the result of the Create + // has more data than the one of Get so there is more we can include in the k8s resource + // this way + + r, err := convertToK8sResource(out, s.namespacer) + if err != nil { + return nil, err + } + return r, nil } func (s *legacyStorage) Update(ctx context.Context, diff --git a/pkg/registry/apis/folders/register.go b/pkg/registry/apis/folders/register.go index 60c6a278ece..618a4c509d8 100644 --- a/pkg/registry/apis/folders/register.go +++ b/pkg/registry/apis/folders/register.go @@ -16,6 +16,7 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apis/folder/v0alpha1" + grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/apiserver/builder" @@ -109,7 +110,7 @@ func (b *FolderAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.API // enable dual writer if optsGetter != nil && dualWriteBuilder != nil { - store, err := newStorage(scheme, optsGetter, legacyStore) + store, err := grafanaregistry.NewRegistryStore(scheme, resourceInfo, optsGetter) if err != nil { return err } diff --git a/pkg/registry/apis/folders/storage.go b/pkg/registry/apis/folders/storage.go deleted file mode 100644 index 21f98e93842..00000000000 --- a/pkg/registry/apis/folders/storage.go +++ /dev/null @@ -1,40 +0,0 @@ -package folders - -import ( - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apiserver/pkg/registry/generic" - genericregistry "k8s.io/apiserver/pkg/registry/generic/registry" - - grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" - grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" -) - -var _ grafanarest.Storage = (*storage)(nil) - -type storage struct { - *genericregistry.Store -} - -func newStorage(scheme *runtime.Scheme, optsGetter generic.RESTOptionsGetter, legacy *legacyStorage) (*storage, error) { - strategy := grafanaregistry.NewStrategy(scheme, resourceInfo.GroupVersion()) - - store := &genericregistry.Store{ - NewFunc: resourceInfo.NewFunc, - NewListFunc: resourceInfo.NewListFunc, - KeyRootFunc: grafanaregistry.KeyRootFunc(resourceInfo.GroupResource()), - KeyFunc: grafanaregistry.NamespaceKeyFunc(resourceInfo.GroupResource()), - PredicateFunc: grafanaregistry.Matcher, - DefaultQualifiedResource: resourceInfo.GroupResource(), - SingularQualifiedResource: resourceInfo.SingularGroupResource(), - TableConvertor: legacy.tableConverter, - - CreateStrategy: strategy, - UpdateStrategy: strategy, - DeleteStrategy: strategy, - } - options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: grafanaregistry.GetAttrs} - if err := store.CompleteWithOptions(options); err != nil { - return nil, err - } - return &storage{Store: store}, nil -} diff --git a/pkg/registry/apis/peakq/register.go b/pkg/registry/apis/peakq/register.go index 31f41d8c379..bb2d38c356d 100644 --- a/pkg/registry/apis/peakq/register.go +++ b/pkg/registry/apis/peakq/register.go @@ -1,6 +1,7 @@ package peakq import ( + "github.com/prometheus/client_golang/prometheus" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" @@ -12,9 +13,8 @@ import ( "k8s.io/kube-openapi/pkg/spec3" "k8s.io/kube-openapi/pkg/validation/spec" - "github.com/prometheus/client_golang/prometheus" - peakq "github.com/grafana/grafana/pkg/apis/peakq/v0alpha1" + grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/services/apiserver/builder" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -68,10 +68,12 @@ func (b *PeakQAPIBuilder) InstallSchema(scheme *runtime.Scheme) error { func (b *PeakQAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, scheme *runtime.Scheme, optsGetter generic.RESTOptionsGetter, _ grafanarest.DualWriteBuilder) error { resourceInfo := peakq.QueryTemplateResourceInfo storage := map[string]rest.Storage{} - peakqStorage, err := newStorage(scheme, optsGetter) + + peakqStorage, err := grafanaregistry.NewRegistryStore(scheme, resourceInfo, optsGetter) if err != nil { return err } + storage[resourceInfo.StoragePath()] = peakqStorage storage[resourceInfo.StoragePath("render")] = &renderREST{ getter: peakqStorage, diff --git a/pkg/registry/apis/peakq/storage.go b/pkg/registry/apis/peakq/storage.go deleted file mode 100644 index 3953ba3e360..00000000000 --- a/pkg/registry/apis/peakq/storage.go +++ /dev/null @@ -1,41 +0,0 @@ -package peakq - -import ( - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apiserver/pkg/registry/generic" - genericregistry "k8s.io/apiserver/pkg/registry/generic/registry" - - peakq "github.com/grafana/grafana/pkg/apis/peakq/v0alpha1" - grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" - grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" -) - -var _ grafanarest.Storage = (*storage)(nil) - -type storage struct { - *genericregistry.Store -} - -func newStorage(scheme *runtime.Scheme, optsGetter generic.RESTOptionsGetter) (*storage, error) { - resourceInfo := peakq.QueryTemplateResourceInfo - strategy := grafanaregistry.NewStrategy(scheme, resourceInfo.GroupVersion()) - - store := &genericregistry.Store{ - NewFunc: resourceInfo.NewFunc, - NewListFunc: resourceInfo.NewListFunc, - KeyRootFunc: grafanaregistry.KeyRootFunc(resourceInfo.GroupResource()), - KeyFunc: grafanaregistry.NamespaceKeyFunc(resourceInfo.GroupResource()), - PredicateFunc: grafanaregistry.Matcher, - DefaultQualifiedResource: resourceInfo.GroupResource(), - SingularQualifiedResource: resourceInfo.SingularGroupResource(), - TableConvertor: resourceInfo.TableConverter(), - CreateStrategy: strategy, - UpdateStrategy: strategy, - DeleteStrategy: strategy, - } - options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: grafanaregistry.GetAttrs} - if err := store.CompleteWithOptions(options); err != nil { - return nil, err - } - return &storage{Store: store}, nil -} diff --git a/pkg/registry/apis/playlist/conversions.go b/pkg/registry/apis/playlist/conversions.go index 9444840d07a..ec5f2179103 100644 --- a/pkg/registry/apis/playlist/conversions.go +++ b/pkg/registry/apis/playlist/conversions.go @@ -15,6 +15,7 @@ import ( "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" gapiutil "github.com/grafana/grafana/pkg/services/apiserver/utils" playlistsvc "github.com/grafana/grafana/pkg/services/playlist" + "github.com/grafana/grafana/pkg/util" ) func LegacyUpdateCommandToUnstructured(cmd playlistsvc.UpdatePlaylistCommand) unstructured.Unstructured { @@ -34,6 +35,9 @@ func LegacyUpdateCommandToUnstructured(cmd playlistsvc.UpdatePlaylistCommand) un }, }, } + if cmd.UID == "" { + cmd.UID = util.GenerateShortUID() + } obj.SetName(cmd.UID) return obj } diff --git a/pkg/registry/apis/playlist/storage.go b/pkg/registry/apis/playlist/storage.go index ecf75910373..bd5a03b14bb 100644 --- a/pkg/registry/apis/playlist/storage.go +++ b/pkg/registry/apis/playlist/storage.go @@ -4,52 +4,28 @@ import ( "strings" "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apiserver/pkg/registry/generic" genericregistry "k8s.io/apiserver/pkg/registry/generic/registry" playlist "github.com/grafana/grafana/apps/playlist/apis/playlist/v0alpha1" + "github.com/grafana/grafana/pkg/apimachinery/utils" grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" - grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" ) -var _ grafanarest.Storage = (*storage)(nil) - -type storage struct { - *genericregistry.Store -} - -func newStorage(scheme *runtime.Scheme, optsGetter generic.RESTOptionsGetter, legacy *legacyStorage) (*storage, error) { - gr := schema.GroupResource{ - Group: playlist.PlaylistKind().Group(), - Resource: playlist.PlaylistKind().Plural(), - } - singularGR := schema.GroupResource{ - Group: playlist.PlaylistKind().Group(), - Resource: strings.ToLower(playlist.PlaylistKind().Kind()), - } - strategy := grafanaregistry.NewStrategy(scheme, gr.WithVersion(playlist.PlaylistKind().Version()).GroupVersion()) - store := &genericregistry.Store{ - NewFunc: func() runtime.Object { - return playlist.PlaylistKind().ZeroValue() +func newStorage(scheme *runtime.Scheme, optsGetter generic.RESTOptionsGetter, legacy *legacyStorage) (*genericregistry.Store, error) { + kind := playlist.PlaylistKind() + singular := strings.ToLower(kind.Kind()) // ??? + resourceInfo := utils.NewResourceInfo( + kind.Group(), kind.Version(), + kind.GroupVersionResource().Resource, singular, + kind.Kind(), + func() runtime.Object { + return kind.ZeroValue() }, - NewListFunc: func() runtime.Object { - return playlist.PlaylistKind().ZeroListValue() + func() runtime.Object { + return kind.ZeroListValue() }, - KeyRootFunc: grafanaregistry.KeyRootFunc(gr), - KeyFunc: grafanaregistry.NamespaceKeyFunc(gr), - PredicateFunc: grafanaregistry.Matcher, - DefaultQualifiedResource: gr, - SingularQualifiedResource: singularGR, - TableConvertor: legacy.tableConverter, - - CreateStrategy: strategy, - UpdateStrategy: strategy, - DeleteStrategy: strategy, - } - options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: grafanaregistry.GetAttrs} - if err := store.CompleteWithOptions(options); err != nil { - return nil, err - } - return &storage{Store: store}, nil + utils.TableColumns{}, // will use default columns... TODO? legacy.tableConverter., + ) + return grafanaregistry.NewRegistryStore(scheme, resourceInfo, optsGetter) } diff --git a/pkg/registry/apis/service/register.go b/pkg/registry/apis/service/register.go index 17763fa64e9..39138660134 100644 --- a/pkg/registry/apis/service/register.go +++ b/pkg/registry/apis/service/register.go @@ -1,6 +1,7 @@ package service import ( + "github.com/prometheus/client_golang/prometheus" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" @@ -10,9 +11,8 @@ import ( genericapiserver "k8s.io/apiserver/pkg/server" "k8s.io/kube-openapi/pkg/common" - "github.com/prometheus/client_golang/prometheus" - service "github.com/grafana/grafana/pkg/apis/service/v0alpha1" + grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/services/apiserver/builder" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -73,10 +73,12 @@ func (b *ServiceAPIBuilder) InstallSchema(scheme *runtime.Scheme) error { func (b *ServiceAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, scheme *runtime.Scheme, optsGetter generic.RESTOptionsGetter, _ grafanarest.DualWriteBuilder) error { resourceInfo := service.ExternalNameResourceInfo storage := map[string]rest.Storage{} - serviceStorage, err := newStorage(scheme, optsGetter) + + serviceStorage, err := grafanaregistry.NewRegistryStore(scheme, resourceInfo, optsGetter) if err != nil { return err } + storage[resourceInfo.StoragePath()] = serviceStorage apiGroupInfo.VersionedResourcesStorageMap[service.VERSION] = storage return nil diff --git a/pkg/registry/apis/service/storage.go b/pkg/registry/apis/service/storage.go deleted file mode 100644 index 2ed49bb53f6..00000000000 --- a/pkg/registry/apis/service/storage.go +++ /dev/null @@ -1,41 +0,0 @@ -package service - -import ( - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apiserver/pkg/registry/generic" - genericregistry "k8s.io/apiserver/pkg/registry/generic/registry" - - service "github.com/grafana/grafana/pkg/apis/service/v0alpha1" - grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" - grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" -) - -var _ grafanarest.Storage = (*storage)(nil) - -type storage struct { - *genericregistry.Store -} - -func newStorage(scheme *runtime.Scheme, optsGetter generic.RESTOptionsGetter) (*storage, error) { - resourceInfo := service.ExternalNameResourceInfo - strategy := grafanaregistry.NewStrategy(scheme, resourceInfo.GroupVersion()) - - store := &genericregistry.Store{ - NewFunc: resourceInfo.NewFunc, - NewListFunc: resourceInfo.NewListFunc, - KeyRootFunc: grafanaregistry.KeyRootFunc(resourceInfo.GroupResource()), - KeyFunc: grafanaregistry.NamespaceKeyFunc(resourceInfo.GroupResource()), - PredicateFunc: grafanaregistry.Matcher, - DefaultQualifiedResource: resourceInfo.GroupResource(), - SingularQualifiedResource: resourceInfo.SingularGroupResource(), - TableConvertor: resourceInfo.TableConverter(), - CreateStrategy: strategy, - UpdateStrategy: strategy, - DeleteStrategy: strategy, - } - options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: grafanaregistry.GetAttrs} - if err := store.CompleteWithOptions(options); err != nil { - return nil, err - } - return &storage{Store: store}, nil -} diff --git a/pkg/server/test_env.go b/pkg/server/test_env.go index 104dc1242f6..51a54dde56c 100644 --- a/pkg/server/test_env.go +++ b/pkg/server/test_env.go @@ -16,7 +16,6 @@ import ( func ProvideTestEnv( server *Server, db db.DB, - repldb db.ReplDB, cfg *setting.Cfg, ns *notifications.NotificationServiceMock, grpcServer grpcserver.Provider, @@ -29,7 +28,6 @@ func ProvideTestEnv( return &TestEnv{ Server: server, SQLStore: db, - ReadReplStore: repldb, Cfg: cfg, NotificationService: ns, GRPCServer: grpcServer, @@ -44,7 +42,6 @@ func ProvideTestEnv( type TestEnv struct { Server *Server SQLStore db.DB - ReadReplStore db.ReplDB Cfg *setting.Cfg NotificationService *notifications.NotificationServiceMock GRPCServer grpcserver.Provider diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 98edf7282b9..81574ec9677 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -391,13 +391,11 @@ var wireSet = wire.NewSet( wireBasicSet, metrics.WireSet, sqlstore.ProvideService, - sqlstore.ProvideServiceWithReadReplica, ngmetrics.ProvideService, wire.Bind(new(notifications.Service), new(*notifications.NotificationService)), wire.Bind(new(notifications.WebhookSender), new(*notifications.NotificationService)), wire.Bind(new(notifications.EmailSender), new(*notifications.NotificationService)), wire.Bind(new(db.DB), new(*sqlstore.SQLStore)), - wire.Bind(new(db.ReplDB), new(*sqlstore.ReplStore)), prefimpl.ProvideService, oauthtoken.ProvideService, wire.Bind(new(oauthtoken.OAuthTokenService), new(*oauthtoken.Service)), @@ -408,13 +406,11 @@ var wireCLISet = wire.NewSet( wireBasicSet, metrics.WireSet, sqlstore.ProvideService, - sqlstore.ProvideServiceWithReadReplica, ngmetrics.ProvideService, wire.Bind(new(notifications.Service), new(*notifications.NotificationService)), wire.Bind(new(notifications.WebhookSender), new(*notifications.NotificationService)), wire.Bind(new(notifications.EmailSender), new(*notifications.NotificationService)), wire.Bind(new(db.DB), new(*sqlstore.SQLStore)), - wire.Bind(new(db.ReplDB), new(*sqlstore.ReplStore)), prefimpl.ProvideService, oauthtoken.ProvideService, wire.Bind(new(oauthtoken.OAuthTokenService), new(*oauthtoken.Service)), @@ -425,14 +421,12 @@ var wireTestSet = wire.NewSet( ProvideTestEnv, metrics.WireSetForTest, sqlstore.ProvideServiceForTests, - sqlstore.ProvideServiceWithReadReplicaForTests, ngmetrics.ProvideServiceForTest, notifications.MockNotificationService, wire.Bind(new(notifications.Service), new(*notifications.NotificationServiceMock)), wire.Bind(new(notifications.WebhookSender), new(*notifications.NotificationServiceMock)), wire.Bind(new(notifications.EmailSender), new(*notifications.NotificationServiceMock)), wire.Bind(new(db.DB), new(*sqlstore.SQLStore)), - wire.Bind(new(db.ReplDB), new(*sqlstore.ReplStore)), prefimpl.ProvideService, oauthtoken.ProvideService, oauthtokentest.ProvideService, @@ -446,7 +440,7 @@ func Initialize(cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*Ser func InitializeForTest(t sqlutil.ITestDB, cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*TestEnv, error) { wire.Build(wireExtsTestSet) - return &TestEnv{Server: &Server{}, SQLStore: &sqlstore.SQLStore{}, ReadReplStore: &sqlstore.ReplStore{}, Cfg: &setting.Cfg{}}, nil + return &TestEnv{Server: &Server{}, SQLStore: &sqlstore.SQLStore{}, Cfg: &setting.Cfg{}}, nil } func InitializeForCLI(cfg *setting.Cfg) (Runner, error) { diff --git a/pkg/services/accesscontrol/acimpl/service.go b/pkg/services/accesscontrol/acimpl/service.go index 3d5502afbf1..50616a33d3f 100644 --- a/pkg/services/accesscontrol/acimpl/service.go +++ b/pkg/services/accesscontrol/acimpl/service.go @@ -51,11 +51,11 @@ var SharedWithMeFolderPermission = accesscontrol.Permission{ var OSSRolesPrefixes = []string{accesscontrol.ManagedRolePrefix, accesscontrol.ExternalServiceRolePrefix} func ProvideService( - cfg *setting.Cfg, db db.ReplDB, routeRegister routing.RouteRegister, cache *localcache.CacheService, + cfg *setting.Cfg, db db.DB, routeRegister routing.RouteRegister, cache *localcache.CacheService, accessControl accesscontrol.AccessControl, actionResolver accesscontrol.ActionResolver, features featuremgmt.FeatureToggles, tracer tracing.Tracer, zclient zanzana.Client, permRegistry permreg.PermissionRegistry, ) (*Service, error) { - service := ProvideOSSService(cfg, database.ProvideService(db), actionResolver, cache, features, tracer, zclient, db.DB(), permRegistry) + service := ProvideOSSService(cfg, database.ProvideService(db), actionResolver, cache, features, tracer, zclient, db, permRegistry) api.NewAccessControlAPI(routeRegister, accessControl, service, features).RegisterAPIEndpoints() if err := accesscontrol.DeclareFixedRoles(service, cfg); err != nil { diff --git a/pkg/services/accesscontrol/acimpl/service_bench_test.go b/pkg/services/accesscontrol/acimpl/service_bench_test.go index 864676ae54b..19e13645263 100644 --- a/pkg/services/accesscontrol/acimpl/service_bench_test.go +++ b/pkg/services/accesscontrol/acimpl/service_bench_test.go @@ -27,7 +27,7 @@ import ( // - each managed role will have 3 permissions {"resources:action2", "resources:id:x"} where x belongs to [1, 3] func setupBenchEnv(b *testing.B, usersCount, resourceCount int) (accesscontrol.Service, *user.SignedInUser) { now := time.Now() - sqlStore := db.InitTestReplDB(b) + sqlStore := db.InitTestDB(b) store := database.ProvideService(sqlStore) acService := &Service{ cfg: setting.NewCfg(), diff --git a/pkg/services/accesscontrol/acimpl/service_test.go b/pkg/services/accesscontrol/acimpl/service_test.go index 6ac4e258c8f..7096b2fcac0 100644 --- a/pkg/services/accesscontrol/acimpl/service_test.go +++ b/pkg/services/accesscontrol/acimpl/service_test.go @@ -42,7 +42,7 @@ func setupTestEnv(t testing.TB) *Service { log: log.New("accesscontrol"), registrations: accesscontrol.RegistrationList{}, roles: accesscontrol.BuildBasicRoleDefinitions(), - store: database.ProvideService(db.InitTestReplDB(t)), + store: database.ProvideService(db.InitTestDB(t)), permRegistry: permreg.ProvidePermissionRegistry(), } require.NoError(t, ac.RegisterFixedRoles(context.Background())) @@ -66,7 +66,7 @@ func TestUsageMetrics(t *testing.T) { s := ProvideOSSService( cfg, - database.ProvideService(db.InitTestReplDB(t)), + database.ProvideService(db.InitTestDB(t)), &resourcepermissions.FakeActionSetSvc{}, localcache.ProvideService(), featuremgmt.WithFeatures(), diff --git a/pkg/services/accesscontrol/database/database.go b/pkg/services/accesscontrol/database/database.go index 8443af055f7..34ad42c332e 100644 --- a/pkg/services/accesscontrol/database/database.go +++ b/pkg/services/accesscontrol/database/database.go @@ -39,12 +39,12 @@ const ( WHERE br.role = ?` ) -func ProvideService(sql db.ReplDB) *AccessControlStore { +func ProvideService(sql db.DB) *AccessControlStore { return &AccessControlStore{sql} } type AccessControlStore struct { - sql db.ReplDB + sql db.DB } func (s *AccessControlStore) GetUserPermissions(ctx context.Context, query accesscontrol.GetUserPermissionsQuery) ([]accesscontrol.Permission, error) { @@ -52,7 +52,7 @@ func (s *AccessControlStore) GetUserPermissions(ctx context.Context, query acces defer span.End() result := make([]accesscontrol.Permission, 0) - err := s.sql.ReadReplica().WithDbSession(ctx, func(sess *db.Session) error { + err := s.sql.WithDbSession(ctx, func(sess *db.Session) error { if query.UserID == 0 && len(query.TeamIDs) == 0 && len(query.Roles) == 0 { // no permission to fetch return nil @@ -113,7 +113,7 @@ func (s *AccessControlStore) GetTeamsPermissions(ctx context.Context, query acce orgID := query.OrgID rolePrefixes := query.RolePrefixes result := make([]teamPermission, 0) - err := s.sql.ReadReplica().WithDbSession(ctx, func(sess *db.Session) error { + err := s.sql.WithDbSession(ctx, func(sess *db.Session) error { if len(teams) == 0 { // no permission to fetch return nil @@ -184,7 +184,7 @@ func (s *AccessControlStore) SearchUsersPermissions(ctx context.Context, orgID i } } - if err := s.sql.ReadReplica().WithDbSession(ctx, func(sess *db.Session) error { + if err := s.sql.WithDbSession(ctx, func(sess *db.Session) error { roleNameFilterJoin := "" if len(options.RolePrefixes) > 0 { roleNameFilterJoin = "INNER JOIN role AS r ON up.role_id = r.id" @@ -210,7 +210,7 @@ func (s *AccessControlStore) SearchUsersPermissions(ctx context.Context, orgID i params = append(params, userID) } - grafanaAdmin := fmt.Sprintf(grafanaAdminAssignsSQL, s.sql.ReadReplica().Quote("user")) + grafanaAdmin := fmt.Sprintf(grafanaAdminAssignsSQL, s.sql.Quote("user")) params = append(params, accesscontrol.RoleGrafanaAdmin) if userID >= 0 { grafanaAdmin += " AND sa.user_id = ?" @@ -299,11 +299,11 @@ func (s *AccessControlStore) GetUsersBasicRoles(ctx context.Context, userFilter IsAdmin bool `xorm:"is_admin"` } dbRoles := make([]UserOrgRole, 0) - if err := s.sql.ReadReplica().WithDbSession(ctx, func(sess *db.Session) error { + if err := s.sql.WithDbSession(ctx, func(sess *db.Session) error { // Find roles q := ` SELECT u.id, ou.role, u.is_admin - FROM ` + s.sql.ReadReplica().GetDialect().Quote("user") + ` AS u + FROM ` + s.sql.GetDialect().Quote("user") + ` AS u LEFT JOIN org_user AS ou ON u.id = ou.user_id WHERE (u.is_admin OR ou.org_id = ?) ` @@ -336,7 +336,7 @@ func (s *AccessControlStore) DeleteUserPermissions(ctx context.Context, orgID, u ctx, span := tracer.Start(ctx, "accesscontrol.database.DeleteUserPermissions") defer span.End() - err := s.sql.DB().WithDbSession(ctx, func(sess *db.Session) error { + err := s.sql.WithDbSession(ctx, func(sess *db.Session) error { roleDeleteQuery := "DELETE FROM user_role WHERE user_id = ?" roleDeleteParams := []any{roleDeleteQuery, userID} if orgID != accesscontrol.GlobalOrgID { @@ -404,7 +404,7 @@ func (s *AccessControlStore) DeleteTeamPermissions(ctx context.Context, orgID, t ctx, span := tracer.Start(ctx, "accesscontrol.database.DeleteTeamPermissions") defer span.End() - err := s.sql.DB().WithDbSession(ctx, func(sess *db.Session) error { + err := s.sql.WithDbSession(ctx, func(sess *db.Session) error { roleDeleteQuery := "DELETE FROM team_role WHERE team_id = ? AND org_id = ?" roleDeleteParams := []any{roleDeleteQuery, teamID, orgID} diff --git a/pkg/services/accesscontrol/database/database_test.go b/pkg/services/accesscontrol/database/database_test.go index e1d4f4c694b..7eaa72db708 100644 --- a/pkg/services/accesscontrol/database/database_test.go +++ b/pkg/services/accesscontrol/database/database_test.go @@ -470,8 +470,8 @@ func createUsersAndTeams(t *testing.T, store db.DB, svcs helperServices, orgID i return res } -func setupTestEnv(t testing.TB) (*database.AccessControlStore, rs.Store, user.Service, team.Service, org.Service, *sqlstore.ReplStore) { - sql, cfg := db.InitTestReplDBWithCfg(t) +func setupTestEnv(t testing.TB) (*database.AccessControlStore, rs.Store, user.Service, team.Service, org.Service, *sqlstore.SQLStore) { + sql, cfg := db.InitTestDBWithCfg(t) cfg.AutoAssignOrg = true cfg.AutoAssignOrgRole = "Viewer" cfg.AutoAssignOrgId = 1 diff --git a/pkg/services/accesscontrol/database/externalservices.go b/pkg/services/accesscontrol/database/externalservices.go index 3c658a85e8f..2ced82d908b 100644 --- a/pkg/services/accesscontrol/database/externalservices.go +++ b/pkg/services/accesscontrol/database/externalservices.go @@ -21,7 +21,7 @@ func (s *AccessControlStore) DeleteExternalServiceRole(ctx context.Context, exte defer span.End() uid := accesscontrol.PrefixedRoleUID(extServiceRoleName(externalServiceID)) - return s.sql.DB().WithDbSession(ctx, func(sess *db.Session) error { + return s.sql.WithDbSession(ctx, func(sess *db.Session) error { stored, errGet := getRoleByUID(ctx, sess, uid) if errGet != nil { // Role not found, nothing to do @@ -61,7 +61,7 @@ func (s *AccessControlStore) SaveExternalServiceRole(ctx context.Context, cmd ac role := genExternalServiceRole(cmd) assignment := genExternalServiceAssignment(cmd) - return s.sql.DB().WithDbSession(ctx, func(sess *db.Session) error { + return s.sql.WithDbSession(ctx, func(sess *db.Session) error { // Create or update the role existingRole, errSaveRole := s.saveRole(ctx, sess, &role) if errSaveRole != nil { diff --git a/pkg/services/accesscontrol/database/externalservices_test.go b/pkg/services/accesscontrol/database/externalservices_test.go index 7bde04735a5..e346f3be1d8 100644 --- a/pkg/services/accesscontrol/database/externalservices_test.go +++ b/pkg/services/accesscontrol/database/externalservices_test.go @@ -7,10 +7,9 @@ import ( "errors" "testing" - "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/stretchr/testify/require" ) func TestAccessControlStore_SaveExternalServiceRole(t *testing.T) { @@ -115,7 +114,7 @@ func TestAccessControlStore_SaveExternalServiceRole(t *testing.T) { t.Run(tt.name, func(t *testing.T) { ctx := context.Background() s := &AccessControlStore{ - sql: db.InitTestReplDB(t), + sql: db.InitTestDB(t), } for i := range tt.runs { @@ -126,7 +125,7 @@ func TestAccessControlStore_SaveExternalServiceRole(t *testing.T) { } require.NoError(t, err) - errDBSession := s.sql.DB().WithDbSession(ctx, func(sess *db.Session) error { + errDBSession := s.sql.WithDbSession(ctx, func(sess *db.Session) error { storedRole, err := getRoleByUID(ctx, sess, accesscontrol.PrefixedRoleUID(extServiceRoleName(tt.runs[i].cmd.ExternalServiceID))) require.NoError(t, err) require.NotNil(t, storedRole) @@ -188,13 +187,13 @@ func TestAccessControlStore_DeleteExternalServiceRole(t *testing.T) { t.Run(tt.name, func(t *testing.T) { ctx := context.Background() s := &AccessControlStore{ - sql: db.InitTestReplDB(t), + sql: db.InitTestDB(t), } if tt.init != nil { tt.init(t, ctx, s) } roleID := int64(-1) - err := s.sql.DB().WithDbSession(ctx, func(sess *db.Session) error { + err := s.sql.WithDbSession(ctx, func(sess *db.Session) error { role, err := getRoleByUID(ctx, sess, accesscontrol.PrefixedRoleUID(extServiceRoleName(tt.id))) if err != nil && !errors.Is(err, accesscontrol.ErrRoleNotFound) { return err @@ -218,7 +217,7 @@ func TestAccessControlStore_DeleteExternalServiceRole(t *testing.T) { } // Assignments should be deleted - _ = s.sql.DB().WithDbSession(ctx, func(sess *db.Session) error { + _ = s.sql.WithDbSession(ctx, func(sess *db.Session) error { var assignment accesscontrol.UserRole count, err := sess.Where("role_id = ?", roleID).Count(&assignment) require.NoError(t, err) @@ -227,7 +226,7 @@ func TestAccessControlStore_DeleteExternalServiceRole(t *testing.T) { }) // Permissions should be deleted - _ = s.sql.DB().WithDbSession(ctx, func(sess *db.Session) error { + _ = s.sql.WithDbSession(ctx, func(sess *db.Session) error { var permission accesscontrol.Permission count, err := sess.Where("role_id = ?", roleID).Count(&permission) require.NoError(t, err) @@ -236,7 +235,7 @@ func TestAccessControlStore_DeleteExternalServiceRole(t *testing.T) { }) // Role should be deleted - _ = s.sql.DB().WithDbSession(ctx, func(sess *db.Session) error { + _ = s.sql.WithDbSession(ctx, func(sess *db.Session) error { storedRole, err := getRoleByUID(ctx, sess, accesscontrol.PrefixedRoleUID(extServiceRoleName(tt.id))) require.ErrorIs(t, err, accesscontrol.ErrRoleNotFound) require.Nil(t, storedRole) diff --git a/pkg/services/accesscontrol/middleware.go b/pkg/services/accesscontrol/middleware.go index 3b3b05b0d5a..9a5c3f89a47 100644 --- a/pkg/services/accesscontrol/middleware.go +++ b/pkg/services/accesscontrol/middleware.go @@ -98,8 +98,13 @@ func deny(c *contextmodel.ReqContext, evaluator Evaluator, err error) { if !c.IsApiRequest() { // TODO(emil): I'd like to show a message after this redirect, not sure how that can be done? - writeRedirectCookie(c) - c.Redirect(setting.AppSubUrl + "/") + if !c.UseSessionStorageRedirect { + writeRedirectCookie(c) + c.Redirect(setting.AppSubUrl + "/") + return + } + + c.Redirect(setting.AppSubUrl + "/" + getRedirectToQueryParam(c)) return } @@ -125,13 +130,25 @@ func unauthorized(c *contextmodel.ReqContext) { return } - writeRedirectCookie(c) + if !c.UseSessionStorageRedirect { + writeRedirectCookie(c) + } + if errors.Is(c.LookupTokenErr, authn.ErrTokenNeedsRotation) { - c.Redirect(setting.AppSubUrl + "/user/auth-tokens/rotate") + if !c.UseSessionStorageRedirect { + c.Redirect(setting.AppSubUrl + "/user/auth-tokens/rotate") + return + } + c.Redirect(setting.AppSubUrl + "/user/auth-tokens/rotate" + getRedirectToQueryParam(c)) return } - c.Redirect(setting.AppSubUrl + "/login") + if !c.UseSessionStorageRedirect { + c.Redirect(setting.AppSubUrl + "/login") + return + } + + c.Redirect(setting.AppSubUrl + "/login" + getRedirectToQueryParam(c)) } func tokenRevoked(c *contextmodel.ReqContext, err *usertoken.TokenRevokedError) { @@ -146,8 +163,13 @@ func tokenRevoked(c *contextmodel.ReqContext, err *usertoken.TokenRevokedError) return } - writeRedirectCookie(c) - c.Redirect(setting.AppSubUrl + "/login") + if !c.UseSessionStorageRedirect { + writeRedirectCookie(c) + c.Redirect(setting.AppSubUrl + "/login") + return + } + + c.Redirect(setting.AppSubUrl + "/login" + getRedirectToQueryParam(c)) } func writeRedirectCookie(c *contextmodel.ReqContext) { @@ -162,6 +184,21 @@ func writeRedirectCookie(c *contextmodel.ReqContext) { cookies.WriteCookie(c.Resp, "redirect_to", url.QueryEscape(redirectTo), 0, nil) } +func getRedirectToQueryParam(c *contextmodel.ReqContext) string { + redirectTo := c.Req.RequestURI + if setting.AppSubUrl != "" && strings.HasPrefix(redirectTo, setting.AppSubUrl) { + redirectTo = strings.TrimPrefix(redirectTo, setting.AppSubUrl) + } + + if redirectTo == "/" { + return "" + } + + // remove any forceLogin=true params + redirectTo = removeForceLoginParams(redirectTo) + return "?redirectTo=" + url.QueryEscape(redirectTo) +} + var forceLoginParamsRegexp = regexp.MustCompile(`&?forceLogin=true`) func removeForceLoginParams(str string) string { diff --git a/pkg/services/accesscontrol/migrator/migrator.go b/pkg/services/accesscontrol/migrator/migrator.go index 33317b4b5be..8ecc20ad553 100644 --- a/pkg/services/accesscontrol/migrator/migrator.go +++ b/pkg/services/accesscontrol/migrator/migrator.go @@ -19,14 +19,14 @@ const ( maxLen = 40 ) -func MigrateScopeSplit(db db.ReplDB, log log.Logger) error { +func MigrateScopeSplit(db db.DB, log log.Logger) error { t := time.Now() ctx := context.Background() cnt := 0 // Search for the permissions to update var permissions []ac.Permission - if errFind := db.DB().WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { + if errFind := db.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { return sess.SQL("SELECT * FROM permission WHERE NOT scope = '' AND identifier = ''").Find(&permissions) }); errFind != nil { log.Error("Could not search for permissions to update", "migration", "scopeSplit", "error", errFind) @@ -76,7 +76,7 @@ func MigrateScopeSplit(db db.ReplDB, log log.Logger) error { delQuery = delQuery[:len(delQuery)-1] + ")" // Batch update the permissions - if errBatchUpdate := db.DB().GetSqlxSession().WithTransaction(ctx, func(tx *session.SessionTx) error { + if errBatchUpdate := db.GetSqlxSession().WithTransaction(ctx, func(tx *session.SessionTx) error { if _, errDel := tx.Exec(ctx, delQuery, delArgs...); errDel != nil { log.Error("Error deleting permissions", "migration", "scopeSplit", "error", errDel) return errDel diff --git a/pkg/services/accesscontrol/migrator/migrator_bench_test.go b/pkg/services/accesscontrol/migrator/migrator_bench_test.go index 7246b0812e3..5257a2fb2dd 100644 --- a/pkg/services/accesscontrol/migrator/migrator_bench_test.go +++ b/pkg/services/accesscontrol/migrator/migrator_bench_test.go @@ -10,7 +10,7 @@ import ( ) func benchScopeSplitConcurrent(b *testing.B, count int) { - store := db.InitTestReplDB(b) + store := db.InitTestDB(b) // Populate permissions require.NoError(b, batchInsertPermissions(count, store), "could not insert permissions") logger := log.New("migrator.test") diff --git a/pkg/services/accesscontrol/migrator/migrator_test.go b/pkg/services/accesscontrol/migrator/migrator_test.go index 0c98aa24a26..bfa35f17efc 100644 --- a/pkg/services/accesscontrol/migrator/migrator_test.go +++ b/pkg/services/accesscontrol/migrator/migrator_test.go @@ -46,7 +46,7 @@ func batchInsertPermissions(cnt int, sqlStore db.DB) error { // TestIntegrationMigrateScopeSplit tests the scope split migration // also tests the scope split truncation logic func TestIntegrationMigrateScopeSplitTruncation(t *testing.T) { - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) logger := log.New("accesscontrol.migrator.test") batchSize = 20 diff --git a/pkg/services/accesscontrol/models.go b/pkg/services/accesscontrol/models.go index 4ab28eba0b3..83e7b390f73 100644 --- a/pkg/services/accesscontrol/models.go +++ b/pkg/services/accesscontrol/models.go @@ -434,10 +434,15 @@ const ( ActionAlertingSilencesCreate = "alert.silences:create" ActionAlertingSilencesWrite = "alert.silences:write" - // Alerting Notification policies actions + // Alerting Notification actions (legacy) ActionAlertingNotificationsRead = "alert.notifications:read" ActionAlertingNotificationsWrite = "alert.notifications:write" + // Alerting notifications template actions + ActionAlertingNotificationsTemplatesRead = "alert.notifications.templates:read" + ActionAlertingNotificationsTemplatesWrite = "alert.notifications.templates:write" + ActionAlertingNotificationsTemplatesDelete = "alert.notifications.templates:delete" + // Alerting notifications time interval actions ActionAlertingNotificationsTimeIntervalsRead = "alert.notifications.time-intervals:read" ActionAlertingNotificationsTimeIntervalsWrite = "alert.notifications.time-intervals:write" diff --git a/pkg/services/accesscontrol/resourcepermissions/service_test.go b/pkg/services/accesscontrol/resourcepermissions/service_test.go index c8c9dbb406c..94ead810dd7 100644 --- a/pkg/services/accesscontrol/resourcepermissions/service_test.go +++ b/pkg/services/accesscontrol/resourcepermissions/service_test.go @@ -493,7 +493,7 @@ func setupTestEnvironment(t *testing.T, ops Options) (*Service, user.Service, te cfg := setting.NewCfg() tracer := tracing.InitializeTracerForTest() - teamSvc, err := teamimpl.ProvideService(db.FakeReplDBFromDB(sql), cfg, tracer) + teamSvc, err := teamimpl.ProvideService(sql, cfg, tracer) require.NoError(t, err) orgSvc, err := orgimpl.ProvideService(sql, cfg, quotatest.New(false, nil)) diff --git a/pkg/services/accesscontrol/resourcepermissions/store_bench_test.go b/pkg/services/accesscontrol/resourcepermissions/store_bench_test.go index 60addeeee24..51a63a5deba 100644 --- a/pkg/services/accesscontrol/resourcepermissions/store_bench_test.go +++ b/pkg/services/accesscontrol/resourcepermissions/store_bench_test.go @@ -140,7 +140,7 @@ func GenerateDatasourcePermissions(b *testing.B, db db.DB, cfg *setting.Cfg, ac } func generateTeamsAndUsers(b *testing.B, store db.DB, cfg *setting.Cfg, users int) ([]int64, []int64) { - teamSvc, err := teamimpl.ProvideService(db.FakeReplDBFromDB(store), cfg, tracing.InitializeTracerForTest()) + teamSvc, err := teamimpl.ProvideService(store, cfg, tracing.InitializeTracerForTest()) require.NoError(b, err) numberOfTeams := int(math.Ceil(float64(users) / UsersPerTeam)) globalUserId := 0 diff --git a/pkg/services/annotations/accesscontrol/accesscontrol_test.go b/pkg/services/annotations/accesscontrol/accesscontrol_test.go index 00106f0f74b..42f767bec35 100644 --- a/pkg/services/annotations/accesscontrol/accesscontrol_test.go +++ b/pkg/services/annotations/accesscontrol/accesscontrol_test.go @@ -27,7 +27,7 @@ func TestIntegrationAuthorize(t *testing.T) { t.Skip("skipping integration test") } - sql, cfg := db.InitTestReplDBWithCfg(t) + sql, cfg := db.InitTestDBWithCfg(t) dash1 := testutil.CreateDashboard(t, sql, cfg, featuremgmt.WithFeatures(), dashboards.SaveDashboardCommand{ UserID: 1, diff --git a/pkg/services/annotations/annotationsimpl/annotations_test.go b/pkg/services/annotations/annotationsimpl/annotations_test.go index eb87f894916..7a1223bfb64 100644 --- a/pkg/services/annotations/annotationsimpl/annotations_test.go +++ b/pkg/services/annotations/annotationsimpl/annotations_test.go @@ -27,7 +27,6 @@ import ( "github.com/grafana/grafana/pkg/services/guardian" alertingStore "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/services/quota/quotatest" - "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" @@ -43,7 +42,7 @@ func TestIntegrationAnnotationListingWithRBAC(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - sql := db.InitTestReplDB(t) + sql := db.InitTestDB(t) cfg := setting.NewCfg() cfg.AnnotationMaximumTagsLength = 60 @@ -210,8 +209,8 @@ func TestIntegrationAnnotationListingWithInheritedRBAC(t *testing.T) { allDashboards := make([]dashInfo, 0, folder.MaxNestedFolderDepth+1) annotationsTexts := make([]string, 0, folder.MaxNestedFolderDepth+1) - setupFolderStructure := func() *sqlstore.ReplStore { - sql, cfg := db.InitTestReplDBWithCfg(t) + setupFolderStructure := func() db.DB { + sql, cfg := db.InitTestDBWithCfg(t) // enable nested folders so that the folder table is populated for all the tests features := featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders) diff --git a/pkg/services/annotations/annotationsimpl/loki/historian_store_test.go b/pkg/services/annotations/annotationsimpl/loki/historian_store_test.go index 6b378e7b8d6..bcd5e536ae0 100644 --- a/pkg/services/annotations/annotationsimpl/loki/historian_store_test.go +++ b/pkg/services/annotations/annotationsimpl/loki/historian_store_test.go @@ -44,7 +44,7 @@ func TestIntegrationAlertStateHistoryStore(t *testing.T) { t.Skip("skipping integration test") } - sql, cfg := db.InitTestReplDBWithCfg(t) + sql, cfg := db.InitTestDBWithCfg(t) dashboard1 := testutil.CreateDashboard(t, sql, cfg, featuremgmt.WithFeatures(), dashboards.SaveDashboardCommand{ UserID: 1, @@ -604,7 +604,7 @@ func TestBuildTransition(t *testing.T) { }) } -func createTestLokiStore(t *testing.T, sql *sqlstore.ReplStore, client lokiQueryClient) *LokiHistorianStore { +func createTestLokiStore(t *testing.T, sql *sqlstore.SQLStore, client lokiQueryClient) *LokiHistorianStore { t.Helper() ruleStore := store.SetupStoreForTesting(t, sql) @@ -618,7 +618,7 @@ func createTestLokiStore(t *testing.T, sql *sqlstore.ReplStore, client lokiQuery // createAlertRule creates an alert rule in the database and returns it. // If a generator is not specified, uniqueness of primary key is not guaranteed. -func createAlertRule(t *testing.T, sql *sqlstore.ReplStore, title string, generator *ngmodels.AlertRuleGenerator) *ngmodels.AlertRule { +func createAlertRule(t *testing.T, sql *sqlstore.SQLStore, title string, generator *ngmodels.AlertRuleGenerator) *ngmodels.AlertRule { t.Helper() if generator == nil { @@ -645,7 +645,7 @@ func createAlertRule(t *testing.T, sql *sqlstore.ReplStore, title string, genera // createAlertRuleFromDashboard creates an alert rule with a linked dashboard and panel in the database and returns it. // If a generator is not specified, uniqueness of primary key is not guaranteed. -func createAlertRuleFromDashboard(t *testing.T, sql *sqlstore.ReplStore, title string, dashboard dashboards.Dashboard, generator *ngmodels.AlertRuleGenerator) *ngmodels.AlertRule { +func createAlertRuleFromDashboard(t *testing.T, sql *sqlstore.SQLStore, title string, dashboard dashboards.Dashboard, generator *ngmodels.AlertRuleGenerator) *ngmodels.AlertRule { t.Helper() panelID := new(int64) diff --git a/pkg/services/annotations/annotationsimpl/xorm_store_test.go b/pkg/services/annotations/annotationsimpl/xorm_store_test.go index 6a5f11089e4..ae06428f835 100644 --- a/pkg/services/annotations/annotationsimpl/xorm_store_test.go +++ b/pkg/services/annotations/annotationsimpl/xorm_store_test.go @@ -29,7 +29,7 @@ func TestIntegrationAnnotations(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - sql := db.InitTestReplDB(t) + sql := db.InitTestDB(t) cfg := setting.NewCfg() cfg.AnnotationMaximumTagsLength = 60 diff --git a/pkg/services/annotations/testutil/testutil.go b/pkg/services/annotations/testutil/testutil.go index 6113e8f2367..3d71f85e40f 100644 --- a/pkg/services/annotations/testutil/testutil.go +++ b/pkg/services/annotations/testutil/testutil.go @@ -5,8 +5,6 @@ import ( "testing" "time" - "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/dashboards" @@ -17,6 +15,7 @@ import ( "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" + "github.com/stretchr/testify/require" ) func SetupRBACRole(t *testing.T, db db.DB, user *user.SignedInUser) *accesscontrol.Role { @@ -79,14 +78,14 @@ func SetupRBACPermission(t *testing.T, db db.DB, role *accesscontrol.Role, user require.NoError(t, err) } -func CreateDashboard(t *testing.T, db db.ReplDB, cfg *setting.Cfg, features featuremgmt.FeatureToggles, cmd dashboards.SaveDashboardCommand) *dashboards.Dashboard { +func CreateDashboard(t *testing.T, db db.DB, cfg *setting.Cfg, features featuremgmt.FeatureToggles, cmd dashboards.SaveDashboardCommand) *dashboards.Dashboard { t.Helper() dashboardStore, err := dashboardstore.ProvideDashboardStore( db, cfg, features, - tagimpl.ProvideService(db.DB()), + tagimpl.ProvideService(db), quotatest.New(false, nil), ) require.NoError(t, err) diff --git a/pkg/services/apiserver/README.md b/pkg/services/apiserver/README.md index f85e39fe880..9bbfd8fe01b 100644 --- a/pkg/services/apiserver/README.md +++ b/pkg/services/apiserver/README.md @@ -67,6 +67,7 @@ kubernetesPlaylists = true [unified_storage.playlists.playlist.grafana.app] dualWriterMode = 2 +dualWriterPeriodicDataSyncJobEnabled = true ``` This will create a development kubeconfig and start a parallel ssl listener. It can be registered by diff --git a/pkg/services/apiserver/builder/helper.go b/pkg/services/apiserver/builder/helper.go index a088be374db..1b7358cdd71 100644 --- a/pkg/services/apiserver/builder/helper.go +++ b/pkg/services/apiserver/builder/helper.go @@ -52,6 +52,12 @@ var PathRewriters = []filters.PathRewriter{ return matches[1] + "/name" // connector requires a name }, }, + { + Pattern: regexp.MustCompile(`(/apis/.*/v0alpha1/namespaces/.*/queryconvert$)`), + ReplaceFunc: func(matches []string) string { + return matches[1] + "/name" // connector requires a name + }, + }, } func getDefaultBuildHandlerChainFunc(builders []APIGroupBuilder) BuildHandlerChainFunc { @@ -170,9 +176,13 @@ func InstallAPIs( // Get the option from custom.ini/command line // when missing this will default to mode zero (legacy only) var mode = grafanarest.DualWriterMode(0) + + var dualWriterPeriodicDataSyncJobEnabled bool + resourceConfig, resourceExists := storageOpts.UnifiedStorageConfig[key] if resourceExists { mode = resourceConfig.DualWriterMode + dualWriterPeriodicDataSyncJobEnabled = resourceConfig.DualWriterPeriodicDataSyncJobEnabled } // Force using storage only -- regardless of internal synchronization state @@ -198,7 +208,7 @@ func InstallAPIs( default: } - if storageOpts.DualWriterDataSyncJobEnabled[key] { + if dualWriterPeriodicDataSyncJobEnabled { grafanarest.StartPeriodicDataSyncer(ctx, currentMode, legacy, storage, key, reg, serverLock, requestInfo) } diff --git a/pkg/services/apiserver/config.go b/pkg/services/apiserver/config.go index 79ecd961fb1..adde28a322a 100644 --- a/pkg/services/apiserver/config.go +++ b/pkg/services/apiserver/config.go @@ -60,11 +60,6 @@ func applyGrafanaConfig(cfg *setting.Cfg, features featuremgmt.FeatureToggles, o unifiedStorageCfg := cfg.UnifiedStorage o.StorageOptions.UnifiedStorageConfig = unifiedStorageCfg - o.StorageOptions.DualWriterDataSyncJobEnabled = map[string]bool{ - // TODO: This will be enabled later, when we get a dedicated config section for unified_storage - // playlist.RESOURCE + "." + playlist.GROUP: true, - } - o.ExtraOptions.DevMode = features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerEnsureKubectlAccess) o.ExtraOptions.ExternalAddress = host o.ExtraOptions.APIURL = apiURL diff --git a/pkg/services/apiserver/options/storage.go b/pkg/services/apiserver/options/storage.go index a031b06007a..512619baa9e 100644 --- a/pkg/services/apiserver/options/storage.go +++ b/pkg/services/apiserver/options/storage.go @@ -32,9 +32,6 @@ type StorageOptions struct { // The desired storage type // {resource}.{group} = 1|2|3|4 UnifiedStorageConfig map[string]setting.UnifiedStorageConfig - - // TODO... this will be moved to UnifiedStorageConfig - DualWriterDataSyncJobEnabled map[string]bool } func NewStorageOptions() *StorageOptions { diff --git a/pkg/services/authn/authn.go b/pkg/services/authn/authn.go index 985858a7135..3c43822e9e8 100644 --- a/pkg/services/authn/authn.go +++ b/pkg/services/authn/authn.go @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/middleware/cookies" "github.com/grafana/grafana/pkg/models/usertoken" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/setting" ) @@ -230,24 +231,30 @@ func ClientWithPrefix(name string) string { type RedirectValidator func(url string) error // HandleLoginResponse is a utility function to perform common operations after a successful login and returns response.NormalResponse -func HandleLoginResponse(r *http.Request, w http.ResponseWriter, cfg *setting.Cfg, identity *Identity, validator RedirectValidator) *response.NormalResponse { +func HandleLoginResponse(r *http.Request, w http.ResponseWriter, cfg *setting.Cfg, identity *Identity, validator RedirectValidator, features featuremgmt.FeatureToggles) *response.NormalResponse { result := map[string]any{"message": "Logged in"} - result["redirectUrl"] = handleLogin(r, w, cfg, identity, validator) + result["redirectUrl"] = handleLogin(r, w, cfg, identity, validator, features) return response.JSON(http.StatusOK, result) } // HandleLoginRedirect is a utility function to perform common operations after a successful login and redirects -func HandleLoginRedirect(r *http.Request, w http.ResponseWriter, cfg *setting.Cfg, identity *Identity, validator RedirectValidator) { - redirectURL := handleLogin(r, w, cfg, identity, validator) +func HandleLoginRedirect(r *http.Request, w http.ResponseWriter, cfg *setting.Cfg, identity *Identity, validator RedirectValidator, features featuremgmt.FeatureToggles) { + redirectURL := handleLogin(r, w, cfg, identity, validator, features) http.Redirect(w, r, redirectURL, http.StatusFound) } // HandleLoginRedirectResponse is a utility function to perform common operations after a successful login and return a response.RedirectResponse -func HandleLoginRedirectResponse(r *http.Request, w http.ResponseWriter, cfg *setting.Cfg, identity *Identity, validator RedirectValidator) *response.RedirectResponse { - return response.Redirect(handleLogin(r, w, cfg, identity, validator)) +func HandleLoginRedirectResponse(r *http.Request, w http.ResponseWriter, cfg *setting.Cfg, identity *Identity, validator RedirectValidator, features featuremgmt.FeatureToggles) *response.RedirectResponse { + return response.Redirect(handleLogin(r, w, cfg, identity, validator, features)) } -func handleLogin(r *http.Request, w http.ResponseWriter, cfg *setting.Cfg, identity *Identity, validator RedirectValidator) string { +func handleLogin(r *http.Request, w http.ResponseWriter, cfg *setting.Cfg, identity *Identity, validator RedirectValidator, features featuremgmt.FeatureToggles) string { + WriteSessionCookie(w, cfg, identity.SessionToken) + + if features.IsEnabledGlobally(featuremgmt.FlagUseSessionStorageForRedirection) { + return cfg.AppSubURL + "/" + } + redirectURL := cfg.AppSubURL + "/" if redirectTo := getRedirectURL(r); len(redirectTo) > 0 { if validator(redirectTo) == nil { @@ -256,7 +263,6 @@ func handleLogin(r *http.Request, w http.ResponseWriter, cfg *setting.Cfg, ident cookies.DeleteCookie(w, "redirect_to", cookieOptions(cfg)) } - WriteSessionCookie(w, cfg, identity.SessionToken) return redirectURL } diff --git a/pkg/services/cloudmigration/api/api.go b/pkg/services/cloudmigration/api/api.go index 0748b8e78f0..b647433e114 100644 --- a/pkg/services/cloudmigration/api/api.go +++ b/pkg/services/cloudmigration/api/api.go @@ -335,6 +335,7 @@ func (cma *CloudMigrationAPI) GetSnapshot(c *contextmodel.ReqContext) response.R dtoResults := make([]MigrateDataResponseItemDTO, len(results)) for i := 0; i < len(results); i++ { dtoResults[i] = MigrateDataResponseItemDTO{ + Name: results[i].Name, Type: MigrateDataType(results[i].Type), RefID: results[i].RefID, Status: ItemStatus(results[i].Status), diff --git a/pkg/services/cloudmigration/api/dtos.go b/pkg/services/cloudmigration/api/dtos.go index 0d6dae53306..f301dea4de7 100644 --- a/pkg/services/cloudmigration/api/dtos.go +++ b/pkg/services/cloudmigration/api/dtos.go @@ -106,6 +106,7 @@ type MigrateDataResponseDTO struct { } type MigrateDataResponseItemDTO struct { + Name string `json:"name"` // required:true Type MigrateDataType `json:"type"` // required:true @@ -119,9 +120,10 @@ type MigrateDataResponseItemDTO struct { type MigrateDataType string const ( - DashboardDataType MigrateDataType = "DASHBOARD" - DatasourceDataType MigrateDataType = "DATASOURCE" - FolderDataType MigrateDataType = "FOLDER" + DashboardDataType MigrateDataType = "DASHBOARD" + DatasourceDataType MigrateDataType = "DATASOURCE" + FolderDataType MigrateDataType = "FOLDER" + LibraryElementDataType MigrateDataType = "LIBRARY_ELEMENT" ) // swagger:enum ItemStatus diff --git a/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_test.go b/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_test.go index 1f69d99a872..95e8a28fbad 100644 --- a/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_test.go +++ b/pkg/services/cloudmigration/cloudmigrationimpl/cloudmigration_test.go @@ -213,6 +213,7 @@ func Test_GetSnapshotStatusFromGMS(t *testing.T) { State: cloudmigration.SnapshotStateFinished, Results: []cloudmigration.CloudMigrationResource{ { + Name: "A name", Type: cloudmigration.DatasourceDataType, RefID: "A", Status: cloudmigration.ItemStatusError, @@ -387,18 +388,21 @@ func Test_NonCoreDataSourcesHaveWarning(t *testing.T) { State: cloudmigration.SnapshotStateFinished, Results: []cloudmigration.CloudMigrationResource{ { + Name: "1 name", Type: cloudmigration.DatasourceDataType, RefID: "1", // this will be core Status: cloudmigration.ItemStatusOK, SnapshotUID: snapshotUid, }, { + Name: "2 name", Type: cloudmigration.DatasourceDataType, RefID: "2", // this will be non-core Status: cloudmigration.ItemStatusOK, SnapshotUID: snapshotUid, }, { + Name: "3 name", Type: cloudmigration.DatasourceDataType, RefID: "3", // this will be non-core with an error Status: cloudmigration.ItemStatusError, @@ -406,6 +410,7 @@ func Test_NonCoreDataSourcesHaveWarning(t *testing.T) { SnapshotUID: snapshotUid, }, { + Name: "4 name", Type: cloudmigration.DatasourceDataType, RefID: "4", // this will be deleted Status: cloudmigration.ItemStatusOK, diff --git a/pkg/services/cloudmigration/cloudmigrationimpl/snapshot_mgmt.go b/pkg/services/cloudmigration/cloudmigrationimpl/snapshot_mgmt.go index 334a52eac96..b2841f9d9f0 100644 --- a/pkg/services/cloudmigration/cloudmigrationimpl/snapshot_mgmt.go +++ b/pkg/services/cloudmigration/cloudmigrationimpl/snapshot_mgmt.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "sort" "time" @@ -13,7 +14,6 @@ import ( "github.com/grafana/grafana-cloud-migration-snapshot/src/contracts" "github.com/grafana/grafana-cloud-migration-snapshot/src/infra/crypto" "github.com/grafana/grafana/pkg/services/cloudmigration" - "github.com/grafana/grafana/pkg/services/cloudmigration/slicesext" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -212,13 +212,14 @@ func (s *Service) buildSnapshot(ctx context.Context, signedInUser *user.SignedIn resourcesGroupedByType := make(map[cloudmigration.MigrateDataType][]snapshot.MigrateDataRequestItemDTO, 0) for i, item := range migrationData.Items { resourcesGroupedByType[item.Type] = append(resourcesGroupedByType[item.Type], snapshot.MigrateDataRequestItemDTO{ + Name: item.Name, Type: snapshot.MigrateDataType(item.Type), RefID: item.RefID, - Name: item.Name, Data: item.Data, }) localSnapshotResource[i] = cloudmigration.CloudMigrationResource{ + Name: item.Name, Type: item.Type, RefID: item.RefID, Status: cloudmigration.ItemStatusPending, @@ -230,7 +231,7 @@ func (s *Service) buildSnapshot(ctx context.Context, signedInUser *user.SignedIn cloudmigration.FolderDataType, cloudmigration.DashboardDataType, } { - for _, chunk := range slicesext.Chunks(int(maxItemsPerPartition), resourcesGroupedByType[resourceType]) { + for chunk := range slices.Chunk(resourcesGroupedByType[resourceType], int(maxItemsPerPartition)) { if err := snapshotWriter.Write(string(resourceType), chunk); err != nil { return fmt.Errorf("writing resources to snapshot writer: resourceType=%s %w", resourceType, err) } diff --git a/pkg/services/cloudmigration/gmsclient/dtos.go b/pkg/services/cloudmigration/gmsclient/dtos.go index 91c305ce7c3..1cb7a24d2c1 100644 --- a/pkg/services/cloudmigration/gmsclient/dtos.go +++ b/pkg/services/cloudmigration/gmsclient/dtos.go @@ -4,12 +4,6 @@ import "time" type MigrateDataType string -const ( - DashboardDataType MigrateDataType = "DASHBOARD" - DatasourceDataType MigrateDataType = "DATASOURCE" - FolderDataType MigrateDataType = "FOLDER" -) - type MigrateDataRequestDTO struct { Items []MigrateDataRequestItemDTO `json:"items"` } diff --git a/pkg/services/cloudmigration/model.go b/pkg/services/cloudmigration/model.go index 0ea1b8b7525..0eb8a8d04d6 100644 --- a/pkg/services/cloudmigration/model.go +++ b/pkg/services/cloudmigration/model.go @@ -68,6 +68,7 @@ type CloudMigrationResource struct { ID int64 `xorm:"pk autoincr 'id'"` UID string `xorm:"uid"` + Name string `xorm:"name" json:"name"` Type MigrateDataType `xorm:"resource_type" json:"type"` RefID string `xorm:"resource_uid" json:"refId"` Status ItemStatus `xorm:"status" json:"status"` @@ -79,9 +80,10 @@ type CloudMigrationResource struct { type MigrateDataType string const ( - DashboardDataType MigrateDataType = "DASHBOARD" - DatasourceDataType MigrateDataType = "DATASOURCE" - FolderDataType MigrateDataType = "FOLDER" + DashboardDataType MigrateDataType = "DASHBOARD" + DatasourceDataType MigrateDataType = "DATASOURCE" + FolderDataType MigrateDataType = "FOLDER" + LibraryElementDataType MigrateDataType = "LIBRARY_ELEMENT" ) type ItemStatus string diff --git a/pkg/services/cloudmigration/slicesext/slicesext.go b/pkg/services/cloudmigration/slicesext/slicesext.go deleted file mode 100644 index 66d9fb6b6cf..00000000000 --- a/pkg/services/cloudmigration/slicesext/slicesext.go +++ /dev/null @@ -1,33 +0,0 @@ -package slicesext - -import "math" - -// Partitions the input into slices where the length is <= chunkSize. -// -// Example: -// -// Chunks(2, []int{1, 2, 3, 4}) -// => [][]int{{1, 2}, {3, 4}} -func Chunks[T any](chunkSize int, xs []T) [][]T { - if chunkSize < 0 { - panic("chunk size must be greater than or equal to 0") - } - if chunkSize == 0 { - return [][]T{} - } - - out := make([][]T, 0, int(math.Ceil(float64(len(xs))/float64(chunkSize)))) - - for i := 0; i < len(xs); i += chunkSize { - var chunk []T - if i+chunkSize < len(xs) { - chunk = xs[i : i+chunkSize] - } else { - chunk = xs[i:] - } - - out = append(out, chunk) - } - - return out -} diff --git a/pkg/services/cloudmigration/slicesext/slicesext_test.go b/pkg/services/cloudmigration/slicesext/slicesext_test.go deleted file mode 100644 index 150f92b004e..00000000000 --- a/pkg/services/cloudmigration/slicesext/slicesext_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package slicesext - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestChunks(t *testing.T) { - t.Parallel() - - t.Run("chunkSize must be greater than 0", func(t *testing.T) { - t.Parallel() - - assert.PanicsWithValue(t, "chunk size must be greater than or equal to 0", func() { - Chunks(-1, []string{}) - }) - }) - - t.Run("basic", func(t *testing.T) { - t.Parallel() - - cases := []struct { - description string - chunkSize int - input []int - expected [][]int - }{ - { - description: "empty slice", - chunkSize: 2, - input: []int{}, - expected: [][]int{}, - }, - { - description: "nil slice", - chunkSize: 2, - input: nil, - expected: [][]int{}, - }, - { - description: "chunk size is 0", - chunkSize: 0, - input: []int{1, 2, 3}, - expected: [][]int{}, - }, - { - description: "chunk size is greater than slice length", - chunkSize: 3, - input: []int{1}, - expected: [][]int{{1}}, - }, - { - description: "chunk size is 1", - chunkSize: 1, - input: []int{1, 2, 3}, - expected: [][]int{{1}, {2}, {3}}, - }, - { - description: "chunk size is 2 and slice length is 3", - chunkSize: 2, - input: []int{1, 2, 3}, - expected: [][]int{{1, 2}, {3}}, - }, - { - description: "chunk size is 2 and slice length is 6", - chunkSize: 2, - input: []int{1, 2, 3, 4, 5, 6}, - expected: [][]int{{1, 2}, {3, 4}, {5, 6}}, - }, - } - - for _, tt := range cases { - t.Run(tt.description, func(t *testing.T) { - result := Chunks(tt.chunkSize, tt.input) - assert.Equal(t, tt.expected, result) - }) - } - }) -} diff --git a/pkg/services/contexthandler/contexthandler.go b/pkg/services/contexthandler/contexthandler.go index b82bd0c3ed8..6e18a1405c9 100644 --- a/pkg/services/contexthandler/contexthandler.go +++ b/pkg/services/contexthandler/contexthandler.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/authlib/claims" authnClients "github.com/grafana/grafana/pkg/services/authn/clients" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/apimachinery/identity" @@ -25,12 +26,13 @@ import ( "github.com/grafana/grafana/pkg/web" ) -func ProvideService(cfg *setting.Cfg, tracer tracing.Tracer, authenticator authn.Authenticator, +func ProvideService(cfg *setting.Cfg, tracer tracing.Tracer, authenticator authn.Authenticator, features featuremgmt.FeatureToggles, ) *ContextHandler { return &ContextHandler{ Cfg: cfg, tracer: tracer, authenticator: authenticator, + features: features, } } @@ -39,6 +41,7 @@ type ContextHandler struct { Cfg *setting.Cfg tracer tracing.Tracer authenticator authn.Authenticator + features featuremgmt.FeatureToggles } type reqContextKey = ctxkey.Key @@ -92,10 +95,11 @@ func (h *ContextHandler) Middleware(next http.Handler) http.Handler { SignedInUser: &user.SignedInUser{ Permissions: map[int64]map[string][]string{}, }, - IsSignedIn: false, - AllowAnonymous: false, - SkipDSCache: false, - Logger: log.New("context"), + IsSignedIn: false, + AllowAnonymous: false, + SkipDSCache: false, + Logger: log.New("context"), + UseSessionStorageRedirect: h.features.IsEnabledGlobally(featuremgmt.FlagUseSessionStorageForRedirection), } // inject ReqContext in the context diff --git a/pkg/services/contexthandler/contexthandler_test.go b/pkg/services/contexthandler/contexthandler_test.go index 1a0458a48fe..0810bb04442 100644 --- a/pkg/services/contexthandler/contexthandler_test.go +++ b/pkg/services/contexthandler/contexthandler_test.go @@ -16,6 +16,7 @@ import ( "github.com/grafana/grafana/pkg/services/authn/authntest" "github.com/grafana/grafana/pkg/services/contexthandler" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -28,6 +29,7 @@ func TestContextHandler(t *testing.T) { setting.NewCfg(), tracing.InitializeTracerForTest(), &authntest.FakeService{ExpectedErr: errors.New("some error")}, + featuremgmt.WithFeatures(), ) server := webtest.NewServer(t, routing.NewRouteRegister()) @@ -49,6 +51,7 @@ func TestContextHandler(t *testing.T) { setting.NewCfg(), tracing.InitializeTracerForTest(), &authntest.FakeService{ExpectedIdentity: id}, + featuremgmt.WithFeatures(), ) server := webtest.NewServer(t, routing.NewRouteRegister()) @@ -74,6 +77,7 @@ func TestContextHandler(t *testing.T) { setting.NewCfg(), tracing.InitializeTracerForTest(), &authntest.FakeService{ExpectedIdentity: identity}, + featuremgmt.WithFeatures(), ) server := webtest.NewServer(t, routing.NewRouteRegister()) @@ -95,6 +99,7 @@ func TestContextHandler(t *testing.T) { setting.NewCfg(), tracing.InitializeTracerForTest(), &authntest.FakeService{ExpectedIdentity: identity}, + featuremgmt.WithFeatures(), ) server := webtest.NewServer(t, routing.NewRouteRegister()) @@ -125,6 +130,7 @@ func TestContextHandler(t *testing.T) { cfg, tracing.InitializeTracerForTest(), &authntest.FakeService{ExpectedIdentity: &authn.Identity{}}, + featuremgmt.WithFeatures(), ) server := webtest.NewServer(t, routing.NewRouteRegister()) @@ -153,6 +159,7 @@ func TestContextHandler(t *testing.T) { cfg, tracing.InitializeTracerForTest(), &authntest.FakeService{ExpectedIdentity: &authn.Identity{ID: i, Type: typ}}, + featuremgmt.WithFeatures(), ) server := webtest.NewServer(t, routing.NewRouteRegister()) diff --git a/pkg/services/contexthandler/model/model.go b/pkg/services/contexthandler/model/model.go index e24375ef80e..32b524eca05 100644 --- a/pkg/services/contexthandler/model/model.go +++ b/pkg/services/contexthandler/model/model.go @@ -35,6 +35,11 @@ type ReqContext struct { PerfmonTimer prometheus.Summary LookupTokenErr error + + // FIXME: Remove this temporary flag after the rollout of FlagUseSessionStorageForRedirection feature flag + // Tracking issue for cleaning up this flag: https://github.com/grafana/identity-access-team/issues/908 + // UseSessionStorageRedirect is introduced to simplify the rollout of the new redirect logic + UseSessionStorageRedirect bool } // Handle handles and logs error by given status. diff --git a/pkg/services/correlations/database.go b/pkg/services/correlations/database.go index 47e54d5361f..0e8ff53999d 100644 --- a/pkg/services/correlations/database.go +++ b/pkg/services/correlations/database.go @@ -11,6 +11,8 @@ import ( "github.com/grafana/grafana/pkg/util" ) +const VALID_TYPE_FILTER = "(correlation.type = 'external' OR (correlation.type = 'query' AND dst.uid IS NOT NULL))" + // createCorrelation adds a correlation func (s CorrelationsService) createCorrelation(ctx context.Context, cmd CreateCorrelationCommand) (Correlation, error) { correlation := Correlation{ @@ -25,6 +27,12 @@ func (s CorrelationsService) createCorrelation(ctx context.Context, cmd CreateCo Type: cmd.Type, } + if correlation.Config.Type == CorrelationType("query") { + correlation.Type = CorrelationType("query") + } else if correlation.Config.Type != "" { + return correlation, ErrInvalidConfigType + } + err := s.SQLStore.WithTransactionalDbSession(ctx, func(session *db.Session) error { var err error @@ -184,7 +192,7 @@ func (s CorrelationsService) getCorrelation(ctx context.Context, cmd GetCorrelat } // Correlations created before the fix #72498 may have org_id = 0, but it's deprecated and will be removed in #72325 - found, err := session.Select("correlation.*").Join("", "data_source AS dss", "correlation.source_uid = dss.uid and (correlation.org_id = 0 or dss.org_id = correlation.org_id) and dss.org_id = ?", cmd.OrgId).Join("", "data_source AS dst", "correlation.target_uid = dst.uid and dst.org_id = ?", cmd.OrgId).Where("correlation.uid = ? AND correlation.source_uid = ?", correlation.UID, correlation.SourceUID).Get(&correlation) + found, err := session.Select("correlation.*").Join("", "data_source AS dss", "correlation.source_uid = dss.uid and (correlation.org_id = 0 or dss.org_id = correlation.org_id) and dss.org_id = ?", cmd.OrgId).Join("LEFT OUTER", "data_source AS dst", "correlation.target_uid = dst.uid and dst.org_id = ?", cmd.OrgId).Where("correlation.uid = ?", correlation.UID).And("correlation.source_uid = ?", correlation.SourceUID).And(VALID_TYPE_FILTER).Get(&correlation) if !found { return ErrCorrelationNotFound } @@ -235,7 +243,7 @@ func (s CorrelationsService) getCorrelationsBySourceUID(ctx context.Context, cmd return ErrSourceDataSourceDoesNotExists } // Correlations created before the fix #72498 may have org_id = 0, but it's deprecated and will be removed in #72325 - return session.Select("correlation.*").Join("", "data_source AS dss", "correlation.source_uid = dss.uid and (correlation.org_id = 0 or dss.org_id = correlation.org_id) and dss.org_id = ?", cmd.OrgId).Join("", "data_source AS dst", "correlation.target_uid = dst.uid and dst.org_id = ?", cmd.OrgId).Where("correlation.source_uid = ?", cmd.SourceUID).Find(&correlations) + return session.Select("correlation.*").Join("", "data_source AS dss", "correlation.source_uid = dss.uid and (correlation.org_id = 0 or dss.org_id = correlation.org_id) and dss.org_id = ?", cmd.OrgId).Join("LEFT OUTER", "data_source AS dst", "correlation.target_uid = dst.uid and dst.org_id = ?", cmd.OrgId).Where("correlation.source_uid = ?", cmd.SourceUID).And(VALID_TYPE_FILTER).Find(&correlations) }) if err != nil { @@ -256,12 +264,14 @@ func (s CorrelationsService) getCorrelations(ctx context.Context, cmd GetCorrela offset := cmd.Limit * (cmd.Page - 1) // Correlations created before the fix #72498 may have org_id = 0, but it's deprecated and will be removed in #72325 - q := session.Select("correlation.*").Join("", "data_source AS dss", "correlation.source_uid = dss.uid and (correlation.org_id = 0 or dss.org_id = correlation.org_id) and dss.org_id = ? ", cmd.OrgId).Join("", "data_source AS dst", "correlation.target_uid = dst.uid and dst.org_id = ?", cmd.OrgId) + q := session.Select("correlation.*").Join("", "data_source AS dss", "correlation.source_uid = dss.uid and (correlation.org_id = 0 or dss.org_id = correlation.org_id) and dss.org_id = ? ", cmd.OrgId).Join("LEFT OUTER", "data_source AS dst", "correlation.target_uid = dst.uid and dst.org_id = ?", cmd.OrgId) if len(cmd.SourceUIDs) > 0 { q.In("dss.uid", cmd.SourceUIDs) } + q.Where(VALID_TYPE_FILTER) + return q.Limit(int(cmd.Limit), int(offset)).Find(&result.Correlations) }) if err != nil { @@ -316,6 +326,7 @@ func (s CorrelationsService) createOrUpdateCorrelation(ctx context.Context, cmd Description: cmd.Description, Config: cmd.Config, Provisioned: false, + Type: cmd.Type, } found := false diff --git a/pkg/services/correlations/models.go b/pkg/services/correlations/models.go index 1b83b0b3042..4e941517a38 100644 --- a/pkg/services/correlations/models.go +++ b/pkg/services/correlations/models.go @@ -14,12 +14,13 @@ var ( ErrTargetDataSourceDoesNotExists = errors.New("target data source does not exist") ErrCorrelationNotFound = errors.New("correlation not found") ErrUpdateCorrelationEmptyParams = errors.New("not enough parameters to edit correlation") - ErrInvalidConfigType = errors.New("invalid correlation config type") + ErrInvalidType = errors.New("invalid correlation type") ErrInvalidTransformationType = errors.New("invalid transformation type") ErrTransformationNotNested = errors.New("transformations must be nested under config") ErrTransformationRegexReqExp = errors.New("regex transformations require expression") ErrCorrelationsQuotaFailed = errors.New("error getting correlations quota") ErrCorrelationsQuotaReached = errors.New("correlations quota reached") + ErrInvalidConfigType = errors.New("correlation contains non default value in config.type") ) const ( @@ -27,8 +28,15 @@ const ( QuotaTarget quota.Target = "correlations" ) +// the type of correlation, either query for containing query information, or external for containing an external URL +// +enum type CorrelationType string +const ( + query CorrelationType = "query" + external CorrelationType = "external" +) + type Transformation struct { //Enum: regex,logfmt Type string `json:"type"` @@ -37,13 +45,9 @@ type Transformation struct { MapValue string `json:"mapValue,omitempty"` } -const ( - TypeQuery CorrelationType = "query" -) - func (t CorrelationType) Validate() error { - if t != TypeQuery { - return fmt.Errorf("%s: \"%s\"", ErrInvalidConfigType, t) + if t != query && t != external { + return fmt.Errorf("%s: \"%s\"", ErrInvalidType, t) } return nil } @@ -161,7 +165,7 @@ type CreateCorrelationCommand struct { Config CorrelationConfig `json:"config" binding:"Required"` // True if correlation was created with provisioning. This makes it read-only. Provisioned bool `json:"provisioned"` - // correlation type, currently only valid value is "query" + // correlation type Type CorrelationType `json:"type" binding:"Required"` } @@ -169,8 +173,8 @@ func (c CreateCorrelationCommand) Validate() error { if err := c.Type.Validate(); err != nil { return err } - if c.TargetUID == nil && c.Type == TypeQuery { - return fmt.Errorf("correlations of type \"%s\" must have a targetUID", TypeQuery) + if c.TargetUID == nil && c.Type == query { + return fmt.Errorf("correlations of type \"%s\" must have a targetUID", query) } if err := c.Config.Transformations.Validate(); err != nil { diff --git a/pkg/services/correlations/models_test.go b/pkg/services/correlations/models_test.go index 4583339812c..16dbc8e279e 100644 --- a/pkg/services/correlations/models_test.go +++ b/pkg/services/correlations/models_test.go @@ -20,7 +20,7 @@ func TestCorrelationModels(t *testing.T) { OrgId: 1, TargetUID: &targetUid, Config: *config, - Type: TypeQuery, + Type: query, } require.NoError(t, cmd.Validate()) @@ -30,7 +30,7 @@ func TestCorrelationModels(t *testing.T) { config := &CorrelationConfig{ Field: "field", Target: map[string]any{}, - Type: TypeQuery, + Type: query, } cmd := &CreateCorrelationCommand{ SourceUID: "some-uid", diff --git a/pkg/services/dashboards/database/database.go b/pkg/services/dashboards/database/database.go index b68ca7357fc..1eeb5fa5569 100644 --- a/pkg/services/dashboards/database/database.go +++ b/pkg/services/dashboards/database/database.go @@ -31,7 +31,7 @@ import ( var tracer = otel.Tracer("github.com/grafana/grafana/pkg/services/dashboard/database") type dashboardStore struct { - store db.ReplDB + store db.DB cfg *setting.Cfg log log.Logger features featuremgmt.FeatureToggles @@ -48,7 +48,7 @@ type dashboardTag struct { // DashboardStore implements the Store interface var _ dashboards.Store = (*dashboardStore)(nil) -func ProvideDashboardStore(sqlStore db.ReplDB, cfg *setting.Cfg, features featuremgmt.FeatureToggles, tagService tag.Service, quotaService quota.Service) (dashboards.Store, error) { +func ProvideDashboardStore(sqlStore db.DB, cfg *setting.Cfg, features featuremgmt.FeatureToggles, tagService tag.Service, quotaService quota.Service) (dashboards.Store, error) { s := &dashboardStore{store: sqlStore, cfg: cfg, log: log.New("dashboard-store"), features: features, tagService: tagService} defaultLimits, err := readQuotaConfig(cfg) @@ -76,7 +76,7 @@ func (d *dashboardStore) ValidateDashboardBeforeSave(ctx context.Context, dashbo defer span.End() isParentFolderChanged := false - err := d.store.DB().WithTransactionalDbSession(ctx, func(sess *db.Session) error { + err := d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error { var err error isParentFolderChanged, err = getExistingDashboardByIDOrUIDForUpdate(sess, dashboard, overwrite) if err != nil { @@ -103,7 +103,7 @@ func (d *dashboardStore) GetProvisionedDataByDashboardID(ctx context.Context, da defer span.End() var data dashboards.DashboardProvisioning - err := d.store.DB().WithDbSession(ctx, func(sess *db.Session) error { + err := d.store.WithDbSession(ctx, func(sess *db.Session) error { _, err := sess.Where("dashboard_id = ?", dashboardID).Get(&data) return err }) @@ -119,7 +119,7 @@ func (d *dashboardStore) GetProvisionedDataByDashboardUID(ctx context.Context, o defer span.End() var provisionedDashboard dashboards.DashboardProvisioning - err := d.store.DB().WithDbSession(ctx, func(sess *db.Session) error { + err := d.store.WithDbSession(ctx, func(sess *db.Session) error { var dashboard dashboards.Dashboard exists, err := sess.Where("org_id = ? AND uid = ?", orgID, dashboardUID).Get(&dashboard) if err != nil { @@ -145,7 +145,7 @@ func (d *dashboardStore) GetProvisionedDashboardData(ctx context.Context, name s defer span.End() var result []*dashboards.DashboardProvisioning - err := d.store.DB().WithDbSession(ctx, func(sess *db.Session) error { + err := d.store.WithDbSession(ctx, func(sess *db.Session) error { return sess.Where("name = ?", name).Find(&result) }) return result, err @@ -157,7 +157,7 @@ func (d *dashboardStore) SaveProvisionedDashboard(ctx context.Context, cmd dashb var result *dashboards.Dashboard var err error - err = d.store.DB().WithTransactionalDbSession(ctx, func(sess *db.Session) error { + err = d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error { result, err = saveDashboard(sess, &cmd, d.emitEntityEvent()) if err != nil { return err @@ -178,7 +178,7 @@ func (d *dashboardStore) SaveDashboard(ctx context.Context, cmd dashboards.SaveD var result *dashboards.Dashboard var err error - err = d.store.DB().WithTransactionalDbSession(ctx, func(sess *db.Session) error { + err = d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error { result, err = saveDashboard(sess, &cmd, d.emitEntityEvent()) if err != nil { return err @@ -197,7 +197,7 @@ func (d *dashboardStore) UnprovisionDashboard(ctx context.Context, id int64) err ctx, span := tracer.Start(ctx, "dashboards.database.UnprovisionDashboard") defer span.End() - return d.store.DB().WithTransactionalDbSession(ctx, func(sess *db.Session) error { + return d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error { _, err := sess.Where("dashboard_id = ?", id).Delete(&dashboards.DashboardProvisioning{}) return err }) @@ -207,7 +207,7 @@ func (d *dashboardStore) DeleteOrphanedProvisionedDashboards(ctx context.Context ctx, span := tracer.Start(ctx, "dashboards.database.DeleteOrphanedProvisionedDashboards") defer span.End() - return d.store.DB().WithDbSession(ctx, func(sess *db.Session) error { + return d.store.WithDbSession(ctx, func(sess *db.Session) error { var result []*dashboards.DashboardProvisioning convertedReaderNames := make([]any, len(cmd.ReaderNames)) @@ -241,8 +241,8 @@ func (d *dashboardStore) Count(ctx context.Context, scopeParams *quota.ScopePara } r := result{} - if err := d.store.ReadReplica().WithDbSession(ctx, func(sess *sqlstore.DBSession) error { - rawSQL := fmt.Sprintf("SELECT COUNT(*) AS count FROM dashboard WHERE is_folder=%s", d.store.ReadReplica().GetDialect().BooleanStr(false)) + if err := d.store.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + rawSQL := fmt.Sprintf("SELECT COUNT(*) AS count FROM dashboard WHERE is_folder=%s", d.store.GetDialect().BooleanStr(false)) if _, err := sess.SQL(rawSQL).Get(&r); err != nil { return err } @@ -258,8 +258,8 @@ func (d *dashboardStore) Count(ctx context.Context, scopeParams *quota.ScopePara } if scopeParams != nil && scopeParams.OrgID != 0 { - if err := d.store.ReadReplica().WithDbSession(ctx, func(sess *sqlstore.DBSession) error { - rawSQL := fmt.Sprintf("SELECT COUNT(*) AS count FROM dashboard WHERE org_id=? AND is_folder=%s", d.store.ReadReplica().GetDialect().BooleanStr(false)) + if err := d.store.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + rawSQL := fmt.Sprintf("SELECT COUNT(*) AS count FROM dashboard WHERE org_id=? AND is_folder=%s", d.store.GetDialect().BooleanStr(false)) if _, err := sess.SQL(rawSQL, scopeParams.OrgID).Get(&r); err != nil { return err } @@ -534,8 +534,8 @@ func (d *dashboardStore) GetDashboardsByPluginID(ctx context.Context, query *das defer span.End() var dashboards = make([]*dashboards.Dashboard, 0) - err := d.store.DB().WithDbSession(ctx, func(dbSession *db.Session) error { - whereExpr := "org_id=? AND plugin_id=? AND is_folder=" + d.store.DB().GetDialect().BooleanStr(false) + err := d.store.WithDbSession(ctx, func(dbSession *db.Session) error { + whereExpr := "org_id=? AND plugin_id=? AND is_folder=" + d.store.GetDialect().BooleanStr(false) err := dbSession.Where(whereExpr, query.OrgID, query.PluginID).Find(&dashboards) return err @@ -545,7 +545,6 @@ func (d *dashboardStore) GetDashboardsByPluginID(ctx context.Context, query *das } return dashboards, nil } - func (d *dashboardStore) GetSoftDeletedDashboard(ctx context.Context, orgID int64, uid string) (*dashboards.Dashboard, error) { ctx, span := tracer.Start(ctx, "dashboards.database.GetSoftDeletedDashboard") defer span.End() @@ -555,7 +554,7 @@ func (d *dashboardStore) GetSoftDeletedDashboard(ctx context.Context, orgID int6 } var queryResult *dashboards.Dashboard - err := d.store.DB().WithDbSession(ctx, func(sess *db.Session) error { + err := d.store.WithDbSession(ctx, func(sess *db.Session) error { dashboard := dashboards.Dashboard{OrgID: orgID, UID: uid} has, err := sess.Where("deleted IS NOT NULL").Get(&dashboard) @@ -576,7 +575,7 @@ func (d *dashboardStore) RestoreDashboard(ctx context.Context, orgID int64, dash ctx, span := tracer.Start(ctx, "dashboards.database.RestoreDashboard") defer span.End() - return d.store.DB().WithTransactionalDbSession(ctx, func(sess *db.Session) error { + return d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error { if folder == nil || folder.UID == "" { _, err := sess.Exec("UPDATE dashboard SET deleted=NULL, folder_id=0, folder_uid=NULL WHERE org_id=? AND uid=?", orgID, dashboardUID) return err @@ -591,7 +590,7 @@ func (d *dashboardStore) SoftDeleteDashboard(ctx context.Context, orgID int64, d ctx, span := tracer.Start(ctx, "dashboards.database.SoftDeleteDashboard") defer span.End() - return d.store.DB().WithTransactionalDbSession(ctx, func(sess *db.Session) error { + return d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error { _, err := sess.Exec("UPDATE dashboard SET deleted=? WHERE org_id=? AND uid=?", time.Now(), orgID, dashboardUID) return err }) @@ -605,7 +604,7 @@ func (d *dashboardStore) SoftDeleteDashboardsInFolders(ctx context.Context, orgI return nil } - return d.store.DB().WithTransactionalDbSession(ctx, func(sess *db.Session) error { + return d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error { s := strings.Builder{} s.WriteString("UPDATE dashboard SET deleted=? WHERE ") s.WriteString(fmt.Sprintf("folder_uid IN (%s)", strings.Repeat("?,", len(folderUids)-1)+"?")) @@ -617,7 +616,7 @@ func (d *dashboardStore) SoftDeleteDashboardsInFolders(ctx context.Context, orgI for _, folderUID := range folderUids { args = append(args, folderUID) } - args = append(args, orgID, d.store.DB().GetDialect().BooleanStr(false)) + args = append(args, orgID, d.store.GetDialect().BooleanStr(false)) _, err := sess.Exec(args...) return err @@ -628,7 +627,7 @@ func (d *dashboardStore) DeleteDashboard(ctx context.Context, cmd *dashboards.De ctx, span := tracer.Start(ctx, "dashboards.database.DeleteDashboard") defer span.End() - return d.store.DB().WithTransactionalDbSession(ctx, func(sess *db.Session) error { + return d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error { return d.deleteDashboard(cmd, sess, d.emitEntityEvent()) }) } @@ -664,14 +663,14 @@ func (d *dashboardStore) deleteDashboard(cmd *dashboards.DeleteDashboardCommand, if dashboard.IsFolder { if !d.features.IsEnabledGlobally(featuremgmt.FlagDashboardRestore) { - sqlStatements = append(sqlStatements, statement{SQL: "DELETE FROM dashboard WHERE org_id = ? AND folder_uid = ? AND is_folder = ? AND deleted IS NULL", args: []any{dashboard.OrgID, dashboard.UID, d.store.DB().GetDialect().BooleanStr(false)}}) + sqlStatements = append(sqlStatements, statement{SQL: "DELETE FROM dashboard WHERE org_id = ? AND folder_uid = ? AND is_folder = ? AND deleted IS NULL", args: []any{dashboard.OrgID, dashboard.UID, d.store.GetDialect().BooleanStr(false)}}) if err := d.deleteChildrenDashboardAssociations(sess, &dashboard); err != nil { return err } } else { // soft delete all dashboards in the folder - sqlStatements = append(sqlStatements, statement{SQL: "UPDATE dashboard SET deleted = ? WHERE org_id = ? AND folder_uid = ? AND is_folder = ? ", args: []any{time.Now(), dashboard.OrgID, dashboard.UID, d.store.DB().GetDialect().BooleanStr(false)}}) + sqlStatements = append(sqlStatements, statement{SQL: "UPDATE dashboard SET deleted = ? WHERE org_id = ? AND folder_uid = ? AND is_folder = ? ", args: []any{time.Now(), dashboard.OrgID, dashboard.UID, d.store.GetDialect().BooleanStr(false)}}) } // remove all access control permission with folder scope @@ -788,7 +787,7 @@ func (d *dashboardStore) GetDashboard(ctx context.Context, query *dashboards.Get defer span.End() var queryResult *dashboards.Dashboard - err := d.store.ReadReplica().WithDbSession(ctx, func(sess *db.Session) error { + err := d.store.WithDbSession(ctx, func(sess *db.Session) error { metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc() // nolint:staticcheck if query.ID == 0 && len(query.UID) == 0 && (query.Title == nil || (query.FolderID == nil && query.FolderUID == nil)) { @@ -833,7 +832,7 @@ func (d *dashboardStore) GetDashboardUIDByID(ctx context.Context, query *dashboa defer span.End() us := &dashboards.DashboardRef{} - err := d.store.DB().WithDbSession(ctx, func(sess *db.Session) error { + err := d.store.WithDbSession(ctx, func(sess *db.Session) error { var rawSQL = `SELECT uid, slug from dashboard WHERE Id=?` exists, err := sess.SQL(rawSQL, query.ID).Get(us) if err != nil { @@ -854,7 +853,7 @@ func (d *dashboardStore) GetDashboards(ctx context.Context, query *dashboards.Ge defer span.End() var dashboards = make([]*dashboards.Dashboard, 0) - err := d.store.ReadReplica().WithDbSession(ctx, func(sess *db.Session) error { + err := d.store.WithDbSession(ctx, func(sess *db.Session) error { if len(query.DashboardIDs) == 0 && len(query.DashboardUIDs) == 0 { return star.ErrCommandValidationFailed } @@ -884,7 +883,7 @@ func (d *dashboardStore) FindDashboards(ctx context.Context, query *dashboards.F ctx, span := tracer.Start(ctx, "dashboards.database.FindDashboards") defer span.End() - recursiveQueriesAreSupported, err := d.store.ReadReplica().RecursiveQueriesAreSupported() + recursiveQueriesAreSupported, err := d.store.RecursiveQueriesAreSupported() if err != nil { return nil, err } @@ -917,11 +916,11 @@ func (d *dashboardStore) FindDashboards(ctx context.Context, query *dashboards.F } if len(query.Title) > 0 { - filters = append(filters, searchstore.TitleFilter{Dialect: d.store.ReadReplica().GetDialect(), Title: query.Title}) + filters = append(filters, searchstore.TitleFilter{Dialect: d.store.GetDialect(), Title: query.Title}) } if len(query.Type) > 0 { - filters = append(filters, searchstore.TypeFilter{Dialect: d.store.ReadReplica().GetDialect(), Type: query.Type}) + filters = append(filters, searchstore.TypeFilter{Dialect: d.store.GetDialect(), Type: query.Type}) } metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc() // nolint:staticcheck @@ -931,7 +930,7 @@ func (d *dashboardStore) FindDashboards(ctx context.Context, query *dashboards.F if len(query.FolderUIDs) > 0 { filters = append(filters, searchstore.FolderUIDFilter{ - Dialect: d.store.ReadReplica().GetDialect(), + Dialect: d.store.GetDialect(), OrgID: orgID, UIDs: query.FolderUIDs, NestedFoldersEnabled: d.features.IsEnabled(ctx, featuremgmt.FlagNestedFolders), @@ -948,7 +947,7 @@ func (d *dashboardStore) FindDashboards(ctx context.Context, query *dashboards.F filters = append(filters, searchstore.DeletedFilter{Deleted: query.IsDeleted}) var res []dashboards.DashboardSearchProjection - sb := &searchstore.Builder{Dialect: d.store.ReadReplica().GetDialect(), Filters: filters, Features: d.features} + sb := &searchstore.Builder{Dialect: d.store.GetDialect(), Filters: filters, Features: d.features} limit := query.Limit if limit < 1 { @@ -962,7 +961,7 @@ func (d *dashboardStore) FindDashboards(ctx context.Context, query *dashboards.F sql, params := sb.ToSQL(limit, page) - err = d.store.ReadReplica().WithDbSession(ctx, func(sess *db.Session) error { + err = d.store.WithDbSession(ctx, func(sess *db.Session) error { return sess.SQL(sql, params...).Find(&res) }) @@ -978,7 +977,7 @@ func (d *dashboardStore) GetDashboardTags(ctx context.Context, query *dashboards defer span.End() queryResult := make([]*dashboards.DashboardTagCloudItem, 0) - err := d.store.DB().WithDbSession(ctx, func(dbSession *db.Session) error { + err := d.store.WithDbSession(ctx, func(dbSession *db.Session) error { sql := `SELECT COUNT(*) as count, term @@ -1009,7 +1008,7 @@ func (d *dashboardStore) CountDashboardsInFolders( return 0, nil } var count int64 - err := d.store.ReadReplica().WithDbSession(ctx, func(sess *db.Session) error { + err := d.store.WithDbSession(ctx, func(sess *db.Session) error { metrics.MFolderIDsServiceCount.WithLabelValues(metrics.Dashboard).Inc() s := strings.Builder{} args := make([]any, 0, 3) @@ -1023,7 +1022,7 @@ func (d *dashboardStore) CountDashboardsInFolders( } } s.WriteString(" AND org_id = ? AND is_folder = ? AND deleted IS NULL") - args = append(args, req.OrgID, d.store.ReadReplica().GetDialect().BooleanStr(false)) + args = append(args, req.OrgID, d.store.GetDialect().BooleanStr(false)) sql := s.String() _, err := sess.SQL(sql, args...).Get(&count) return err @@ -1036,7 +1035,7 @@ func (d *dashboardStore) DeleteDashboardsInFolders( ctx, span := tracer.Start(ctx, "dashboards.database.DeleteDashboardsInFolders") defer span.End() - return d.store.DB().WithTransactionalDbSession(ctx, func(sess *db.Session) error { + return d.store.WithTransactionalDbSession(ctx, func(sess *db.Session) error { // TODO delete all dashboards in the folder in a bulk query for _, folderUID := range req.FolderUIDs { dashboard := dashboards.Dashboard{OrgID: req.OrgID} @@ -1066,7 +1065,7 @@ func (d *dashboardStore) GetAllDashboards(ctx context.Context) ([]*dashboards.Da defer span.End() var dashboards = make([]*dashboards.Dashboard, 0) - err := d.store.ReadReplica().WithDbSession(ctx, func(session *db.Session) error { + err := d.store.WithDbSession(ctx, func(session *db.Session) error { err := session.Find(&dashboards) return err }) @@ -1081,7 +1080,7 @@ func (d *dashboardStore) GetSoftDeletedExpiredDashboards(ctx context.Context, du defer span.End() var dashboards = make([]*dashboards.Dashboard, 0) - err := d.store.DB().WithDbSession(ctx, func(sess *db.Session) error { + err := d.store.WithDbSession(ctx, func(sess *db.Session) error { err := sess.Where("deleted IS NOT NULL AND deleted < ?", time.Now().Add(-duration)).Find(&dashboards) return err }) diff --git a/pkg/services/dashboards/database/database_folder_test.go b/pkg/services/dashboards/database/database_folder_test.go index 6f52c415e55..f654bc428e1 100644 --- a/pkg/services/dashboards/database/database_folder_test.go +++ b/pkg/services/dashboards/database/database_folder_test.go @@ -39,17 +39,17 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { t.Skip("skipping integration test") } t.Run("Testing DB", func(t *testing.T) { - var sqlStore db.ReplDB + var sqlStore db.DB var cfg *setting.Cfg var flder, dashInRoot, childDash *dashboards.Dashboard var currentUser *user.SignedInUser var dashboardStore dashboards.Store setup := func() { - sqlStore, cfg = db.InitTestReplDBWithCfg(t) + sqlStore, cfg = db.InitTestDBWithCfg(t) quotaService := quotatest.New(false, nil) var err error - dashboardStore, err = ProvideDashboardStore(sqlStore, cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err = ProvideDashboardStore(sqlStore, cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) flder = insertTestDashboard(t, dashboardStore, "1 test dash folder", 1, 0, "", true, "prod", "webapp") dashInRoot = insertTestDashboard(t, dashboardStore, "test dash 67", 1, 0, "", false, "prod", "webapp") @@ -68,7 +68,7 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { t.Run("and user can read folders and dashboards", func(t *testing.T) { currentUser.Permissions = map[int64]map[string][]string{1: {dashboards.ActionDashboardsRead: []string{dashboards.ScopeDashboardsAll}, dashboards.ActionFoldersRead: []string{dashboards.ScopeFoldersAll}}} - actest.AddUserPermissionToDB(t, sqlStore.DB(), currentUser) + actest.AddUserPermissionToDB(t, sqlStore, currentUser) t.Run("should return all dashboards and folders", func(t *testing.T) { query := &dashboards.FindPersistedDashboardsQuery{ @@ -86,7 +86,7 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { t.Run("and user can only read dashboards", func(t *testing.T) { currentUser.Permissions = map[int64]map[string][]string{1: {dashboards.ActionDashboardsRead: []string{dashboards.ScopeDashboardsAll}}} - actest.AddUserPermissionToDB(t, sqlStore.DB(), currentUser) + actest.AddUserPermissionToDB(t, sqlStore, currentUser) t.Run("should not return folder", func(t *testing.T) { query := &dashboards.FindPersistedDashboardsQuery{ @@ -104,7 +104,7 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { t.Run("and permissions are set for dashboard child and folder has all permissions removed", func(t *testing.T) { currentUser.Permissions = map[int64]map[string][]string{1: {dashboards.ActionDashboardsRead: {dashboards.ScopeDashboardsProvider.GetResourceScopeUID(dashInRoot.UID)}}} - actest.AddUserPermissionToDB(t, sqlStore.DB(), currentUser) + actest.AddUserPermissionToDB(t, sqlStore, currentUser) t.Run("should not return folder or child", func(t *testing.T) { query := &dashboards.FindPersistedDashboardsQuery{ @@ -119,7 +119,7 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { t.Run("when the user is given permission to child", func(t *testing.T) { currentUser.Permissions = map[int64]map[string][]string{1: {dashboards.ActionDashboardsRead: {dashboards.ScopeDashboardsAll}}} - actest.AddUserPermissionToDB(t, sqlStore.DB(), currentUser) + actest.AddUserPermissionToDB(t, sqlStore, currentUser) t.Run("should be able to search for child dashboard but not folder", func(t *testing.T) { query := &dashboards.FindPersistedDashboardsQuery{ @@ -138,17 +138,17 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { }) t.Run("Given two dashboard folders with one dashboard each and one dashboard in the root folder", func(t *testing.T) { - var sqlStore db.ReplDB + var sqlStore db.DB var cfg *setting.Cfg var folder1, folder2, dashInRoot, childDash1, childDash2 *dashboards.Dashboard var rootFolderId int64 = 0 var currentUser *user.SignedInUser setup2 := func() { - sqlStore, cfg = db.InitTestReplDBWithCfg(t) + sqlStore, cfg = db.InitTestDBWithCfg(t) quotaService := quotatest.New(false, nil) var err error - dashboardStore, err = ProvideDashboardStore(sqlStore, cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err = ProvideDashboardStore(sqlStore, cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) folder1 = insertTestDashboard(t, dashboardStore, "1 test dash folder", 1, 0, "", true, "prod") folder2 = insertTestDashboard(t, dashboardStore, "2 test dash folder", 1, 0, "", true, "prod") @@ -166,7 +166,7 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { setup2() t.Run("and one folder is expanded, the other collapsed", func(t *testing.T) { currentUser.Permissions = map[int64]map[string][]string{1: {dashboards.ActionDashboardsRead: {dashboards.ScopeDashboardsAll}, dashboards.ActionFoldersRead: []string{dashboards.ScopeFoldersAll}}} - actest.AddUserPermissionToDB(t, sqlStore.DB(), currentUser) + actest.AddUserPermissionToDB(t, sqlStore, currentUser) t.Run("should return dashboards in root and expanded folder", func(t *testing.T) { query := &dashboards.FindPersistedDashboardsQuery{ @@ -191,7 +191,7 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { t.Run("and a dashboard is moved from folder without acl to the folder with an acl", func(t *testing.T) { moveDashboard(t, dashboardStore, 1, childDash2.Data, folder1.ID, folder1.UID) currentUser.Permissions = map[int64]map[string][]string{1: {dashboards.ActionDashboardsRead: {dashboards.ScopeFoldersProvider.GetResourceScopeUID(folder2.UID), dashboards.ScopeDashboardsProvider.GetResourceScopeUID(dashInRoot.UID)}}} - actest.AddUserPermissionToDB(t, sqlStore.DB(), currentUser) + actest.AddUserPermissionToDB(t, sqlStore, currentUser) t.Run("should not return folder with acl or its children", func(t *testing.T) { query := &dashboards.FindPersistedDashboardsQuery{ @@ -210,7 +210,7 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { setup2() moveDashboard(t, dashboardStore, 1, childDash1.Data, folder2.ID, childDash2.FolderUID) currentUser.Permissions = map[int64]map[string][]string{1: {dashboards.ActionDashboardsRead: {dashboards.ScopeDashboardsProvider.GetResourceScopeUID(dashInRoot.UID), dashboards.ScopeFoldersProvider.GetResourceScopeUID(folder2.UID)}, dashboards.ActionFoldersRead: {dashboards.ScopeFoldersProvider.GetResourceScopeUID(folder2.UID)}}} - actest.AddUserPermissionToDB(t, sqlStore.DB(), currentUser) + actest.AddUserPermissionToDB(t, sqlStore, currentUser) t.Run("should return folder without acl and its children", func(t *testing.T) { query := &dashboards.FindPersistedDashboardsQuery{ @@ -240,7 +240,7 @@ func TestIntegrationDashboardInheritedFolderRBAC(t *testing.T) { // the maximux nested folder hierarchy starting from parent down to subfolders nestedFolders := make([]*folder.Folder, 0, folder.MaxNestedFolderDepth+1) - var sqlStore db.ReplDB + var sqlStore db.DB var cfg *setting.Cfg const ( dashInRootTitle = "dashboard in root" @@ -250,7 +250,7 @@ func TestIntegrationDashboardInheritedFolderRBAC(t *testing.T) { var viewer *user.SignedInUser setup := func() { - sqlStore, cfg = db.InitTestReplDBWithCfg(t) + sqlStore, cfg = db.InitTestDBWithCfg(t) cfg.AutoAssignOrg = true cfg.AutoAssignOrgId = 1 cfg.AutoAssignOrgRole = string(org.RoleViewer) @@ -262,13 +262,13 @@ func TestIntegrationDashboardInheritedFolderRBAC(t *testing.T) { features := featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders) var err error - dashboardWriteStore, err := ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardWriteStore, err := ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) - orgService, err := orgimpl.ProvideService(sqlStore.DB(), cfg, quotaService) + orgService, err := orgimpl.ProvideService(sqlStore, cfg, quotaService) require.NoError(t, err) usrSvc, err := userimpl.ProvideService( - sqlStore.DB(), orgService, cfg, nil, nil, tracer, + sqlStore, orgService, cfg, nil, nil, tracer, quotaService, supportbundlestest.NewFakeBundleService(), ) require.NoError(t, err) @@ -303,7 +303,7 @@ func TestIntegrationDashboardInheritedFolderRBAC(t *testing.T) { guardian.New = origNewGuardian }) - folderSvc := folderimpl.ProvideService(mock.New(), bus.ProvideBus(tracer), dashboardWriteStore, folderimpl.ProvideDashboardFolderStore(sqlStore.DB()), sqlStore.DB(), features, supportbundlestest.NewFakeBundleService(), nil, tracing.InitializeTracerForTest()) + folderSvc := folderimpl.ProvideService(mock.New(), bus.ProvideBus(tracer), dashboardWriteStore, folderimpl.ProvideDashboardFolderStore(sqlStore), sqlStore, features, supportbundlestest.NewFakeBundleService(), nil, tracing.InitializeTracerForTest()) parentUID := "" for i := 0; ; i++ { @@ -405,11 +405,11 @@ func TestIntegrationDashboardInheritedFolderRBAC(t *testing.T) { for _, tc := range testCases { t.Run(tc.desc, func(t *testing.T) { - dashboardReadStore, err := ProvideDashboardStore(sqlStore, cfg, tc.features, tagimpl.ProvideService(sqlStore.DB()), quotatest.New(false, nil)) + dashboardReadStore, err := ProvideDashboardStore(sqlStore, cfg, tc.features, tagimpl.ProvideService(sqlStore), quotatest.New(false, nil)) require.NoError(t, err) viewer.Permissions = map[int64]map[string][]string{viewer.OrgID: tc.permissions} - actest.AddUserPermissionToDB(t, sqlStore.DB(), viewer) + actest.AddUserPermissionToDB(t, sqlStore, viewer) query := &dashboards.FindPersistedDashboardsQuery{ SignedInUser: viewer, diff --git a/pkg/services/dashboards/database/database_provisioning_test.go b/pkg/services/dashboards/database/database_provisioning_test.go index 12cbe8aa4ea..798368370e9 100644 --- a/pkg/services/dashboards/database/database_provisioning_test.go +++ b/pkg/services/dashboards/database/database_provisioning_test.go @@ -18,7 +18,7 @@ func TestIntegrationDashboardProvisioningTest(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - sqlStore, cfg := db.InitTestReplDBWithCfg(t) + sqlStore, cfg := db.InitTestDBWithCfg(t) quotaService := quotatest.New(false, nil) dashboardStore, err := ProvideDashboardStore(sqlStore, cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) diff --git a/pkg/services/dashboards/database/database_test.go b/pkg/services/dashboards/database/database_test.go index 0e77bfee2d5..83c118d6047 100644 --- a/pkg/services/dashboards/database/database_test.go +++ b/pkg/services/dashboards/database/database_test.go @@ -44,16 +44,16 @@ func TestIntegrationDashboardDataAccess(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - var sqlStore db.ReplDB + var sqlStore db.DB var cfg *setting.Cfg var savedFolder, savedDash, savedDash2 *dashboards.Dashboard var dashboardStore dashboards.Store setup := func() { - sqlStore, cfg = db.InitTestReplDBWithCfg(t) + sqlStore, cfg = db.InitTestDBWithCfg(t) quotaService := quotatest.New(false, nil) var err error - dashboardStore, err = ProvideDashboardStore(sqlStore, cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err = ProvideDashboardStore(sqlStore, cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) // insertTestDashboard creates the following hierarchy: // 1 test dash folder @@ -539,16 +539,16 @@ func TestIntegrationGetSoftDeletedDashboard(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - var sqlStore db.ReplDB + var sqlStore *sqlstore.SQLStore var cfg *setting.Cfg var savedFolder, savedDash *dashboards.Dashboard var dashboardStore dashboards.Store setup := func() { - sqlStore, cfg = db.InitTestReplDBWithCfg(t) + sqlStore, cfg = db.InitTestDBWithCfg(t) quotaService := quotatest.New(false, nil) var err error - dashboardStore, err = ProvideDashboardStore(sqlStore, cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err = ProvideDashboardStore(sqlStore, cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) savedFolder = insertTestDashboard(t, dashboardStore, "1 test dash folder", 1, 0, "", true, "prod", "webapp") savedDash = insertTestDashboard(t, dashboardStore, "test dash 23", 1, savedFolder.ID, savedFolder.UID, false, "prod", "webapp") @@ -660,7 +660,7 @@ func TestIntegrationDashboardDataAccessGivenPluginWithImportedDashboards(t *test if testing.Short() { t.Skip("skipping integration test") } - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) quotaService := quotatest.New(false, nil) dashboardStore, err := ProvideDashboardStore(sqlStore, &setting.Cfg{}, testFeatureToggles, tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) @@ -685,7 +685,7 @@ func TestIntegrationDashboard_SortingOptions(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) quotaService := quotatest.New(false, nil) dashboardStore, err := ProvideDashboardStore(sqlStore, &setting.Cfg{}, testFeatureToggles, tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) @@ -736,7 +736,7 @@ func TestIntegrationDashboard_Filter(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() quotaService := quotatest.New(false, nil) dashboardStore, err := ProvideDashboardStore(sqlStore, cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore), quotaService) @@ -780,7 +780,7 @@ func TestIntegrationDashboard_Filter(t *testing.T) { } func TestGetExistingDashboardByTitleAndFolder(t *testing.T) { - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() quotaService := quotatest.New(false, nil) dashboardStore, err := ProvideDashboardStore(sqlStore, cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore), quotaService) @@ -818,7 +818,7 @@ func TestIntegrationFindDashboardsByTitle(t *testing.T) { t.Skip("skipping integration test") } - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() quotaService := quotatest.New(false, nil) features := featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders, featuremgmt.FlagPanelTitleSearch) @@ -936,7 +936,7 @@ func TestIntegrationFindDashboardsByFolder(t *testing.T) { t.Skip("skipping integration test") } - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() quotaService := quotatest.New(false, nil) features := featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders, featuremgmt.FlagPanelTitleSearch) @@ -1118,8 +1118,8 @@ func TestIntegrationFindDashboardsByFolder(t *testing.T) { } } -func insertTestRule(t *testing.T, sqlStore db.ReplDB, foderOrgID int64, folderUID string) { - err := sqlStore.DB().WithDbSession(context.Background(), func(sess *db.Session) error { +func insertTestRule(t *testing.T, sqlStore db.DB, foderOrgID int64, folderUID string) { + err := sqlStore.WithDbSession(context.Background(), func(sess *db.Session) error { type alertQuery struct { RefID string DatasourceUID string diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go index 99f68aa917b..34c400461c4 100644 --- a/pkg/services/dashboards/service/dashboard_service.go +++ b/pkg/services/dashboards/service/dashboard_service.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" "github.com/prometheus/client_golang/prometheus" "go.opentelemetry.io/otel" + "golang.org/x/exp/slices" "github.com/grafana/grafana/pkg/apimachinery/identity" diff --git a/pkg/services/dashboards/service/dashboard_service_integration_test.go b/pkg/services/dashboards/service/dashboard_service_integration_test.go index e536a332ea5..c1fb4f426a0 100644 --- a/pkg/services/dashboards/service/dashboard_service_integration_test.go +++ b/pkg/services/dashboards/service/dashboard_service_integration_test.go @@ -103,7 +103,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { permissionScenario(t, "When creating a new dashboard in the General folder", canSave, func(t *testing.T, sc *permissionScenarioContext) { - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) cmd := dashboards.SaveDashboardCommand{ OrgID: testOrgID, Dashboard: simplejson.NewFromAny(map[string]any{ @@ -838,7 +838,7 @@ func TestIntegrationIntegratedDashboardService(t *testing.T) { type permissionScenarioContext struct { dashboardGuardianMock *guardian.FakeDashboardGuardian - sqlStore db.ReplDB + sqlStore db.DB dashboardStore dashboards.Store savedFolder *dashboards.Dashboard savedDashInFolder *dashboards.Dashboard @@ -858,7 +858,7 @@ func permissionScenario(t *testing.T, desc string, canSave bool, fn permissionSc t.Run(desc, func(t *testing.T) { features := featuremgmt.WithFeatures() cfg := setting.NewCfg() - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) quotaService := quotatest.New(false, nil) ac := actest.FakeAccessControl{ExpectedEvaluate: true} dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore), quotaService) @@ -917,16 +917,16 @@ func permissionScenario(t *testing.T, desc string, canSave bool, fn permissionSc }) } -func callSaveWithResult(t *testing.T, cmd dashboards.SaveDashboardCommand, sqlStore db.ReplDB) *dashboards.Dashboard { +func callSaveWithResult(t *testing.T, cmd dashboards.SaveDashboardCommand, sqlStore db.DB) *dashboards.Dashboard { t.Helper() features := featuremgmt.WithFeatures() dto := toSaveDashboardDto(cmd) cfg := setting.NewCfg() quotaService := quotatest.New(false, nil) - dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) - folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore.DB()) + folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) folderPermissions := accesscontrolmock.NewMockedPermissionsService() folderPermissions.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil) @@ -948,14 +948,14 @@ func callSaveWithResult(t *testing.T, cmd dashboards.SaveDashboardCommand, sqlSt return res } -func callSaveWithError(t *testing.T, cmd dashboards.SaveDashboardCommand, sqlStore db.ReplDB) error { +func callSaveWithError(t *testing.T, cmd dashboards.SaveDashboardCommand, sqlStore db.DB) error { features := featuremgmt.WithFeatures() dto := toSaveDashboardDto(cmd) cfg := setting.NewCfg() quotaService := quotatest.New(false, nil) - dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) - folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore.DB()) + folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) service, err := ProvideDashboardServiceImpl( cfg, dashboardStore, folderStore, featuremgmt.WithFeatures(), @@ -970,7 +970,7 @@ func callSaveWithError(t *testing.T, cmd dashboards.SaveDashboardCommand, sqlSto return err } -func saveTestDashboard(t *testing.T, title string, orgID int64, folderUID string, sqlStore db.ReplDB) *dashboards.Dashboard { +func saveTestDashboard(t *testing.T, title string, orgID int64, folderUID string, sqlStore db.DB) *dashboards.Dashboard { t.Helper() cmd := dashboards.SaveDashboardCommand{ @@ -994,9 +994,9 @@ func saveTestDashboard(t *testing.T, title string, orgID int64, folderUID string features := featuremgmt.WithFeatures() cfg := setting.NewCfg() quotaService := quotatest.New(false, nil) - dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) - folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore.DB()) + folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) dashboardPermissions := accesscontrolmock.NewMockedPermissionsService() dashboardPermissions.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil) service, err := ProvideDashboardServiceImpl( @@ -1016,7 +1016,7 @@ func saveTestDashboard(t *testing.T, title string, orgID int64, folderUID string return res } -func saveTestFolder(t *testing.T, title string, orgID int64, sqlStore db.ReplDB) *dashboards.Dashboard { +func saveTestFolder(t *testing.T, title string, orgID int64, sqlStore db.DB) *dashboards.Dashboard { t.Helper() cmd := dashboards.SaveDashboardCommand{ OrgID: orgID, @@ -1044,9 +1044,9 @@ func saveTestFolder(t *testing.T, title string, orgID int64, sqlStore db.ReplDB) features := featuremgmt.WithFeatures() cfg := setting.NewCfg() quotaService := quotatest.New(false, nil) - dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) - folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore.DB()) + folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) folderPermissions := accesscontrolmock.NewMockedPermissionsService() folderPermissions.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil) service, err := ProvideDashboardServiceImpl( diff --git a/pkg/services/dashboardsnapshots/service/service_test.go b/pkg/services/dashboardsnapshots/service/service_test.go index 0c56c52ebd5..c2cdf73efad 100644 --- a/pkg/services/dashboardsnapshots/service/service_test.go +++ b/pkg/services/dashboardsnapshots/service/service_test.go @@ -92,7 +92,7 @@ func TestDashboardSnapshotsService(t *testing.T) { } func TestValidateDashboardExists(t *testing.T) { - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() dsStore := dashsnapdb.ProvideStore(sqlStore, cfg) secretsService := secretsManager.SetupTestService(t, database.ProvideSecretsStore(sqlStore)) diff --git a/pkg/services/featuremgmt/codeowners.go b/pkg/services/featuremgmt/codeowners.go index 2269fd6a1d1..83f9fda7ee2 100644 --- a/pkg/services/featuremgmt/codeowners.go +++ b/pkg/services/featuremgmt/codeowners.go @@ -28,4 +28,5 @@ const ( enterpriseDatasourcesSquad codeowner = "@grafana/enterprise-datasources" grafanaSharingSquad codeowner = "@grafana/sharing-squad" grafanaDatabasesFrontend codeowner = "@grafana/databases-frontend" + growthAndOnboarding codeowner = "@grafana/growth-and-onboarding" ) diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index ba60f9270c8..c87b872e64f 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1324,13 +1324,6 @@ var ( Stage: FeatureStageExperimental, Owner: grafanaPluginsPlatformSquad, }, - { - Name: "databaseReadReplica", - Description: "Use a read replica for some database queries.", - Stage: FeatureStageExperimental, - Owner: grafanaBackendServicesSquad, - Expression: "false", // enabled by default - }, { Name: "zanzana", Description: "Use openFGA as authorization engine.", @@ -1438,6 +1431,13 @@ var ( FrontendOnly: true, Owner: grafanaObservabilityLogsSquad, }, + { + Name: "homeSetupGuide", + Description: "Used in Home for users who want to return to the onboarding flow or quickly find popular config pages", + Stage: FeatureStageExperimental, + FrontendOnly: true, + Owner: growthAndOnboarding, + }, { Name: "appPlatformAccessTokens", Description: "Enables the use of access tokens for the App Platform", @@ -1459,6 +1459,13 @@ var ( Owner: identityAccessTeam, HideFromDocs: true, }, + { + Name: "alertingQueryAndExpressionsStepMode", + Description: "Enables step mode for alerting queries and expressions", + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + FrontendOnly: true, + }, { Name: "improvedExternalSessionHandling", Description: "Enable improved support for external sessions in Grafana", @@ -1473,6 +1480,12 @@ var ( Stage: FeatureStagePublicPreview, Owner: identityAccessTeam, }, + { + Name: "rolePickerDrawer", + Description: "Enables the new role picker drawer design", + Stage: FeatureStageExperimental, + Owner: identityAccessTeam, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 15adc71435a..ae8b748ca22 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -174,7 +174,6 @@ authZGRPCServer,experimental,@grafana/identity-access-team,false,false,false openSearchBackendFlowEnabled,GA,@grafana/aws-datasources,false,false,false ssoSettingsLDAP,experimental,@grafana/identity-access-team,false,false,false failWrongDSUID,experimental,@grafana/plugins-platform-backend,false,false,false -databaseReadReplica,experimental,@grafana/grafana-backend-services-squad,false,false,false zanzana,experimental,@grafana/identity-access-team,false,false,false passScopeToDashboardApi,experimental,@grafana/dashboards-squad,false,false,false alertingApiServer,experimental,@grafana/alerting-squad,false,true,false @@ -190,8 +189,11 @@ singleTopNav,experimental,@grafana/grafana-frontend-platform,false,false,true exploreLogsShardSplitting,experimental,@grafana/observability-logs,false,false,true exploreLogsAggregatedMetrics,experimental,@grafana/observability-logs,false,false,true exploreLogsLimitedTimeRange,experimental,@grafana/observability-logs,false,false,true +homeSetupGuide,experimental,@grafana/growth-and-onboarding,false,false,true appPlatformAccessTokens,experimental,@grafana/identity-access-team,false,false,false appSidecar,experimental,@grafana/explore-squad,false,false,false groupAttributeSync,experimental,@grafana/identity-access-team,false,false,false +alertingQueryAndExpressionsStepMode,experimental,@grafana/alerting-squad,false,false,true improvedExternalSessionHandling,experimental,@grafana/identity-access-team,false,false,false useSessionStorageForRedirection,preview,@grafana/identity-access-team,false,false,false +rolePickerDrawer,experimental,@grafana/identity-access-team,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index d1bcec7d3d0..2be496880cb 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -707,10 +707,6 @@ const ( // Throws an error if a datasource has an invalid UIDs FlagFailWrongDSUID = "failWrongDSUID" - // FlagDatabaseReadReplica - // Use a read replica for some database queries. - FlagDatabaseReadReplica = "databaseReadReplica" - // FlagZanzana // Use openFGA as authorization engine. FlagZanzana = "zanzana" @@ -771,6 +767,10 @@ const ( // Used in Explore Logs to limit the time range FlagExploreLogsLimitedTimeRange = "exploreLogsLimitedTimeRange" + // FlagHomeSetupGuide + // Used in Home for users who want to return to the onboarding flow or quickly find popular config pages + FlagHomeSetupGuide = "homeSetupGuide" + // FlagAppPlatformAccessTokens // Enables the use of access tokens for the App Platform FlagAppPlatformAccessTokens = "appPlatformAccessTokens" @@ -783,6 +783,10 @@ const ( // Enable the groupsync extension for managing Group Attribute Sync feature FlagGroupAttributeSync = "groupAttributeSync" + // FlagAlertingQueryAndExpressionsStepMode + // Enables step mode for alerting queries and expressions + FlagAlertingQueryAndExpressionsStepMode = "alertingQueryAndExpressionsStepMode" + // FlagImprovedExternalSessionHandling // Enable improved support for external sessions in Grafana FlagImprovedExternalSessionHandling = "improvedExternalSessionHandling" @@ -790,4 +794,8 @@ const ( // FlagUseSessionStorageForRedirection // Use session storage for handling the redirection after login FlagUseSessionStorageForRedirection = "useSessionStorageForRedirection" + + // FlagRolePickerDrawer + // Enables the new role picker drawer design + FlagRolePickerDrawer = "rolePickerDrawer" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 4c8ad4a0a3f..24e8ef6522f 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -240,6 +240,19 @@ "hideFromAdminPage": true } }, + { + "metadata": { + "name": "alertingQueryAndExpressionsStepMode", + "resourceVersion": "1725978395461", + "creationTimestamp": "2024-09-10T14:26:35Z" + }, + "spec": { + "description": "Enables step mode for alerting queries and expressions", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad", + "frontend": true + } + }, { "metadata": { "name": "alertingQueryOptimization", @@ -869,6 +882,7 @@ "name": "databaseReadReplica", "resourceVersion": "1720021873452", "creationTimestamp": "2024-06-18T15:07:15Z", + "deletionTimestamp": "2024-09-20T20:03:26Z", "annotations": { "grafana.app/updatedTimestamp": "2024-07-03 15:51:13.452477 +0000 UTC" } @@ -1433,6 +1447,22 @@ "hideFromAdminPage": true } }, + { + "metadata": { + "name": "homeSetupGuide", + "resourceVersion": "1726258153467", + "creationTimestamp": "2024-09-10T15:46:32Z", + "annotations": { + "grafana.app/updatedTimestamp": "2024-09-13 20:09:13.467989 +0000 UTC" + } + }, + "spec": { + "description": "Used in Home for users who want to return to the onboarding flow or quickly find popular config pages", + "stage": "experimental", + "codeowner": "@grafana/growth-and-onboarding", + "frontend": true + } + }, { "metadata": { "name": "idForwarding", @@ -2627,6 +2657,18 @@ "requiresRestart": true } }, + { + "metadata": { + "name": "rolePickerDrawer", + "resourceVersion": "1727337187819", + "creationTimestamp": "2024-09-26T07:53:07Z" + }, + "spec": { + "description": "Enables the new role picker drawer design", + "stage": "experimental", + "codeowner": "@grafana/identity-access-team" + } + }, { "metadata": { "name": "scenes", diff --git a/pkg/services/folder/folderimpl/dashboard_folder_store_test.go b/pkg/services/folder/folderimpl/dashboard_folder_store_test.go index 660fdeb40ec..8345f6ce32a 100644 --- a/pkg/services/folder/folderimpl/dashboard_folder_store_test.go +++ b/pkg/services/folder/folderimpl/dashboard_folder_store_test.go @@ -23,15 +23,15 @@ func TestMain(m *testing.M) { } func TestIntegrationDashboardFolderStore(t *testing.T) { - var sqlStore db.ReplDB + var sqlStore db.DB var cfg *setting.Cfg var dashboardStore dashboards.Store setup := func() { - sqlStore, cfg = db.InitTestReplDBWithCfg(t) + sqlStore, cfg = db.InitTestDBWithCfg(t) quotaService := quotatest.New(false, nil) var err error - dashboardStore, err = database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(featuremgmt.FlagPanelTitleSearch), tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err = database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(featuremgmt.FlagPanelTitleSearch), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) } t.Run("Given dashboard and folder with the same title", func(t *testing.T) { diff --git a/pkg/services/folder/folderimpl/folder_test.go b/pkg/services/folder/folderimpl/folder_test.go index f2712ff2359..7ad8b4b8235 100644 --- a/pkg/services/folder/folderimpl/folder_test.go +++ b/pkg/services/folder/folderimpl/folder_test.go @@ -418,7 +418,7 @@ func TestIntegrationNestedFolderService(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - db, cfg := sqlstore.InitTestReplDB(t) + db, cfg := sqlstore.InitTestDB(t) cfg.UnifiedAlerting.BaseInterval = time.Second quotaService := quotatest.New(false, nil) folderStore := ProvideDashboardFolderStore(db) @@ -828,7 +828,7 @@ func TestFolderServiceDualWrite(t *testing.T) { guardian.New = g }) - db, _ := sqlstore.InitTestReplDB(t) + db, _ := sqlstore.InitTestDB(t) cfg := setting.NewCfg() features := featuremgmt.WithFeatures() nestedFolderStore := ProvideStore(db) @@ -1459,7 +1459,7 @@ func TestIntegrationNestedFolderSharedWithMe(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - db, cfg := sqlstore.InitTestReplDB(t) + db, cfg := sqlstore.InitTestDB(t) quotaService := quotatest.New(false, nil) folderStore := ProvideDashboardFolderStore(db) @@ -1863,7 +1863,7 @@ func TestIntegrationNestedFolderSharedWithMe(t *testing.T) { } func TestFolderServiceGetFolder(t *testing.T) { - db, _ := sqlstore.InitTestReplDB(t) + db, _ := sqlstore.InitTestDB(t) signedInAdminUser := user.SignedInUser{UserID: 1, OrgID: orgID, Permissions: map[int64]map[string][]string{ orgID: { @@ -1962,7 +1962,7 @@ func TestFolderServiceGetFolder(t *testing.T) { } func TestFolderServiceGetFolders(t *testing.T) { - db, cfg := sqlstore.InitTestReplDB(t) + db, cfg := sqlstore.InitTestDB(t) quotaService := quotatest.New(false, nil) folderStore := ProvideDashboardFolderStore(db) @@ -2038,7 +2038,7 @@ func TestFolderServiceGetFolders(t *testing.T) { // TODO replace it with an API test under /pkg/tests/api/folders // whenever the golang client with get updated to allow filtering child folders by permission func TestGetChildrenFilterByPermission(t *testing.T) { - db, cfg := sqlstore.InitTestReplDB(t) + db, cfg := sqlstore.InitTestDB(t) signedInAdminUser := user.SignedInUser{UserID: 1, OrgID: orgID, Permissions: map[int64]map[string][]string{ orgID: { diff --git a/pkg/services/libraryelements/guard.go b/pkg/services/libraryelements/guard.go index 41df9c47049..38479965753 100644 --- a/pkg/services/libraryelements/guard.go +++ b/pkg/services/libraryelements/guard.go @@ -58,7 +58,7 @@ func (l *LibraryElementService) requireEditPermissionsOnFolder(ctx context.Conte } func (l *LibraryElementService) requireViewPermissionsOnFolder(ctx context.Context, user identity.Requester, folderID int64) error { - if isGeneralFolder(folderID) && user.HasRole(org.RoleViewer) { + if isGeneralFolder(folderID) { return nil } diff --git a/pkg/services/libraryelements/libraryelements_delete_test.go b/pkg/services/libraryelements/libraryelements_delete_test.go index c09062b18a4..597787d6ee0 100644 --- a/pkg/services/libraryelements/libraryelements_delete_test.go +++ b/pkg/services/libraryelements/libraryelements_delete_test.go @@ -4,13 +4,12 @@ import ( "encoding/json" "testing" - "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/libraryelements/model" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/web" + "github.com/stretchr/testify/require" ) func TestDeleteLibraryElement(t *testing.T) { @@ -75,7 +74,7 @@ func TestDeleteLibraryElement(t *testing.T) { Data: simplejson.NewFromAny(dashJSON), } // nolint:staticcheck - dashInDB := createDashboard(t, sc.replStore, sc.user, &dash, sc.folder.ID, sc.folder.UID) + dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash, sc.folder.ID, sc.folder.UID) err := sc.service.ConnectElementsToDashboard(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, []string{sc.initialResult.Result.UID}, dashInDB.ID) require.NoError(t, err) diff --git a/pkg/services/libraryelements/libraryelements_get_test.go b/pkg/services/libraryelements/libraryelements_get_test.go index 869e326389b..b441505639b 100644 --- a/pkg/services/libraryelements/libraryelements_get_test.go +++ b/pkg/services/libraryelements/libraryelements_get_test.go @@ -4,14 +4,13 @@ import ( "testing" "github.com/google/go-cmp/cmp" - "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/kinds/librarypanel" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/libraryelements/model" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/web" + "github.com/stretchr/testify/require" ) func TestGetLibraryElement(t *testing.T) { @@ -125,7 +124,7 @@ func TestGetLibraryElement(t *testing.T) { Data: simplejson.NewFromAny(dashJSON), } // nolint:staticcheck - dashInDB := createDashboard(t, sc.replStore, sc.user, &dash, sc.folder.ID, sc.folder.UID) + dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash, sc.folder.ID, sc.folder.UID) err := sc.service.ConnectElementsToDashboard(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, []string{sc.initialResult.Result.UID}, dashInDB.ID) require.NoError(t, err) diff --git a/pkg/services/libraryelements/libraryelements_test.go b/pkg/services/libraryelements/libraryelements_test.go index 9937ceb6aae..1f5c22168ff 100644 --- a/pkg/services/libraryelements/libraryelements_test.go +++ b/pkg/services/libraryelements/libraryelements_test.go @@ -89,7 +89,7 @@ func TestDeleteLibraryPanelsInFolder(t *testing.T) { Data: simplejson.NewFromAny(dashJSON), } // nolint:staticcheck - dashInDB := createDashboard(t, sc.replStore, sc.user, &dash, sc.folder.ID, sc.folder.UID) + dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash, sc.folder.ID, sc.folder.UID) err := sc.service.ConnectElementsToDashboard(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, []string{sc.initialResult.Result.UID}, dashInDB.ID) require.NoError(t, err) @@ -164,7 +164,7 @@ func TestGetLibraryPanelConnections(t *testing.T) { Data: simplejson.NewFromAny(dashJSON), } // nolint:staticcheck - dashInDB := createDashboard(t, sc.replStore, sc.user, &dash, sc.folder.ID, sc.folder.UID) + dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash, sc.folder.ID, sc.folder.UID) err := sc.service.ConnectElementsToDashboard(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, []string{sc.initialResult.Result.UID}, dashInDB.ID) require.NoError(t, err) @@ -279,11 +279,10 @@ type scenarioContext struct { folder *folder.Folder initialResult libraryElementResult sqlStore db.DB - replStore db.ReplDB log log.Logger } -func createDashboard(t *testing.T, sqlStore db.ReplDB, user user.SignedInUser, dash *dashboards.Dashboard, folderID int64, folderUID string) *dashboards.Dashboard { +func createDashboard(t *testing.T, sqlStore db.DB, user user.SignedInUser, dash *dashboards.Dashboard, folderID int64, folderUID string) *dashboards.Dashboard { // nolint:staticcheck dash.FolderID = folderID dash.FolderUID = folderUID @@ -298,13 +297,13 @@ func createDashboard(t *testing.T, sqlStore db.ReplDB, user user.SignedInUser, d features := featuremgmt.WithFeatures() cfg := setting.NewCfg() quotaService := quotatest.New(false, nil) - dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) ac := actest.FakeAccessControl{ExpectedEvaluate: true} folderPermissions := acmock.NewMockedPermissionsService() dashboardPermissions := acmock.NewMockedPermissionsService() dashboardPermissions.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil) - folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore.DB()) + folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) service, err := dashboardservice.ProvideDashboardServiceImpl( cfg, dashboardStore, folderStore, features, folderPermissions, dashboardPermissions, ac, @@ -325,7 +324,7 @@ func createFolder(t *testing.T, sc scenarioContext, title string) *folder.Folder cfg := setting.NewCfg() ac := actest.FakeAccessControl{ExpectedEvaluate: true} quotaService := quotatest.New(false, nil) - dashboardStore, err := database.ProvideDashboardStore(sc.replStore, cfg, features, tagimpl.ProvideService(sc.sqlStore), quotaService) + dashboardStore, err := database.ProvideDashboardStore(sc.sqlStore, cfg, features, tagimpl.ProvideService(sc.sqlStore), quotaService) require.NoError(t, err) folderStore := folderimpl.ProvideDashboardFolderStore(sc.sqlStore) @@ -381,7 +380,7 @@ func scenarioWithPanel(t *testing.T, desc string, fn func(t *testing.T, sc scena t.Helper() features := featuremgmt.WithFeatures() - sqlStore, cfg := db.InitTestReplDBWithCfg(t) + sqlStore, cfg := db.InitTestDBWithCfg(t) ac := actest.FakeAccessControl{} quotaService := quotatest.New(false, nil) dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore), quotaService) @@ -442,7 +441,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo features := featuremgmt.WithFeatures() tracer := tracing.InitializeTracerForTest() - sqlStore, cfg := db.InitTestReplDBWithCfg(t) + sqlStore, cfg := db.InitTestDBWithCfg(t) quotaService := quotatest.New(false, nil) dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) @@ -485,11 +484,10 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo require.NoError(t, err) sc := scenarioContext{ - user: usr, - ctx: &webCtx, - service: &service, - sqlStore: sqlStore.DB(), - replStore: sqlStore, + user: usr, + ctx: &webCtx, + service: &service, + sqlStore: sqlStore, reqContext: &contextmodel.ReqContext{ Context: &webCtx, SignedInUser: &usr, diff --git a/pkg/services/librarypanels/librarypanels_test.go b/pkg/services/librarypanels/librarypanels_test.go index a5389988491..89bcb12b3cb 100644 --- a/pkg/services/librarypanels/librarypanels_test.go +++ b/pkg/services/librarypanels/librarypanels_test.go @@ -84,7 +84,7 @@ func TestConnectLibraryPanelsForDashboard(t *testing.T) { Title: "Testing ConnectLibraryPanelsForDashboard", Data: simplejson.NewFromAny(dashJSON), } - dashInDB := createDashboard(t, sc.replStore, sc.user, &dash) + dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash) err := sc.service.ConnectLibraryPanelsForDashboard(sc.ctx, sc.user, dashInDB) require.NoError(t, err) @@ -182,7 +182,7 @@ func TestConnectLibraryPanelsForDashboard(t *testing.T) { Title: "Testing ConnectLibraryPanelsForDashboard", Data: simplejson.NewFromAny(dashJSON), } - dashInDB := createDashboard(t, sc.replStore, sc.user, &dash) + dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash) err = sc.service.ConnectLibraryPanelsForDashboard(sc.ctx, sc.user, dashInDB) require.NoError(t, err) @@ -228,7 +228,7 @@ func TestConnectLibraryPanelsForDashboard(t *testing.T) { Title: "Testing ConnectLibraryPanelsForDashboard", Data: simplejson.NewFromAny(dashJSON), } - dashInDB := createDashboard(t, sc.replStore, sc.user, &dash) + dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash) err := sc.service.ConnectLibraryPanelsForDashboard(sc.ctx, sc.user, dashInDB) require.EqualError(t, err, errLibraryPanelHeaderUIDMissing.Error()) @@ -284,7 +284,7 @@ func TestConnectLibraryPanelsForDashboard(t *testing.T) { Title: "Testing ConnectLibraryPanelsForDashboard", Data: simplejson.NewFromAny(dashJSON), } - dashInDB := createDashboard(t, sc.replStore, sc.user, &dash) + dashInDB := createDashboard(t, sc.sqlStore, sc.user, &dash) err = sc.elementService.ConnectElementsToDashboard(sc.ctx, sc.user, []string{sc.initialResult.Result.UID}, dashInDB.ID) require.NoError(t, err) @@ -635,7 +635,6 @@ type scenarioContext struct { folder *folder.Folder initialResult libraryPanelResult sqlStore db.DB - replStore db.ReplDB lps LibraryPanelService } @@ -712,7 +711,7 @@ func getExpected(t *testing.T, res model.LibraryElementDTO, UID string, name str } } -func createDashboard(t *testing.T, sqlStore db.ReplDB, user *user.SignedInUser, dash *dashboards.Dashboard) *dashboards.Dashboard { +func createDashboard(t *testing.T, sqlStore db.DB, user *user.SignedInUser, dash *dashboards.Dashboard) *dashboards.Dashboard { dashItem := &dashboards.SaveDashboardDTO{ Dashboard: dash, Message: "", @@ -724,10 +723,10 @@ func createDashboard(t *testing.T, sqlStore db.ReplDB, user *user.SignedInUser, features := featuremgmt.WithFeatures() cfg := setting.NewCfg() quotaService := quotatest.New(false, nil) - dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) ac := actest.FakeAccessControl{ExpectedEvaluate: true} - folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore.DB()) + folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) dashPermissionService := acmock.NewMockedPermissionsService() dashPermissionService.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil) service, err := dashboardservice.ProvideDashboardServiceImpl( @@ -750,7 +749,7 @@ func createFolder(t *testing.T, sc scenarioContext, title string) *folder.Folder ac := actest.FakeAccessControl{ExpectedEvaluate: true} cfg := setting.NewCfg() quotaService := quotatest.New(false, nil) - dashboardStore, err := database.ProvideDashboardStore(sc.replStore, cfg, features, tagimpl.ProvideService(sc.sqlStore), quotaService) + dashboardStore, err := database.ProvideDashboardStore(sc.sqlStore, cfg, features, tagimpl.ProvideService(sc.sqlStore), quotaService) require.NoError(t, err) folderStore := folderimpl.ProvideDashboardFolderStore(sc.sqlStore) s := folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashboardStore, folderStore, sc.sqlStore, features, supportbundlestest.NewFakeBundleService(), nil, tracing.InitializeTracerForTest()) @@ -815,8 +814,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo t.Run(desc, func(t *testing.T) { orgID := int64(1) role := org.RoleAdmin - replStore, cfg := db.InitTestReplDBWithCfg(t) - sqlStore := replStore.DB() + sqlStore, cfg := db.InitTestDBWithCfg(t) quotaService := quotatest.New(false, nil) ac := actest.FakeAccessControl{ExpectedEvaluate: true} @@ -833,7 +831,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo require.NoError(t, err) guardian.InitAccessControlGuardian(setting.NewCfg(), ac, dashService) - dashboardStore, err := database.ProvideDashboardStore(replStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) + dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) features := featuremgmt.WithFeatures() folderService := folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashboardStore, folderStore, sqlStore, features, supportbundlestest.NewFakeBundleService(), nil, tracing.InitializeTracerForTest()) @@ -886,7 +884,6 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo service: &service, elementService: elementService, sqlStore: sqlStore, - replStore: replStore, lps: service, } diff --git a/pkg/services/live/live.go b/pkg/services/live/live.go index 56eedf5e1ea..feabf87ad83 100644 --- a/pkg/services/live/live.go +++ b/pkg/services/live/live.go @@ -97,6 +97,11 @@ func ProvideService(plugCtxProvider *plugincontext.Provider, cfg *setting.Cfg, r }, usageStatsService: usageStatsService, orgService: orgService, + keyPrefix: "gf_live", + } + + if cfg.LiveHAPrefix != "" { + g.keyPrefix = cfg.LiveHAPrefix + ".gf_live" } logger.Debug("GrafanaLive initialization", "ha", g.IsHA()) @@ -153,7 +158,7 @@ func ProvideService(plugCtxProvider *plugincontext.Provider, cfg *setting.Cfg, r managedStreamRunner = managedstream.NewRunner( g.Publish, channelLocalPublisher, - managedstream.NewRedisFrameCache(redisClient), + managedstream.NewRedisFrameCache(redisClient, g.keyPrefix), ) } else { managedStreamRunner = managedstream.NewRunner( @@ -344,7 +349,7 @@ func setupRedisLiveEngine(g *GrafanaLive, node *centrifuge.Node) error { } broker, err := centrifuge.NewRedisBroker(node, centrifuge.RedisBrokerConfig{ - Prefix: "gf_live", + Prefix: g.keyPrefix, Shards: redisShards, }) if err != nil { @@ -354,7 +359,7 @@ func setupRedisLiveEngine(g *GrafanaLive, node *centrifuge.Node) error { node.SetBroker(broker) presenceManager, err := centrifuge.NewRedisPresenceManager(node, centrifuge.RedisPresenceManagerConfig{ - Prefix: "gf_live", + Prefix: g.keyPrefix, Shards: redisShards, }) if err != nil { @@ -385,6 +390,8 @@ type GrafanaLive struct { queryDataService query.Service orgService org.Service + keyPrefix string + node *centrifuge.Node surveyCaller *survey.Caller diff --git a/pkg/services/live/managedstream/cache_redis.go b/pkg/services/live/managedstream/cache_redis.go index b547a11cd20..34b16991671 100644 --- a/pkg/services/live/managedstream/cache_redis.go +++ b/pkg/services/live/managedstream/cache_redis.go @@ -18,11 +18,13 @@ type RedisFrameCache struct { mu sync.RWMutex redisClient *redis.Client frames map[int64]map[string]data.FrameJSONCache + keyPrefix string } // NewRedisFrameCache ... -func NewRedisFrameCache(redisClient *redis.Client) *RedisFrameCache { +func NewRedisFrameCache(redisClient *redis.Client, keyPrefix string) *RedisFrameCache { return &RedisFrameCache{ + keyPrefix: keyPrefix, frames: map[int64]map[string]data.FrameJSONCache{}, redisClient: redisClient, } @@ -43,7 +45,7 @@ func (c *RedisFrameCache) GetActiveChannels(orgID int64) (map[string]json.RawMes } func (c *RedisFrameCache) GetFrame(ctx context.Context, orgID int64, channel string) (json.RawMessage, bool, error) { - key := getCacheKey(orgchannel.PrependOrgID(orgID, channel)) + key := c.getCacheKey(orgchannel.PrependOrgID(orgID, channel)) cmd := c.redisClient.HGetAll(ctx, key) result, err := cmd.Result() if err != nil { @@ -69,7 +71,7 @@ func (c *RedisFrameCache) Update(ctx context.Context, orgID int64, channel strin stringSchema := string(jsonFrame.Bytes(data.IncludeSchemaOnly)) - key := getCacheKey(orgchannel.PrependOrgID(orgID, channel)) + key := c.getCacheKey(orgchannel.PrependOrgID(orgID, channel)) pipe := c.redisClient.TxPipeline() defer func() { _ = pipe.Close() }() @@ -107,6 +109,6 @@ func (c *RedisFrameCache) Update(ctx context.Context, orgID int64, channel strin return true, nil } -func getCacheKey(channelID string) string { - return "gf_live.managed_stream." + channelID +func (c *RedisFrameCache) getCacheKey(channelID string) string { + return c.keyPrefix + ".managed_stream." + channelID } diff --git a/pkg/services/live/managedstream/cache_redis_test.go b/pkg/services/live/managedstream/cache_redis_test.go index d7c3f9fddbb..807a1b890b2 100644 --- a/pkg/services/live/managedstream/cache_redis_test.go +++ b/pkg/services/live/managedstream/cache_redis_test.go @@ -2,9 +2,11 @@ package managedstream import ( "os" + "strings" "testing" "github.com/go-redis/redis/v8" + "github.com/google/uuid" "github.com/stretchr/testify/require" ) @@ -30,7 +32,36 @@ func TestIntegrationRedisCacheStorage(t *testing.T) { Addr: addr, DB: db, }) - c := NewRedisFrameCache(redisClient) + prefix := uuid.New().String() + + t.Cleanup(redisCleanup(t, redisClient, prefix)) + + c := NewRedisFrameCache(redisClient, prefix) require.NotNil(t, c) testFrameCache(t, c) + + keys, err := redisClient.Keys(redisClient.Context(), "*").Result() + if err != nil { + require.NoError(t, err) + } + + require.NotZero(t, len(keys)) + + for _, key := range keys { + require.True(t, strings.HasPrefix(key, prefix)) + } +} + +func redisCleanup(t *testing.T, redisClient *redis.Client, prefix string) func() { + return func() { + keys, err := redisClient.Keys(redisClient.Context(), prefix+"*").Result() + if err != nil { + require.NoError(t, err) + } + + for _, key := range keys { + _, err := redisClient.Del(redisClient.Context(), key).Result() + require.NoError(t, err) + } + } } diff --git a/pkg/services/navtree/navtreeimpl/navtree.go b/pkg/services/navtree/navtreeimpl/navtree.go index 6bbc3277383..d33e93f763b 100644 --- a/pkg/services/navtree/navtreeimpl/navtree.go +++ b/pkg/services/navtree/navtreeimpl/navtree.go @@ -202,6 +202,18 @@ func (s *ServiceImpl) getHomeNode(c *contextmodel.ReqContext, prefs *pref.Prefer Icon: "home-alt", SortWeight: navtree.WeightHome, } + ctx := c.Req.Context() + if s.features.IsEnabled(ctx, featuremgmt.FlagHomeSetupGuide) { + var children []*navtree.NavLink + // setup guide (a submenu item under Home) + children = append(children, &navtree.NavLink{ + Id: "home-setup-guide", + Text: "Setup guide", + Url: homeUrl + "/setup-guide", + SortWeight: navtree.WeightHome, + }) + homeNode.Children = children + } return homeNode } diff --git a/pkg/services/ngalert/accesscontrol.go b/pkg/services/ngalert/accesscontrol.go index 53c4ba79c5f..1aa3eb8f4c5 100644 --- a/pkg/services/ngalert/accesscontrol.go +++ b/pkg/services/ngalert/accesscontrol.go @@ -153,13 +153,38 @@ var ( }, } + templatesReaderRole = accesscontrol.RoleRegistration{ + Role: accesscontrol.RoleDTO{ + Name: accesscontrol.FixedRolePrefix + "alerting.templates:reader", + DisplayName: "Templates Reader", + Description: "Read all templates in Grafana alerting", + Group: AlertRolesGroup, + Permissions: []accesscontrol.Permission{ + {Action: accesscontrol.ActionAlertingNotificationsTemplatesRead}, + }, + }, + } + + templatesWriterRole = accesscontrol.RoleRegistration{ + Role: accesscontrol.RoleDTO{ + Name: accesscontrol.FixedRolePrefix + "alerting.templates:writer", + DisplayName: "Templates Writer", + Description: "Create, update, and delete all templates in Grafana alerting", + Group: AlertRolesGroup, + Permissions: accesscontrol.ConcatPermissions(templatesReaderRole.Role.Permissions, []accesscontrol.Permission{ + {Action: accesscontrol.ActionAlertingNotificationsTemplatesWrite}, + {Action: accesscontrol.ActionAlertingNotificationsTemplatesDelete}, + }), + }, + } + notificationsReaderRole = accesscontrol.RoleRegistration{ Role: accesscontrol.RoleDTO{ Name: accesscontrol.FixedRolePrefix + "alerting.notifications:reader", DisplayName: "Notifications Reader", Description: "Read notification policies and contact points in Grafana and external providers", Group: AlertRolesGroup, - Permissions: accesscontrol.ConcatPermissions(receiversReaderRole.Role.Permissions, []accesscontrol.Permission{ + Permissions: accesscontrol.ConcatPermissions(receiversReaderRole.Role.Permissions, templatesReaderRole.Role.Permissions, []accesscontrol.Permission{ { Action: accesscontrol.ActionAlertingNotificationsRead, }, @@ -180,7 +205,7 @@ var ( DisplayName: "Notifications Writer", Description: "Add, update, and delete contact points and notification policies in Grafana and external providers", Group: AlertRolesGroup, - Permissions: accesscontrol.ConcatPermissions(notificationsReaderRole.Role.Permissions, receiversWriterRole.Role.Permissions, []accesscontrol.Permission{ + Permissions: accesscontrol.ConcatPermissions(notificationsReaderRole.Role.Permissions, receiversWriterRole.Role.Permissions, templatesWriterRole.Role.Permissions, []accesscontrol.Permission{ { Action: accesscontrol.ActionAlertingNotificationsWrite, }, @@ -312,7 +337,7 @@ func DeclareFixedRoles(service accesscontrol.Service, features featuremgmt.Featu } if features.IsEnabledGlobally(featuremgmt.FlagAlertingApiServer) { - fixedRoles = append(fixedRoles, receiversReaderRole, receiversCreatorRole, receiversWriterRole) + fixedRoles = append(fixedRoles, receiversReaderRole, receiversCreatorRole, receiversWriterRole, templatesReaderRole, templatesWriterRole) } return service.DeclareFixedRoles(fixedRoles...) diff --git a/pkg/services/ngalert/api/api_alertmanager_test.go b/pkg/services/ngalert/api/api_alertmanager_test.go index ed064df3ff5..1787dcfa5e9 100644 --- a/pkg/services/ngalert/api/api_alertmanager_test.go +++ b/pkg/services/ngalert/api/api_alertmanager_test.go @@ -609,7 +609,20 @@ func createMultiOrgAlertmanager(t *testing.T, configs map[int64]*ngmodels.AlertC }, // do not poll in tests. } - mam, err := notifier.NewMultiOrgAlertmanager(cfg, configStore, orgStore, kvStore, provStore, decryptFn, m.GetMultiOrgAlertmanagerMetrics(), nil, log.New("testlogger"), secretsService, featuremgmt.WithManager(featuremgmt.FlagAlertingSimplifiedRouting)) + mam, err := notifier.NewMultiOrgAlertmanager( + cfg, + configStore, + orgStore, + kvStore, + provStore, + decryptFn, + m.GetMultiOrgAlertmanagerMetrics(), + nil, + ngfakes.NewFakeReceiverPermissionsService(), + log.New("testlogger"), + secretsService, + featuremgmt.WithManager(featuremgmt.FlagAlertingSimplifiedRouting), + ) require.NoError(t, err) err = mam.LoadAndSyncAlertmanagersForOrgs(context.Background()) require.NoError(t, err) diff --git a/pkg/services/ngalert/api/api_provisioning_test.go b/pkg/services/ngalert/api/api_provisioning_test.go index 785d433769f..32fa6c7d460 100644 --- a/pkg/services/ngalert/api/api_provisioning_test.go +++ b/pkg/services/ngalert/api/api_provisioning_test.go @@ -1793,8 +1793,7 @@ func createTestEnv(t *testing.T, testConfig string) testEnvironment { GetsConfig(models.AlertConfiguration{ AlertmanagerConfiguration: string(raw), }) - replDB, cfg := db.InitTestReplDBWithCfg(t) - sqlStore := replDB.DB() + sqlStore, cfg := db.InitTestDBWithCfg(t) quotas := &provisioning.MockQuotaChecker{} quotas.EXPECT().LimitOK() @@ -1818,7 +1817,7 @@ func createTestEnv(t *testing.T, testConfig string) testEnvironment { }}, nil).Maybe() ac := &recordingAccessControlFake{} - dashboardStore, err := database.ProvideDashboardStore(replDB, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotatest.New(false, nil)) + dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotatest.New(false, nil)) require.NoError(t, err) folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) diff --git a/pkg/services/ngalert/models/alert_rule.go b/pkg/services/ngalert/models/alert_rule.go index fe9be233d03..bec7137c4b0 100644 --- a/pkg/services/ngalert/models/alert_rule.go +++ b/pkg/services/ngalert/models/alert_rule.go @@ -795,6 +795,13 @@ func PatchPartialAlertRule(existingRule *AlertRule, ruleToPatch *AlertRuleWithOp if !ruleToPatch.HasPause { ruleToPatch.IsPaused = existingRule.IsPaused } + + // Currently metadata contains only editor settings, so we can just copy it. + // If we add more fields to metadata, we might need to handle them separately, + // and/or merge or update their values. + if ruleToPatch.Metadata == (AlertRuleMetadata{}) { + ruleToPatch.Metadata = existingRule.Metadata + } } func ValidateRuleGroupInterval(intervalSeconds, baseIntervalSeconds int64) error { diff --git a/pkg/services/ngalert/models/alert_rule_test.go b/pkg/services/ngalert/models/alert_rule_test.go index 3cfbb07a392..652e0c23d41 100644 --- a/pkg/services/ngalert/models/alert_rule_test.go +++ b/pkg/services/ngalert/models/alert_rule_test.go @@ -253,10 +253,17 @@ func TestPatchPartialAlertRule(t *testing.T) { r.IsPaused = true }, }, + { + name: "No metadata", + mutator: func(r *AlertRuleWithOptionals) { + r.Metadata = AlertRuleMetadata{} + }, + }, } gen := RuleGen.With( - RuleMuts.WithFor(time.Duration(rand.Int63n(1000) + 1)), + RuleMuts.WithFor(time.Duration(rand.Int63n(1000)+1)), + RuleMuts.WithEditorSettingsSimplifiedQueryAndExpressionsSection(true), ) for _, testCase := range testCases { diff --git a/pkg/services/ngalert/ngalert.go b/pkg/services/ngalert/ngalert.go index 74ca6331bcd..b2628d6ae08 100644 --- a/pkg/services/ngalert/ngalert.go +++ b/pkg/services/ngalert/ngalert.go @@ -311,7 +311,21 @@ func (ng *AlertNG) init() error { decryptFn := ng.SecretsService.GetDecryptedValue multiOrgMetrics := ng.Metrics.GetMultiOrgAlertmanagerMetrics() - moa, err := notifier.NewMultiOrgAlertmanager(ng.Cfg, ng.store, ng.store, ng.KVStore, ng.store, decryptFn, multiOrgMetrics, ng.NotificationService, moaLogger, ng.SecretsService, ng.FeatureToggles, overrides...) + moa, err := notifier.NewMultiOrgAlertmanager( + ng.Cfg, + ng.store, + ng.store, + ng.KVStore, + ng.store, + decryptFn, + multiOrgMetrics, + ng.NotificationService, + ng.ResourcePermissions, + moaLogger, + ng.SecretsService, + ng.FeatureToggles, + overrides..., + ) if err != nil { return err } diff --git a/pkg/services/ngalert/notifier/alertmanager_config.go b/pkg/services/ngalert/notifier/alertmanager_config.go index 7d71de2c343..c2248fe91c6 100644 --- a/pkg/services/ngalert/notifier/alertmanager_config.go +++ b/pkg/services/ngalert/notifier/alertmanager_config.go @@ -2,16 +2,19 @@ package notifier import ( "context" + "encoding/json" "errors" "fmt" "time" "github.com/go-openapi/strfmt" + "k8s.io/apimachinery/pkg/util/sets" "github.com/grafana/grafana/pkg/apimachinery/errutil" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/notifier/legacy_storage" "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/util" ) @@ -58,10 +61,32 @@ func (moa *MultiOrgAlertmanager) SaveAndApplyDefaultConfig(ctx context.Context, return err } + previousConfig, cleanPermissionsErr := moa.configStore.GetLatestAlertmanagerConfiguration(ctx, orgId) + err = orgAM.SaveAndApplyDefaultConfig(ctx) if err != nil { return err } + + // Attempt to cleanup permissions for receivers that are no longer defined and add defaults for new receivers. + // Failure should not prevent the default config from being applied. + if cleanPermissionsErr == nil { + cleanPermissionsErr = func() error { + defaultedConfig, err := moa.configStore.GetLatestAlertmanagerConfiguration(ctx, orgId) + if err != nil { + return err + } + newReceiverNames, err := extractReceiverNames(defaultedConfig.AlertmanagerConfiguration) + if err != nil { + return err + } + return moa.cleanPermissions(ctx, orgId, previousConfig, newReceiverNames) + }() + } + if cleanPermissionsErr != nil { + moa.logger.Error("Failed to clean permissions for receivers", "error", cleanPermissionsErr) + } + return nil } @@ -130,12 +155,29 @@ func (moa *MultiOrgAlertmanager) ActivateHistoricalConfiguration(ctx context.Con } } + previousConfig, cleanPermissionsErr := moa.configStore.GetLatestAlertmanagerConfiguration(ctx, orgId) + if err := am.SaveAndApplyConfig(ctx, cfg); err != nil { moa.logger.Error("Unable to save and apply historical alertmanager configuration", "error", err, "org", orgId, "id", id) return AlertmanagerConfigRejectedError{err} } moa.logger.Info("Applied historical alertmanager configuration", "org", orgId, "id", id) + // Attempt to cleanup permissions for receivers that are no longer defined and add defaults for new receivers. + // Failure should not prevent the default config from being applied. + if cleanPermissionsErr == nil { + cleanPermissionsErr = func() error { + newReceiverNames, err := extractReceiverNames(config.AlertmanagerConfiguration) + if err != nil { + return err + } + return moa.cleanPermissions(ctx, orgId, previousConfig, newReceiverNames) + }() + } + if cleanPermissionsErr != nil { + moa.logger.Error("Failed to clean permissions for receivers", "error", cleanPermissionsErr) + } + return nil } @@ -231,13 +273,14 @@ func (moa *MultiOrgAlertmanager) SaveAndApplyAlertmanagerConfiguration(ctx conte } // Get the last known working configuration - _, err := moa.configStore.GetLatestAlertmanagerConfiguration(ctx, org) + previousConfig, err := moa.configStore.GetLatestAlertmanagerConfiguration(ctx, org) if err != nil { // If we don't have a configuration there's nothing for us to know and we should just continue saving the new one if !errors.Is(err, store.ErrNoAlertmanagerConfiguration) { return fmt.Errorf("failed to get latest configuration %w", err) } } + cleanPermissionsErr := err if err := moa.Crypto.ProcessSecureSettings(ctx, org, config.AlertmanagerConfig.Receivers); err != nil { return fmt.Errorf("failed to post process Alertmanager configuration: %w", err) @@ -268,6 +311,21 @@ func (moa *MultiOrgAlertmanager) SaveAndApplyAlertmanagerConfiguration(ctx conte return AlertmanagerConfigRejectedError{err} } + // Attempt to cleanup permissions for receivers that are no longer defined and add defaults for new receivers. + // Failure should not prevent the default config from being applied. + if cleanPermissionsErr == nil { + cleanPermissionsErr = func() error { + newReceiverNames := make(sets.Set[string], len(config.AlertmanagerConfig.Receivers)) + for _, r := range config.AlertmanagerConfig.Receivers { + newReceiverNames.Insert(r.Name) + } + return moa.cleanPermissions(ctx, org, previousConfig, newReceiverNames) + }() + } + if cleanPermissionsErr != nil { + moa.logger.Error("Failed to clean permissions for receivers", "error", cleanPermissionsErr) + } + return nil } @@ -352,3 +410,51 @@ func (moa *MultiOrgAlertmanager) mergeProvenance(ctx context.Context, config def return config, nil } + +// cleanPermissions will remove permissions for receivers that are no longer defined in the new configuration and +// set default permissions for new receivers. +func (moa *MultiOrgAlertmanager) cleanPermissions(ctx context.Context, orgID int64, previousConfig *models.AlertConfiguration, newReceiverNames sets.Set[string]) error { + previousReceiverNames, err := extractReceiverNames(previousConfig.AlertmanagerConfiguration) + if err != nil { + return fmt.Errorf("failed to extract receiver names from previous configuration: %w", err) + } + + var errs []error + for receiverName := range previousReceiverNames.Difference(newReceiverNames) { // Deleted receivers. + if err := moa.receiverResourcePermissions.DeleteResourcePermissions(ctx, orgID, legacy_storage.NameToUid(receiverName)); err != nil { + errs = append(errs, fmt.Errorf("failed to delete permissions for receiver %s: %w", receiverName, err)) + } + } + + for receiverName := range newReceiverNames.Difference(previousReceiverNames) { // Added receivers. + moa.receiverResourcePermissions.SetDefaultPermissions(ctx, orgID, nil, legacy_storage.NameToUid(receiverName)) + } + + return errors.Join(errs...) +} + +// extractReceiverNames extracts receiver names from the raw Alertmanager configuration. Unmarshalling ignores fields +// unrelated to receiver names, making it more resilient to invalid configurations. +func extractReceiverNames(rawConfig string) (sets.Set[string], error) { + // Slimmed down version of the Alertmanager configuration to extract receiver names. This is more resilient to + // invalid configurations when all we are interested in is the receiver names. + type receiverUserConfig struct { + AlertmanagerConfig struct { + Receivers []struct { + Name string `yaml:"name" json:"name"` + } `yaml:"receivers,omitempty" json:"receivers,omitempty"` + } `yaml:"alertmanager_config" json:"alertmanager_config"` + } + + cfg := &receiverUserConfig{} + if err := json.Unmarshal([]byte(rawConfig), cfg); err != nil { + return nil, fmt.Errorf("unable to parse Alertmanager configuration: %w", err) + } + + receiverNames := make(sets.Set[string], len(cfg.AlertmanagerConfig.Receivers)) + for _, r := range cfg.AlertmanagerConfig.Receivers { + receiverNames[r.Name] = struct{}{} + } + + return receiverNames, nil +} diff --git a/pkg/services/ngalert/notifier/multiorg_alertmanager.go b/pkg/services/ngalert/notifier/multiorg_alertmanager.go index 464c1036296..71140200558 100644 --- a/pkg/services/ngalert/notifier/multiorg_alertmanager.go +++ b/pkg/services/ngalert/notifier/multiorg_alertmanager.go @@ -12,6 +12,7 @@ import ( alertingCluster "github.com/grafana/alerting/cluster" "github.com/grafana/grafana/pkg/apimachinery/errutil" + ac "github.com/grafana/grafana/pkg/services/accesscontrol" alertingNotify "github.com/grafana/alerting/notify" @@ -100,6 +101,8 @@ type MultiOrgAlertmanager struct { metrics *metrics.MultiOrgAlertmanager ns notifications.Service + + receiverResourcePermissions ac.ReceiverPermissionsService } type OrgAlertmanagerFactory func(ctx context.Context, orgID int64) (Alertmanager, error) @@ -121,6 +124,7 @@ func NewMultiOrgAlertmanager( decryptFn alertingNotify.GetDecryptedValueFn, m *metrics.MultiOrgAlertmanager, ns notifications.Service, + receiverResourcePermissions ac.ReceiverPermissionsService, l log.Logger, s secrets.Service, featureManager featuremgmt.FeatureToggles, @@ -130,17 +134,18 @@ func NewMultiOrgAlertmanager( Crypto: NewCrypto(s, configStore, l), ProvStore: provStore, - logger: l, - settings: cfg, - featureManager: featureManager, - alertmanagers: map[int64]Alertmanager{}, - configStore: configStore, - orgStore: orgStore, - kvStore: kvStore, - decryptFn: decryptFn, - metrics: m, - ns: ns, - peer: &NilPeer{}, + logger: l, + settings: cfg, + featureManager: featureManager, + alertmanagers: map[int64]Alertmanager{}, + configStore: configStore, + orgStore: orgStore, + kvStore: kvStore, + decryptFn: decryptFn, + receiverResourcePermissions: receiverResourcePermissions, + metrics: m, + ns: ns, + peer: &NilPeer{}, } if cfg.UnifiedAlerting.SkipClustering { diff --git a/pkg/services/ngalert/notifier/multiorg_alertmanager_remote_test.go b/pkg/services/ngalert/notifier/multiorg_alertmanager_remote_test.go index 81573c42061..5301b37bf9a 100644 --- a/pkg/services/ngalert/notifier/multiorg_alertmanager_remote_test.go +++ b/pkg/services/ngalert/notifier/multiorg_alertmanager_remote_test.go @@ -98,6 +98,7 @@ func TestMultiorgAlertmanager_RemoteSecondaryMode(t *testing.T) { secretsService.GetDecryptedValue, m.GetMultiOrgAlertmanagerMetrics(), nil, + ngfakes.NewFakeReceiverPermissionsService(), nopLogger, secretsService, featuremgmt.WithFeatures(), diff --git a/pkg/services/ngalert/notifier/multiorg_alertmanager_test.go b/pkg/services/ngalert/notifier/multiorg_alertmanager_test.go index a2fb2647ace..e53da1fa451 100644 --- a/pkg/services/ngalert/notifier/multiorg_alertmanager_test.go +++ b/pkg/services/ngalert/notifier/multiorg_alertmanager_test.go @@ -383,7 +383,20 @@ func setupMam(t *testing.T, cfg *setting.Cfg) *MultiOrgAlertmanager { decryptFn := secretsService.GetDecryptedValue reg := prometheus.NewPedanticRegistry() m := metrics.NewNGAlert(reg) - mam, err := NewMultiOrgAlertmanager(cfg, cs, orgStore, kvStore, provStore, decryptFn, m.GetMultiOrgAlertmanagerMetrics(), nil, log.New("testlogger"), secretsService, featuremgmt.WithFeatures()) + mam, err := NewMultiOrgAlertmanager( + cfg, + cs, + orgStore, + kvStore, + provStore, + decryptFn, + m.GetMultiOrgAlertmanagerMetrics(), + nil, + ngfakes.NewFakeReceiverPermissionsService(), + log.New("testlogger"), + secretsService, + featuremgmt.WithFeatures(), + ) require.NoError(t, err) return mam } diff --git a/pkg/services/ngalert/provisioning/alert_rules.go b/pkg/services/ngalert/provisioning/alert_rules.go index 47a481b6efa..ee56a6e134b 100644 --- a/pkg/services/ngalert/provisioning/alert_rules.go +++ b/pkg/services/ngalert/provisioning/alert_rules.go @@ -598,6 +598,14 @@ func (service *AlertRuleService) UpdateAlertRule(ctx context.Context, user ident rule.Updated = time.Now() rule.ID = storedRule.ID rule.IntervalSeconds = storedRule.IntervalSeconds + + // Currently metadata contains only editor settings, so we can just copy it. + // If we add more fields to metadata, we might need to handle them separately, + // and/or merge or update their values. + if rule.Metadata == (models.AlertRuleMetadata{}) { + rule.Metadata = storedRule.Metadata + } + err = rule.SetDashboardAndPanelFromAnnotations() if err != nil { return models.AlertRule{}, err diff --git a/pkg/services/ngalert/provisioning/alert_rules_test.go b/pkg/services/ngalert/provisioning/alert_rules_test.go index 5728b976230..e5e819ef45f 100644 --- a/pkg/services/ngalert/provisioning/alert_rules_test.go +++ b/pkg/services/ngalert/provisioning/alert_rules_test.go @@ -16,6 +16,12 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/expr" + "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol" + "github.com/grafana/grafana/pkg/services/ngalert/tests/fakes" + "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/util" + "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" @@ -25,15 +31,10 @@ import ( "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/folder/folderimpl" "github.com/grafana/grafana/pkg/services/folder/foldertest" - "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/store" - "github.com/grafana/grafana/pkg/services/ngalert/tests/fakes" "github.com/grafana/grafana/pkg/services/ngalert/testutil" - "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" - "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/util" ) func TestAlertRuleService(t *testing.T) { @@ -183,6 +184,74 @@ func TestAlertRuleService(t *testing.T) { require.Equal(t, int64(2), readGroup.Rules[0].Version) }) + t.Run("updating a group should not override its rules editor settings", func(t *testing.T) { + namespaceUID := "my-namespace" + groupTitle := "test-group-123" + + // create the rule group via the rule store, to persist the editor settings + rule := createTestRule(util.GenerateShortUID(), groupTitle, orgID, namespaceUID) + ruleMetadata := models.AlertRuleMetadata{ + EditorSettings: models.EditorSettings{ + SimplifiedQueryAndExpressionsSection: true, + }, + } + rule.Metadata = ruleMetadata + r, err := ruleService.ruleStore.InsertAlertRules(context.Background(), []models.AlertRule{rule}) + require.NoError(t, err) + require.Len(t, r, 1) + + // Set the UID for the rule to update it + rule.UID = r[0].UID + // clear the metadata to check that the existing metadata is not overridden + rule.Metadata = models.AlertRuleMetadata{} + + // Now update the rule group with the rule to update its metadata + group := models.AlertRuleGroup{ + Title: groupTitle, + Interval: 60, + FolderUID: namespaceUID, + Rules: []models.AlertRule{rule}, + } + + err = ruleService.ReplaceRuleGroup(context.Background(), u, group, models.ProvenanceAPI) + require.NoError(t, err) + + readGroup, err := ruleService.GetRuleGroup(context.Background(), u, namespaceUID, groupTitle) + require.NoError(t, err) + require.NotEmpty(t, readGroup.Rules) + require.Len(t, readGroup.Rules, 1) + + // check that the metadata is still there + require.Equal(t, ruleMetadata, readGroup.Rules[0].Metadata) + }) + + t.Run("updating a rule should not override its editor settings", func(t *testing.T) { + rule := createTestRule(util.GenerateShortUID(), "my-group", orgID, "my-folder") + ruleMetadata := models.AlertRuleMetadata{ + EditorSettings: models.EditorSettings{ + SimplifiedQueryAndExpressionsSection: true, + }, + } + rule.Metadata = ruleMetadata + r, err := ruleService.ruleStore.InsertAlertRules(context.Background(), []models.AlertRule{rule}) + require.NoError(t, err) + require.Len(t, r, 1) + + // Set the UID for the rule to update it + rule.UID = r[0].UID + // clear the metadata to check that the existing metadata is not overridden + rule.Metadata = models.AlertRuleMetadata{} + + // Update the rule + _, err = ruleService.UpdateAlertRule(context.Background(), u, rule, models.ProvenanceAPI) + require.NoError(t, err) + + // Read the rule and check that the editor settings are preserved + readRule, _, err := ruleService.GetAlertRule(context.Background(), u, rule.UID) + require.NoError(t, err) + require.Equal(t, ruleMetadata, readRule.Metadata) + }) + t.Run("updating a group to temporarily overlap rule names should not throw unique constraint", func(t *testing.T) { var orgID int64 = 1 group := models.AlertRuleGroup{ @@ -1473,7 +1542,7 @@ func TestDeleteRuleGroup(t *testing.T) { func TestProvisiongWithFullpath(t *testing.T) { tracer := tracing.InitializeTracerForTest() inProcBus := bus.ProvideBus(tracer) - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) _, dashboardStore := testutil.SetupDashboardService(t, sqlStore, folderStore, cfg) diff --git a/pkg/services/ngalert/sender/router_test.go b/pkg/services/ngalert/sender/router_test.go index c10f88e6001..25f0c7566aa 100644 --- a/pkg/services/ngalert/sender/router_test.go +++ b/pkg/services/ngalert/sender/router_test.go @@ -491,7 +491,20 @@ func createMultiOrgAlertmanager(t *testing.T, orgs []int64) *notifier.MultiOrgAl m := metrics.NewNGAlert(registry) secretsService := secretsManager.SetupTestService(t, fake_secrets.NewFakeSecretsStore()) decryptFn := secretsService.GetDecryptedValue - moa, err := notifier.NewMultiOrgAlertmanager(cfg, cfgStore, orgStore, kvStore, fakes.NewFakeProvisioningStore(), decryptFn, m.GetMultiOrgAlertmanagerMetrics(), nil, log.New("testlogger"), secretsService, featuremgmt.WithFeatures()) + moa, err := notifier.NewMultiOrgAlertmanager( + cfg, + cfgStore, + orgStore, + kvStore, + fakes.NewFakeProvisioningStore(), + decryptFn, + m.GetMultiOrgAlertmanagerMetrics(), + nil, + fakes.NewFakeReceiverPermissionsService(), + log.New("testlogger"), + secretsService, + featuremgmt.WithFeatures(), + ) require.NoError(t, err) require.NoError(t, moa.LoadAndSyncAlertmanagersForOrgs(context.Background())) require.Eventually(t, func() bool { diff --git a/pkg/services/ngalert/store/alert_rule_test.go b/pkg/services/ngalert/store/alert_rule_test.go index 8300f0a213f..2969f24d56b 100644 --- a/pkg/services/ngalert/store/alert_rule_test.go +++ b/pkg/services/ngalert/store/alert_rule_test.go @@ -16,21 +16,22 @@ import ( "golang.org/x/exp/rand" "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/log/logtest" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/actest" - acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/folder/folderimpl" - "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/testutil" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/user" + + "github.com/grafana/grafana/pkg/infra/db" + acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" + "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) @@ -41,7 +42,7 @@ func TestIntegrationUpdateAlertRules(t *testing.T) { } cfg := setting.NewCfg() cfg.UnifiedAlerting = setting.UnifiedAlertingSettings{BaseInterval: time.Duration(rand.Int63n(100)+1) * time.Second} - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) store := &DBstore{ SQLStore: sqlStore, Cfg: cfg.UnifiedAlerting, @@ -120,7 +121,7 @@ func TestIntegrationUpdateAlertRulesWithUniqueConstraintViolation(t *testing.T) } cfg := setting.NewCfg() cfg.UnifiedAlerting = setting.UnifiedAlertingSettings{BaseInterval: time.Duration(rand.Int63n(100)+1) * time.Second} - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) store := &DBstore{ SQLStore: sqlStore, Cfg: cfg.UnifiedAlerting, @@ -377,7 +378,7 @@ func TestIntegration_GetAlertRulesForScheduling(t *testing.T) { BaseInterval: time.Duration(rand.Int63n(100)) * time.Second, } - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) store := &DBstore{ Logger: &logtest.Fake{}, SQLStore: sqlStore, @@ -501,7 +502,7 @@ func TestIntegration_CountAlertRules(t *testing.T) { t.Skip("skipping integration test") } - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() store := &DBstore{SQLStore: sqlStore, FolderService: setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures())} @@ -566,7 +567,7 @@ func TestIntegration_DeleteInFolder(t *testing.T) { t.Skip("skipping integration test") } - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() store := &DBstore{ SQLStore: sqlStore, @@ -599,7 +600,7 @@ func TestIntegration_GetNamespaceByUID(t *testing.T) { t.Skip("skipping integration test") } - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() store := &DBstore{ SQLStore: sqlStore, @@ -653,7 +654,7 @@ func TestIntegrationInsertAlertRules(t *testing.T) { } orgID := int64(1) - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() cfg.UnifiedAlerting.BaseInterval = 1 * time.Second store := &DBstore{ @@ -804,7 +805,7 @@ func TestIntegrationAlertRulesNotificationSettings(t *testing.T) { return result } - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() cfg.UnifiedAlerting.BaseInterval = 1 * time.Second store := &DBstore{ @@ -1080,7 +1081,7 @@ func TestIntegrationListNotificationSettings(t *testing.T) { t.Skip("skipping integration test") } - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() cfg.UnifiedAlerting.BaseInterval = 1 * time.Second store := &DBstore{ @@ -1203,7 +1204,7 @@ func TestIntegrationGetNamespacesByRuleUID(t *testing.T) { t.Skip("skipping integration test") } - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() cfg.UnifiedAlerting.BaseInterval = 1 * time.Second store := &DBstore{ @@ -1254,7 +1255,7 @@ func TestIntegrationRuleGroupsCaseSensitive(t *testing.T) { t.Skip("skipping integration test") } - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) cfg := setting.NewCfg() cfg.UnifiedAlerting.BaseInterval = 1 * time.Second store := &DBstore{ @@ -1363,7 +1364,7 @@ func TestIncreaseVersionForAllRulesInNamespaces(t *testing.T) { } cfg := setting.NewCfg() cfg.UnifiedAlerting = setting.UnifiedAlertingSettings{BaseInterval: time.Duration(rand.Int63n(100)+1) * time.Second} - sqlStore := db.InitTestReplDB(t) + sqlStore := db.InitTestDB(t) store := &DBstore{ SQLStore: sqlStore, Cfg: cfg.UnifiedAlerting, @@ -1463,11 +1464,11 @@ func createFolder(t *testing.T, store *DBstore, uid, title string, orgID int64, require.NoError(t, err) } -func setupFolderService(t *testing.T, sqlStore db.ReplDB, cfg *setting.Cfg, features featuremgmt.FeatureToggles) folder.Service { +func setupFolderService(t *testing.T, sqlStore db.DB, cfg *setting.Cfg, features featuremgmt.FeatureToggles) folder.Service { tracer := tracing.InitializeTracerForTest() inProcBus := bus.ProvideBus(tracer) - folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore.DB()) + folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) _, dashboardStore := testutil.SetupDashboardService(t, sqlStore, folderStore, cfg) - return testutil.SetupFolderService(t, cfg, sqlStore.DB(), dashboardStore, folderStore, inProcBus, features, &actest.FakeAccessControl{ExpectedEvaluate: true}) + return testutil.SetupFolderService(t, cfg, sqlStore, dashboardStore, folderStore, inProcBus, features, &actest.FakeAccessControl{ExpectedEvaluate: true}) } diff --git a/pkg/services/ngalert/store/testing.go b/pkg/services/ngalert/store/testing.go index 9efa3804f80..39f6f4ffb09 100644 --- a/pkg/services/ngalert/store/testing.go +++ b/pkg/services/ngalert/store/testing.go @@ -7,10 +7,10 @@ import ( "testing" "time" + "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log/logtest" "github.com/grafana/grafana/pkg/services/folder/foldertest" "github.com/grafana/grafana/pkg/services/ngalert/models" - "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" ) @@ -134,7 +134,7 @@ func (f *FakeAdminConfigStore) UpdateAdminConfiguration(cmd UpdateAdminConfigura return nil } -func SetupStoreForTesting(t *testing.T, db *sqlstore.ReplStore) *DBstore { +func SetupStoreForTesting(t *testing.T, db db.DB) *DBstore { t.Helper() cfg := setting.NewCfg() cfg.UnifiedAlerting = setting.UnifiedAlertingSettings{BaseInterval: 1 * time.Second} diff --git a/pkg/services/ngalert/tests/util.go b/pkg/services/ngalert/tests/util.go index 4b635292ba5..ae5664adeec 100644 --- a/pkg/services/ngalert/tests/util.go +++ b/pkg/services/ngalert/tests/util.go @@ -54,8 +54,7 @@ func SetupTestEnv(tb testing.TB, baseInterval time.Duration) (*ngalert.AlertNG, *cfg.UnifiedAlerting.Enabled = true m := metrics.NewNGAlert(prometheus.NewRegistry()) - replStore := db.InitTestReplDB(tb) - sqlStore := replStore.DB() + sqlStore := db.InitTestDB(tb) secretsService := secretsManager.SetupTestService(tb, database.ProvideSecretsStore(sqlStore)) ac := acmock.New() @@ -63,7 +62,7 @@ func SetupTestEnv(tb testing.TB, baseInterval time.Duration) (*ngalert.AlertNG, tracer := tracing.InitializeTracerForTest() bus := bus.ProvideBus(tracer) folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) - dashboardService, dashboardStore := testutil.SetupDashboardService(tb, replStore, folderStore, cfg) + dashboardService, dashboardStore := testutil.SetupDashboardService(tb, sqlStore, folderStore, cfg) features := featuremgmt.WithFeatures() folderService := testutil.SetupFolderService(tb, cfg, sqlStore, dashboardStore, folderStore, bus, features, ac) ruleStore, err := store.ProvideDBStore(cfg, featuremgmt.WithFeatures(), sqlStore, folderService, &dashboards.FakeDashboardService{}, ac) diff --git a/pkg/services/ngalert/testutil/testutil.go b/pkg/services/ngalert/testutil/testutil.go index a1986de8734..6311a9ffdbb 100644 --- a/pkg/services/ngalert/testutil/testutil.go +++ b/pkg/services/ngalert/testutil/testutil.go @@ -30,7 +30,7 @@ func SetupFolderService(tb testing.TB, cfg *setting.Cfg, db db.DB, dashboardStor return folderimpl.ProvideService(ac, bus, dashboardStore, folderStore, db, features, supportbundlestest.NewFakeBundleService(), nil, tracing.InitializeTracerForTest()) } -func SetupDashboardService(tb testing.TB, sqlStore db.ReplDB, fs *folderimpl.DashboardFolderStoreImpl, cfg *setting.Cfg) (*dashboardservice.DashboardServiceImpl, dashboards.Store) { +func SetupDashboardService(tb testing.TB, sqlStore db.DB, fs *folderimpl.DashboardFolderStoreImpl, cfg *setting.Cfg) (*dashboardservice.DashboardServiceImpl, dashboards.Store) { tb.Helper() origNewGuardian := guardian.New @@ -51,7 +51,7 @@ func SetupDashboardService(tb testing.TB, sqlStore db.ReplDB, fs *folderimpl.Das features := featuremgmt.WithFeatures() quotaService := quotatest.New(false, nil) - dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, features, tagimpl.ProvideService(sqlStore), quotaService) require.NoError(tb, err) dashboardService, err := dashboardservice.ProvideDashboardServiceImpl( diff --git a/pkg/services/org/orgimpl/store_test.go b/pkg/services/org/orgimpl/store_test.go index 72288d14c26..a4f839d6842 100644 --- a/pkg/services/org/orgimpl/store_test.go +++ b/pkg/services/org/orgimpl/store_test.go @@ -906,7 +906,7 @@ func TestIntegration_SQLStore_RemoveOrgUser(t *testing.T) { func createOrgAndUserSvc(t *testing.T, store db.DB, cfg *setting.Cfg) (org.Service, user.Service) { t.Helper() - quotaService := quotaimpl.ProvideService(db.FakeReplDBFromDB(store), cfg) + quotaService := quotaimpl.ProvideService(store, cfg) orgService, err := ProvideService(store, cfg, quotaService) require.NoError(t, err) usrSvc, err := userimpl.ProvideService( diff --git a/pkg/services/provisioning/datasources/datasources.go b/pkg/services/provisioning/datasources/datasources.go index 1bbf5ff7820..55edd021d00 100644 --- a/pkg/services/provisioning/datasources/datasources.go +++ b/pkg/services/provisioning/datasources/datasources.go @@ -196,7 +196,7 @@ func makeCreateCorrelationCommand(correlation map[string]any, SourceUID string, // we ignore the legacy config.type value - the only valid value at that version was "query" var corrType = correlation["type"] if corrType == nil || corrType == "" { - corrType = correlations.TypeQuery + corrType = correlations.CorrelationType("query") } var json = jsoniter.ConfigCompatibleWithStandardLibrary @@ -229,6 +229,11 @@ func makeCreateCorrelationCommand(correlation map[string]any, SourceUID string, return correlations.CreateCorrelationCommand{}, err } + // config.type is a deprecated place for this value. We will default it to "query" for legacy purposes but non-query correlations should have type outside of config + if config.Type != correlations.CorrelationType("query") { + return correlations.CreateCorrelationCommand{}, correlations.ErrInvalidConfigType + } + createCommand.Config = config } if err := createCommand.Validate(); err != nil { diff --git a/pkg/services/publicdashboards/api/query_test.go b/pkg/services/publicdashboards/api/query_test.go index bb6a201c3c4..6146c36de2f 100644 --- a/pkg/services/publicdashboards/api/query_test.go +++ b/pkg/services/publicdashboards/api/query_test.go @@ -254,7 +254,7 @@ func TestIntegrationUnauthenticatedUserCanGetPubdashPanelQueryData(t *testing.T) if testing.Short() { t.Skip("skipping integration test") } - db, cfg := db.InitTestReplDBWithCfg(t) + db, cfg := db.InitTestDBWithCfg(t) cacheService := datasourcesService.ProvideCacheService(localcache.ProvideService(), db, guardian.ProvideGuardian()) qds := buildQueryDataService(t, cacheService, nil, db) diff --git a/pkg/services/publicdashboards/database/database_test.go b/pkg/services/publicdashboards/database/database_test.go index 2138e4ccd5f..d2e506063b1 100644 --- a/pkg/services/publicdashboards/database/database_test.go +++ b/pkg/services/publicdashboards/database/database_test.go @@ -48,7 +48,6 @@ func TestIntegrationListPublicDashboard(t *testing.T) { } var sqlStore db.DB - var replStore db.ReplDB var cfg *setting.Cfg var aDash *dashboards.Dashboard @@ -64,10 +63,9 @@ func TestIntegrationListPublicDashboard(t *testing.T) { var publicdashboardStore *PublicDashboardStoreImpl setup := func() { - replStore, cfg = db.InitTestReplDBWithCfg(t, db.InitTestDBOpt{}) - sqlStore = replStore.DB() + sqlStore, cfg = db.InitTestDBWithCfg(t, db.InitTestDBOpt{}) quotaService := quotatest.New(false, nil) - dashboardStore, err := dashboardsDB.ProvideDashboardStore(replStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) publicdashboardStore = ProvideStore(sqlStore, cfg, featuremgmt.WithFeatures()) @@ -173,19 +171,19 @@ func TestIntegrationExistsEnabledByAccessToken(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - var sqlStore db.ReplDB + var sqlStore db.DB var cfg *setting.Cfg var dashboardStore dashboards.Store var publicdashboardStore *PublicDashboardStoreImpl var savedDashboard *dashboards.Dashboard setup := func() { - sqlStore, cfg = db.InitTestReplDBWithCfg(t) + sqlStore, cfg = db.InitTestDBWithCfg(t) quotaService := quotatest.New(false, nil) - store, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotaService) + store, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) dashboardStore = store - publicdashboardStore = ProvideStore(sqlStore.DB(), cfg, featuremgmt.WithFeatures()) + publicdashboardStore = ProvideStore(sqlStore, cfg, featuremgmt.WithFeatures()) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, "", true) } t.Run("ExistsEnabledByAccessToken will return true when at least one public dashboard has a matching access token", func(t *testing.T) { @@ -246,19 +244,19 @@ func TestIntegrationExistsEnabledByDashboardUid(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - var sqlStore db.ReplDB + var sqlStore db.DB var cfg *setting.Cfg var dashboardStore dashboards.Store var publicdashboardStore *PublicDashboardStoreImpl var savedDashboard *dashboards.Dashboard setup := func() { - sqlStore, cfg = db.InitTestReplDBWithCfg(t) + sqlStore, cfg = db.InitTestDBWithCfg(t) quotaService := quotatest.New(false, nil) - store, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotaService) + store, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) dashboardStore = store - publicdashboardStore = ProvideStore(sqlStore.DB(), cfg, featuremgmt.WithFeatures()) + publicdashboardStore = ProvideStore(sqlStore, cfg, featuremgmt.WithFeatures()) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, "", true) } @@ -311,19 +309,19 @@ func TestIntegrationFindByDashboardUid(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - var sqlStore db.ReplDB + var sqlStore db.DB var cfg *setting.Cfg var dashboardStore dashboards.Store var publicdashboardStore *PublicDashboardStoreImpl var savedDashboard *dashboards.Dashboard setup := func() { - sqlStore, cfg = db.InitTestReplDBWithCfg(t) + sqlStore, cfg = db.InitTestDBWithCfg(t) quotaService := quotatest.New(false, nil) - store, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotaService) + store, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) dashboardStore = store - publicdashboardStore = ProvideStore(sqlStore.DB(), cfg, featuremgmt.WithFeatures()) + publicdashboardStore = ProvideStore(sqlStore, cfg, featuremgmt.WithFeatures()) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, "", true) } @@ -379,7 +377,7 @@ func TestIntegrationFindByAccessToken(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - var sqlStore db.ReplDB + var sqlStore db.DB var cfg *setting.Cfg var dashboardStore dashboards.Store var publicdashboardStore *PublicDashboardStoreImpl @@ -387,10 +385,10 @@ func TestIntegrationFindByAccessToken(t *testing.T) { var err error setup := func() { - sqlStore, cfg = db.InitTestReplDBWithCfg(t) - dashboardStore, err = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotatest.New(false, nil)) + sqlStore, cfg = db.InitTestDBWithCfg(t) + dashboardStore, err = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotatest.New(false, nil)) require.NoError(t, err) - publicdashboardStore = ProvideStore(sqlStore.DB(), cfg, featuremgmt.WithFeatures()) + publicdashboardStore = ProvideStore(sqlStore, cfg, featuremgmt.WithFeatures()) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, "", true) } @@ -447,7 +445,7 @@ func TestIntegrationCreatePublicDashboard(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - var sqlStore db.ReplDB + var sqlStore db.DB var cfg *setting.Cfg var dashboardStore dashboards.Store var publicdashboardStore *PublicDashboardStoreImpl @@ -455,12 +453,12 @@ func TestIntegrationCreatePublicDashboard(t *testing.T) { var savedDashboard2 *dashboards.Dashboard setup := func() { - sqlStore, cfg = db.InitTestReplDBWithCfg(t, db.InitTestDBOpt{}) + sqlStore, cfg = db.InitTestDBWithCfg(t, db.InitTestDBOpt{}) quotaService := quotatest.New(false, nil) - store, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotaService) + store, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) dashboardStore = store - publicdashboardStore = ProvideStore(sqlStore.DB(), cfg, featuremgmt.WithFeatures()) + publicdashboardStore = ProvideStore(sqlStore, cfg, featuremgmt.WithFeatures()) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, "", true) savedDashboard2 = insertTestDashboard(t, dashboardStore, "testDashie2", 1, "", true) insertPublicDashboard(t, publicdashboardStore, savedDashboard2.UID, savedDashboard2.OrgID, false, PublicShareType) @@ -526,7 +524,7 @@ func TestIntegrationUpdatePublicDashboard(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - var sqlStore db.ReplDB + var sqlStore db.DB var cfg *setting.Cfg var dashboardStore dashboards.Store var publicdashboardStore *PublicDashboardStoreImpl @@ -535,11 +533,11 @@ func TestIntegrationUpdatePublicDashboard(t *testing.T) { var err error setup := func() { - sqlStore, cfg = db.InitTestReplDBWithCfg(t, db.InitTestDBOpt{}) + sqlStore, cfg = db.InitTestDBWithCfg(t, db.InitTestDBOpt{}) quotaService := quotatest.New(false, nil) - dashboardStore, err = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) - publicdashboardStore = ProvideStore(sqlStore.DB(), cfg, featuremgmt.WithFeatures()) + publicdashboardStore = ProvideStore(sqlStore, cfg, featuremgmt.WithFeatures()) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, "", true) anotherSavedDashboard = insertTestDashboard(t, dashboardStore, "test another Dashie", 1, "", true) } @@ -631,7 +629,7 @@ func TestIntegrationGetOrgIdByAccessToken(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - var sqlStore db.ReplDB + var sqlStore db.DB var cfg *setting.Cfg var dashboardStore dashboards.Store var publicdashboardStore *PublicDashboardStoreImpl @@ -639,11 +637,11 @@ func TestIntegrationGetOrgIdByAccessToken(t *testing.T) { var err error setup := func() { - sqlStore, cfg = db.InitTestReplDBWithCfg(t) + sqlStore, cfg = db.InitTestDBWithCfg(t) quotaService := quotatest.New(false, nil) - dashboardStore, err = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) - publicdashboardStore = ProvideStore(sqlStore.DB(), cfg, featuremgmt.WithFeatures()) + publicdashboardStore = ProvideStore(sqlStore, cfg, featuremgmt.WithFeatures()) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, "", true) } t.Run("GetOrgIdByAccessToken will OrgId when enabled", func(t *testing.T) { @@ -703,7 +701,7 @@ func TestIntegrationDelete(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - var sqlStore db.ReplDB + var sqlStore db.DB var cfg *setting.Cfg var dashboardStore dashboards.Store var publicdashboardStore *PublicDashboardStoreImpl @@ -712,10 +710,10 @@ func TestIntegrationDelete(t *testing.T) { var err error setup := func() { - sqlStore, cfg = db.InitTestReplDBWithCfg(t) - dashboardStore, err = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotatest.New(false, nil)) + sqlStore, cfg = db.InitTestDBWithCfg(t) + dashboardStore, err = dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotatest.New(false, nil)) require.NoError(t, err) - publicdashboardStore = ProvideStore(sqlStore.DB(), cfg, featuremgmt.WithFeatures()) + publicdashboardStore = ProvideStore(sqlStore, cfg, featuremgmt.WithFeatures()) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, "", true) savedPublicDashboard = insertPublicDashboard(t, publicdashboardStore, savedDashboard.UID, savedDashboard.OrgID, true, PublicShareType) } @@ -763,7 +761,7 @@ func TestFindByFolder(t *testing.T) { }) t.Run("can get all pubdashes for dashboard folder and org", func(t *testing.T) { - sqlStore, cfg := db.InitTestReplDBWithCfg(t) + sqlStore, cfg := db.InitTestDBWithCfg(t) quotaService := quotatest.New(false, nil) dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) @@ -792,7 +790,7 @@ func TestGetMetrics(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - var sqlStore db.ReplDB + var sqlStore db.DB var cfg *setting.Cfg var dashboardStore dashboards.Store var publicdashboardStore *PublicDashboardStoreImpl @@ -802,12 +800,12 @@ func TestGetMetrics(t *testing.T) { var savedDashboard4 *dashboards.Dashboard setup := func() { - sqlStore, cfg = db.InitTestReplDBWithCfg(t, db.InitTestDBOpt{}) + sqlStore, cfg = db.InitTestDBWithCfg(t, db.InitTestDBOpt{}) quotaService := quotatest.New(false, nil) - store, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotaService) + store, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) dashboardStore = store - publicdashboardStore = ProvideStore(sqlStore.DB(), cfg, featuremgmt.WithFeatures()) + publicdashboardStore = ProvideStore(sqlStore, cfg, featuremgmt.WithFeatures()) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, "", false) savedDashboard2 = insertTestDashboard(t, dashboardStore, "testDashie2", 1, "", false) savedDashboard3 = insertTestDashboard(t, dashboardStore, "testDashie3", 2, "", false) diff --git a/pkg/services/publicdashboards/service/common_test.go b/pkg/services/publicdashboards/service/common_test.go index a81fbf32784..fd014362050 100644 --- a/pkg/services/publicdashboards/service/common_test.go +++ b/pkg/services/publicdashboards/service/common_test.go @@ -24,10 +24,10 @@ func newPublicDashboardServiceImpl( publicDashboardStore publicdashboards.Store, dashboardService dashboards.DashboardService, annotationsRepo annotations.Repository, -) (*PublicDashboardServiceImpl, db.ReplDB, *setting.Cfg) { +) (*PublicDashboardServiceImpl, db.DB, *setting.Cfg) { t.Helper() - db, cfg := db.InitTestReplDBWithCfg(t) + db, cfg := db.InitTestDBWithCfg(t) tagService := tagimpl.ProvideService(db) if annotationsRepo == nil { annotationsRepo = annotationsimpl.ProvideService(db, cfg, featuremgmt.WithFeatures(), tagService, tracing.InitializeTracerForTest(), nil) diff --git a/pkg/services/publicdashboards/service/query_test.go b/pkg/services/publicdashboards/service/query_test.go index e82d452e77a..7a356effc7b 100644 --- a/pkg/services/publicdashboards/service/query_test.go +++ b/pkg/services/publicdashboards/service/query_test.go @@ -10,9 +10,6 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db" @@ -28,6 +25,10 @@ import ( "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/util" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" ) const ( @@ -681,7 +682,7 @@ func TestGetQueryDataResponse(t *testing.T) { fakeQueryService.On("QueryData", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&backend.QueryDataResponse{}, nil) service.QueryDataService = fakeQueryService - dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, service.cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotatest.New(false, nil)) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, service.cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotatest.New(false, nil)) require.NoError(t, err) publicDashboardQueryDTO := PublicDashboardQueryDTO{ @@ -1084,7 +1085,7 @@ func TestFindAnnotations(t *testing.T) { func TestGetMetricRequest(t *testing.T) { service, sqlStore, cfg := newPublicDashboardServiceImpl(t, nil, nil, nil) - dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotatest.New(false, nil)) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotatest.New(false, nil)) require.NoError(t, err) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true, []map[string]interface{}{}, nil) @@ -1166,7 +1167,7 @@ func TestBuildMetricRequest(t *testing.T) { fakeDashboardService := &dashboards.FakeDashboardService{} service, sqlStore, cfg := newPublicDashboardServiceImpl(t, nil, fakeDashboardService, nil) - dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotatest.New(false, nil)) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotatest.New(false, nil)) require.NoError(t, err) publicDashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true, []map[string]interface{}{}, nil) nonPublicDashboard := insertTestDashboard(t, dashboardStore, "testNonPublicDashie", 1, 0, "", true, []map[string]interface{}{}, nil) @@ -1319,7 +1320,7 @@ func TestBuildMetricRequest(t *testing.T) { } func TestBuildAnonymousUser(t *testing.T) { - sqlStore, cfg := db.InitTestReplDBWithCfg(t) + sqlStore, cfg := db.InitTestDBWithCfg(t) dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotatest.New(false, nil)) require.NoError(t, err) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true, []map[string]interface{}{}, nil) diff --git a/pkg/services/publicdashboards/service/service_test.go b/pkg/services/publicdashboards/service/service_test.go index 2970fbb9b87..fa28eed134c 100644 --- a/pkg/services/publicdashboards/service/service_test.go +++ b/pkg/services/publicdashboards/service/service_test.go @@ -586,7 +586,7 @@ func TestCreatePublicDashboard(t *testing.T) { service, sqlStore, cfg := newPublicDashboardServiceImpl(t, nil, fakeDashboardService, nil) quotaService := quotatest.New(false, nil) - dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true, []map[string]any{}, nil) fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(dashboard, nil) @@ -666,7 +666,7 @@ func TestCreatePublicDashboard(t *testing.T) { fakeDashboardService := &dashboards.FakeDashboardService{} service, sqlStore, cfg := newPublicDashboardServiceImpl(t, nil, fakeDashboardService, nil) quotaService := quotatest.New(false, nil) - dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true, []map[string]any{}, nil) fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(dashboard, nil) @@ -698,7 +698,7 @@ func TestCreatePublicDashboard(t *testing.T) { fakeDashboardService := &dashboards.FakeDashboardService{} service, sqlStore, cfg := newPublicDashboardServiceImpl(t, nil, fakeDashboardService, nil) quotaService := quotatest.New(false, nil) - dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true, []map[string]any{}, nil) fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(dashboard, nil) @@ -725,7 +725,7 @@ func TestCreatePublicDashboard(t *testing.T) { fakeDashboardService := &dashboards.FakeDashboardService{} service, sqlStore, cfg := newPublicDashboardServiceImpl(t, nil, fakeDashboardService, nil) quotaService := quotatest.New(false, nil) - dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) templateVars := make([]map[string]any, 1) @@ -791,7 +791,7 @@ func TestCreatePublicDashboard(t *testing.T) { fakeDashboardService := &dashboards.FakeDashboardService{} service, sqlStore, cfg := newPublicDashboardServiceImpl(t, nil, fakeDashboardService, nil) quotaService := quotatest.New(false, nil) - dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true, []map[string]any{}, nil) fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(dashboard, nil) @@ -857,7 +857,7 @@ func TestCreatePublicDashboard(t *testing.T) { fakeDashboardService := &dashboards.FakeDashboardService{} service, sqlStore, cfg := newPublicDashboardServiceImpl(t, nil, fakeDashboardService, nil) quotaService := quotatest.New(false, nil) - dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true, []map[string]interface{}{}, nil) fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(dashboard, nil) @@ -909,7 +909,7 @@ func TestCreatePublicDashboard(t *testing.T) { fakeDashboardService := &dashboards.FakeDashboardService{} service, sqlStore, cfg := newPublicDashboardServiceImpl(t, publicdashboardStore, fakeDashboardService, nil) - dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotatest.New(false, nil)) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotatest.New(false, nil)) require.NoError(t, err) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true, []map[string]any{}, nil) fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(dashboard, nil) @@ -935,7 +935,7 @@ func TestCreatePublicDashboard(t *testing.T) { service, sqlStore, cfg := newPublicDashboardServiceImpl(t, nil, fakeDashboardService, nil) quotaService := quotatest.New(false, nil) - dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true, []map[string]any{}, nil) fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(dashboard, nil) @@ -973,7 +973,7 @@ func TestUpdatePublicDashboard(t *testing.T) { service, sqlStore, cfg := newPublicDashboardServiceImpl(t, nil, fakeDashboardService, nil) quotaService := quotatest.New(false, nil) - dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true, []map[string]any{}, nil) dashboard2 := insertTestDashboard(t, dashboardStore, "testDashie2", 1, 0, "", true, []map[string]any{}, nil) @@ -1156,7 +1156,7 @@ func TestUpdatePublicDashboard(t *testing.T) { service, sqlStore, cfg := newPublicDashboardServiceImpl(t, nil, fakeDashboardService, nil) quotaService := quotatest.New(false, nil) - dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore.DB()), quotaService) + dashboardStore, err := dashboardsDB.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, "", true, []map[string]any{}, nil) fakeDashboardService.On("GetDashboard", mock.Anything, mock.Anything, mock.Anything).Return(dashboard, nil) diff --git a/pkg/services/quota/quotaimpl/quota.go b/pkg/services/quota/quotaimpl/quota.go index e8867ad38e8..34ba998317f 100644 --- a/pkg/services/quota/quotaimpl/quota.go +++ b/pkg/services/quota/quotaimpl/quota.go @@ -58,7 +58,7 @@ type service struct { targetToSrv *quota.TargetToSrv } -func ProvideService(db db.ReplDB, cfg *setting.Cfg) quota.Service { +func ProvideService(db db.DB, cfg *setting.Cfg) quota.Service { logger := log.New("quota_service") s := service{ store: &sqlStore{db: db, logger: logger}, diff --git a/pkg/services/quota/quotaimpl/quota_test.go b/pkg/services/quota/quotaimpl/quota_test.go index 4c334396003..66763397395 100644 --- a/pkg/services/quota/quotaimpl/quota_test.go +++ b/pkg/services/quota/quotaimpl/quota_test.go @@ -69,7 +69,7 @@ func TestIntegrationQuotaCommandsAndQueries(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - sqlStore, cfg := db.InitTestReplDBWithCfg(t) + sqlStore, cfg := db.InitTestDBWithCfg(t) cfg.Quota = setting.QuotaSettings{ Enabled: true, @@ -479,14 +479,13 @@ func getQuotaBySrvTargetScope(t *testing.T, quotaService quota.Service, srv quot return quota.QuotaDTO{}, err } -func setupEnv(t *testing.T, replStore db.ReplDB, cfg *setting.Cfg, b bus.Bus, quotaService quota.Service) { - sqlStore := replStore.DB() +func setupEnv(t *testing.T, sqlStore db.DB, cfg *setting.Cfg, b bus.Bus, quotaService quota.Service) { tracer := tracing.InitializeTracerForTest() _, err := apikeyimpl.ProvideService(sqlStore, cfg, quotaService) require.NoError(t, err) _, err = authimpl.ProvideUserAuthTokenService(sqlStore, nil, quotaService, cfg) require.NoError(t, err) - _, err = dashboardStore.ProvideDashboardStore(replStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) + _, err = dashboardStore.ProvideDashboardStore(sqlStore, cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore()) secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger")) diff --git a/pkg/services/quota/quotaimpl/store.go b/pkg/services/quota/quotaimpl/store.go index 743e4f20275..78bc38fe4d9 100644 --- a/pkg/services/quota/quotaimpl/store.go +++ b/pkg/services/quota/quotaimpl/store.go @@ -16,12 +16,12 @@ type store interface { } type sqlStore struct { - db db.ReplDB + db db.DB logger log.Logger } func (ss *sqlStore) DeleteByUser(ctx quota.Context, userID int64) error { - return ss.db.DB().WithDbSession(ctx, func(sess *db.Session) error { + return ss.db.WithDbSession(ctx, func(sess *db.Session) error { var rawSQL = "DELETE FROM quota WHERE user_id = ?" _, err := sess.Exec(rawSQL, userID) return err @@ -54,7 +54,7 @@ func (ss *sqlStore) Get(ctx quota.Context, scopeParams *quota.ScopeParameters) ( } func (ss *sqlStore) Update(ctx quota.Context, cmd *quota.UpdateQuotaCmd) error { - return ss.db.DB().WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { + return ss.db.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { // Check if quota is already defined in the DB quota := quota.Quota{ Target: cmd.Target, @@ -87,7 +87,7 @@ func (ss *sqlStore) Update(ctx quota.Context, cmd *quota.UpdateQuotaCmd) error { func (ss *sqlStore) getUserScopeQuota(ctx quota.Context, userID int64) (*quota.Map, error) { r := quota.Map{} - err := ss.db.ReadReplica().WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + err := ss.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { quotas := make([]*quota.Quota, 0) if err := sess.Table("quota").Where("user_id=? AND org_id=0", userID).Find("as); err != nil { return err @@ -111,7 +111,7 @@ func (ss *sqlStore) getUserScopeQuota(ctx quota.Context, userID int64) (*quota.M func (ss *sqlStore) getOrgScopeQuota(ctx quota.Context, OrgID int64) (*quota.Map, error) { r := quota.Map{} - err := ss.db.ReadReplica().WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + err := ss.db.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { quotas := make([]*quota.Quota, 0) if err := sess.Table("quota").Where("user_id=0 AND org_id=?", OrgID).Find("as); err != nil { return err diff --git a/pkg/services/quota/quotaimpl/store_test.go b/pkg/services/quota/quotaimpl/store_test.go index 0128f86ad1d..2e8211871d1 100644 --- a/pkg/services/quota/quotaimpl/store_test.go +++ b/pkg/services/quota/quotaimpl/store_test.go @@ -20,7 +20,7 @@ func TestIntegrationQuotaDataAccess(t *testing.T) { t.Skip("skipping integration test") } - ss := db.InitTestReplDB(t) + ss := db.InitTestDB(t) quotaStore := sqlStore{ db: ss, } diff --git a/pkg/services/searchV2/index_test.go b/pkg/services/searchV2/index_test.go index bc1d4b5d07b..e6a569fb57c 100644 --- a/pkg/services/searchV2/index_test.go +++ b/pkg/services/searchV2/index_test.go @@ -773,8 +773,7 @@ func TestIntegrationSoftDeletion(t *testing.T) { // Set up search v2. folderCount := 1 dashboardsPerFolder := 1 - replStore, cfg := db.InitTestReplDBWithCfg(t) - sqlStore := replStore.DB() + sqlStore, cfg := db.InitTestDBWithCfg(t) searchService, testUser, err := setupIntegrationEnv(t, folderCount, dashboardsPerFolder, sqlStore) require.NoError(t, err) @@ -796,7 +795,7 @@ func TestIntegrationSoftDeletion(t *testing.T) { featuremgmt.FlagDashboardRestore, featuremgmt.FlagMysqlParseTime, ) - dashboardStore, err := database.ProvideDashboardStore(replStore, cfg, featureToggles, tagimpl.ProvideService(sqlStore), quotaService) + dashboardStore, err := database.ProvideDashboardStore(sqlStore, cfg, featureToggles, tagimpl.ProvideService(sqlStore), quotaService) require.NoError(t, err) // Soft delete "dashboard2". diff --git a/pkg/services/serviceaccounts/tests/common.go b/pkg/services/serviceaccounts/tests/common.go index 5ef35d18c59..66d15e35b9d 100644 --- a/pkg/services/serviceaccounts/tests/common.go +++ b/pkg/services/serviceaccounts/tests/common.go @@ -36,17 +36,17 @@ type TestApiKey struct { ServiceAccountID *int64 } -func SetupUserServiceAccount(t *testing.T, store db.DB, cfg *setting.Cfg, testUser TestUser) *user.User { +func SetupUserServiceAccount(t *testing.T, db db.DB, cfg *setting.Cfg, testUser TestUser) *user.User { role := string(org.RoleViewer) if testUser.Role != "" { role = testUser.Role } - quotaService := quotaimpl.ProvideService(db.FakeReplDBFromDB(store), cfg) - orgService, err := orgimpl.ProvideService(store, cfg, quotaService) + quotaService := quotaimpl.ProvideService(db, cfg) + orgService, err := orgimpl.ProvideService(db, cfg, quotaService) require.NoError(t, err) usrSvc, err := userimpl.ProvideService( - store, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), + db, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), quotaService, supportbundlestest.NewFakeBundleService(), ) require.NoError(t, err) @@ -112,7 +112,7 @@ func SetupApiKey(t *testing.T, store db.DB, cfg *setting.Cfg, testKey TestApiKey func SetupUsersServiceAccounts(t *testing.T, sqlStore db.DB, cfg *setting.Cfg, testUsers []TestUser) (orgID int64) { role := string(org.RoleNone) - quotaService := quotaimpl.ProvideService(db.FakeReplDBFromDB(sqlStore), cfg) + quotaService := quotaimpl.ProvideService(sqlStore, cfg) orgService, err := orgimpl.ProvideService(sqlStore, cfg, quotaService) require.NoError(t, err) usrSvc, err := userimpl.ProvideService( diff --git a/pkg/services/sqlstore/database_config.go b/pkg/services/sqlstore/database_config.go index e469455670f..171ff4972b9 100644 --- a/pkg/services/sqlstore/database_config.go +++ b/pkg/services/sqlstore/database_config.go @@ -10,7 +10,6 @@ import ( "strings" "github.com/go-sql-driver/mysql" - "gopkg.in/ini.v1" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" @@ -66,15 +65,9 @@ func NewDatabaseConfig(cfg *setting.Cfg, features featuremgmt.FeatureToggles) (* return dbCfg, nil } -// readConfigSection reads the database configuration from the given block of -// the configuration file. This method allows us to add a "database_replica" -// section to the configuration file while using the same cfg struct. -func (dbCfg *DatabaseConfig) readConfigSection(cfg *setting.Cfg, section string) error { - sec := cfg.Raw.Section(section) - return dbCfg.parseConfigIni(sec) -} +func (dbCfg *DatabaseConfig) readConfig(cfg *setting.Cfg) error { + sec := cfg.Raw.Section("database") -func (dbCfg *DatabaseConfig) parseConfigIni(sec *ini.Section) error { cfgURL := sec.Key("url").String() if len(cfgURL) != 0 { dbURL, err := url.Parse(cfgURL) @@ -108,6 +101,7 @@ func (dbCfg *DatabaseConfig) parseConfigIni(sec *ini.Section) error { dbCfg.MaxOpenConn = sec.Key("max_open_conn").MustInt(0) dbCfg.MaxIdleConn = sec.Key("max_idle_conn").MustInt(2) dbCfg.ConnMaxLifetime = sec.Key("conn_max_lifetime").MustInt(14400) + dbCfg.SslMode = sec.Key("ssl_mode").String() dbCfg.SSLSNI = sec.Key("ssl_sni").String() dbCfg.CaCertPath = sec.Key("ca_cert_path").String() @@ -116,20 +110,19 @@ func (dbCfg *DatabaseConfig) parseConfigIni(sec *ini.Section) error { dbCfg.ServerCertName = sec.Key("server_cert_name").String() dbCfg.Path = sec.Key("path").MustString("data/grafana.db") dbCfg.IsolationLevel = sec.Key("isolation_level").String() + dbCfg.CacheMode = sec.Key("cache_mode").MustString("private") dbCfg.WALEnabled = sec.Key("wal").MustBool(false) dbCfg.SkipMigrations = sec.Key("skip_migrations").MustBool() dbCfg.MigrationLock = sec.Key("migration_locking").MustBool(true) dbCfg.MigrationLockAttemptTimeout = sec.Key("locking_attempt_timeout_sec").MustInt() + dbCfg.QueryRetries = sec.Key("query_retries").MustInt() dbCfg.TransactionRetries = sec.Key("transaction_retries").MustInt(5) - dbCfg.LogQueries = sec.Key("log_queries").MustBool(false) - return nil -} -// readConfig is a wrapper around readConfigSection that read the "database" configuration block. -func (dbCfg *DatabaseConfig) readConfig(cfg *setting.Cfg) error { - return dbCfg.readConfigSection(cfg, "database") + dbCfg.LogQueries = sec.Key("log_queries").MustBool(false) + + return nil } func (dbCfg *DatabaseConfig) buildConnectionString(cfg *setting.Cfg, features featuremgmt.FeatureToggles) error { @@ -235,50 +228,3 @@ func buildExtraConnectionString(sep rune, urlQueryParams map[string][]string) st } return sb.String() } - -func validateReplicaConfigs(primary *DatabaseConfig, cfgs []DatabaseConfig) error { - if cfgs == nil { - return errors.New("cfg cannot be nil") - } - - // Return multiple errors so we can fix them all at once! - var result error - - // Check for duplicate connection strings - seen := make(map[string]struct{}) - seen[primary.ConnectionString] = struct{}{} - for _, cfg := range cfgs { - if _, ok := seen[cfg.ConnectionString]; ok { - result = errors.Join(result, errors.New("duplicate connection string")) - } else { - seen[cfg.ConnectionString] = struct{}{} - } - } - - // Verify that every database is the same type and version, and that it matches the primary database. - // The database Yype may include a "withHooks" suffix, which is used to differentiate drivers for instrumentation and ignored for the purpose of this check. - for _, cfg := range cfgs { - if databaseDriverFromName(cfg.Type) != databaseDriverFromName(primary.Type) { - result = errors.Join(result, fmt.Errorf("the replicas must have the same database type as the primary database (%s != %s)", primary.Type, cfg.Type)) - break // Only need to report this once - } - } - - return result -} - -// databaseDriverFromName strips any suffixes from the driver type that are not relevant to the database driver. -// This is used to remove the "WithHooks" or "ReplWithHooks" suffixes which are used to differentiate drivers for instrumentation. -func databaseDriverFromName(driverTy string) string { - if strings.HasPrefix(driverTy, migrator.MySQL) { - return migrator.MySQL - } - if strings.HasPrefix(driverTy, migrator.Postgres) { - return migrator.Postgres - } - if strings.HasPrefix(driverTy, migrator.SQLite) { - return migrator.SQLite - } - // default - return driverTy -} diff --git a/pkg/services/sqlstore/database_config_test.go b/pkg/services/sqlstore/database_config_test.go index 5dd882f85b6..cd13fc3b8fa 100644 --- a/pkg/services/sqlstore/database_config_test.go +++ b/pkg/services/sqlstore/database_config_test.go @@ -7,7 +7,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "gopkg.in/ini.v1" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" @@ -222,93 +221,3 @@ func TestBuildConnectionStringPostgres(t *testing.T) { }) } } - -func TestValidateReplicaConfigs(t *testing.T) { - t.Run("valid config", func(t *testing.T) { - inicfg, err := ini.Load([]byte(testReplCfg)) - require.NoError(t, err) - cfg, err := setting.NewCfgFromINIFile(inicfg) - require.NoError(t, err) - - dbCfgs, err := NewRODatabaseConfigs(cfg, nil) - require.NoError(t, err) - - err = validateReplicaConfigs(&DatabaseConfig{Type: "mysql"}, dbCfgs) - require.NoError(t, err) - }) - - t.Run("valid but awkward config", func(t *testing.T) { - inicfg, err := ini.Load([]byte(testReplCfg)) - require.NoError(t, err) - cfg, err := setting.NewCfgFromINIFile(inicfg) - require.NoError(t, err) - - dbCfgs, err := NewRODatabaseConfigs(cfg, nil) - require.NoError(t, err) - - // The primary is mysql, but the replicas are mysqlWithHooks. This can - // occur when some but not all the replicas (or primary) have - // instrument_queries enabled - err = validateReplicaConfigs(&DatabaseConfig{Type: "mysqlWithHooks"}, dbCfgs) - require.NoError(t, err) - }) - - t.Run("invalid config: primary database type mismatch", func(t *testing.T) { - // valid repl config, the issue is that the primary has a different type - inicfg, err := ini.Load([]byte(testReplCfg)) - require.NoError(t, err) - cfg, err := setting.NewCfgFromINIFile(inicfg) - require.NoError(t, err) - - dbCfgs, err := NewRODatabaseConfigs(cfg, nil) - require.NoError(t, err) - - err = validateReplicaConfigs(&DatabaseConfig{Type: "postgres"}, dbCfgs) - require.Error(t, err) - - if uw, ok := err.(interface{ Unwrap() []error }); ok { - errs := uw.Unwrap() - require.Equal(t, 1, len(errs)) - } - }) - - t.Run("invalid repl config", func(t *testing.T) { - // Type mismatch + duplicate hosts - inicfg, err := ini.Load([]byte(invalidReplCfg)) - require.NoError(t, err) - cfg, err := setting.NewCfgFromINIFile(inicfg) - require.NoError(t, err) - - dbCfgs, err := NewRODatabaseConfigs(cfg, nil) - require.NoError(t, err) - - err = validateReplicaConfigs(&DatabaseConfig{Type: "mysql"}, dbCfgs) - require.Error(t, err) - - if uw, ok := err.(interface{ Unwrap() []error }); ok { - errs := uw.Unwrap() - require.Equal(t, 2, len(errs)) - } - }) -} - -// This cfg has a duplicate host for repls 0 and 1, and a type mismatch in repl 2 -var invalidReplCfg = ` -[database_replicas] -type = mysql -name = grafana -user = grafana -password = password -host = 127.0.0.1:3306 -[database_replica.one] -name = grafana -user = grafana -password = password -type = mysql -host = 127.0.0.1:3306 -[database_replica.two] -name = grafana -user = grafana -password = password -type = postgres -host = 127.0.0.1:3308` diff --git a/pkg/services/sqlstore/database_wrapper.go b/pkg/services/sqlstore/database_wrapper.go index b6358e73002..d173f48158a 100644 --- a/pkg/services/sqlstore/database_wrapper.go +++ b/pkg/services/sqlstore/database_wrapper.go @@ -58,27 +58,6 @@ func WrapDatabaseDriverWithHooks(dbType string, tracer tracing.Tracer) string { return driverWithHooks } -// WrapDatabaseDriverWithHooks creates a fake database driver that -// executes pre and post functions which we use to gather metrics about -// database queries. It also registers the metrics. -func WrapDatabaseReplDriverWithHooks(dbType string, index uint, tracer tracing.Tracer) string { - drivers := map[string]driver.Driver{ - migrator.SQLite: &sqlite3.SQLiteDriver{}, - migrator.MySQL: &mysql.MySQLDriver{}, - migrator.Postgres: &pq.Driver{}, - } - - d, exist := drivers[dbType] - if !exist { - return dbType - } - - driverWithHooks := dbType + fmt.Sprintf("ReplicaWithHooks%d", index) - sql.Register(driverWithHooks, sqlhooks.Wrap(d, &databaseQueryWrapper{log: log.New("sqlstore.metrics"), tracer: tracer})) - core.RegisterDriver(driverWithHooks, &databaseQueryWrapperDriver{dbType: dbType}) - return driverWithHooks -} - // databaseQueryWrapper satisfies the sqlhook.databaseQueryWrapper interface // which allow us to wrap all SQL queries with a `Before` & `After` hook. type databaseQueryWrapper struct { diff --git a/pkg/services/sqlstore/migrations/cloud_migrations.go b/pkg/services/sqlstore/migrations/cloud_migrations.go index b64eb59d69e..8b9e3a72d60 100644 --- a/pkg/services/sqlstore/migrations/cloud_migrations.go +++ b/pkg/services/sqlstore/migrations/cloud_migrations.go @@ -158,4 +158,10 @@ func addCloudMigrationsMigrations(mg *Migrator) { // -- delete the snapshot result column while still in the experimental phase mg.AddMigration("delete cloud_migration_snapshot.result column", NewRawSQLMigration("ALTER TABLE cloud_migration_snapshot DROP COLUMN result")) + + mg.AddMigration("add cloud_migration_resource.name column", NewAddColumnMigration(migrationResourceTable, &Column{ + Name: "name", + Type: DB_Text, + Nullable: true, + })) } diff --git a/pkg/services/sqlstore/migrator/dialect.go b/pkg/services/sqlstore/migrator/dialect.go index ff9ec8ef8f3..5aae742b6ca 100644 --- a/pkg/services/sqlstore/migrator/dialect.go +++ b/pkg/services/sqlstore/migrator/dialect.go @@ -6,10 +6,9 @@ import ( "strconv" "strings" + "github.com/grafana/grafana/pkg/services/sqlstore/session" "golang.org/x/exp/slices" "xorm.io/xorm" - - "github.com/grafana/grafana/pkg/services/sqlstore/session" ) var ( @@ -107,14 +106,12 @@ type LockCfg struct { type dialectFunc func() Dialect var supportedDialects = map[string]dialectFunc{ - MySQL: NewMysqlDialect, - SQLite: NewSQLite3Dialect, - Postgres: NewPostgresDialect, - MySQL + "WithHooks": NewMysqlDialect, - MySQL + "ReplicaWithHooks": NewMysqlDialect, - SQLite + "WithHooks": NewSQLite3Dialect, - Postgres + "WithHooks": NewPostgresDialect, - Postgres + "ReplicaWithHooks": NewPostgresDialect, + MySQL: NewMysqlDialect, + SQLite: NewSQLite3Dialect, + Postgres: NewPostgresDialect, + MySQL + "WithHooks": NewMysqlDialect, + SQLite + "WithHooks": NewSQLite3Dialect, + Postgres + "WithHooks": NewPostgresDialect, } func NewDialect(driverName string) Dialect { diff --git a/pkg/services/sqlstore/permissions/dashboard_test.go b/pkg/services/sqlstore/permissions/dashboard_test.go index 1acf17fce41..4c184e56e17 100644 --- a/pkg/services/sqlstore/permissions/dashboard_test.go +++ b/pkg/services/sqlstore/permissions/dashboard_test.go @@ -816,7 +816,7 @@ func setupTest(t *testing.T, numFolders, numDashboards int, permissions []access func setupNestedTest(t *testing.T, usr *user.SignedInUser, perms []accesscontrol.Permission, orgID int64, features featuremgmt.FeatureToggles) db.DB { t.Helper() - db, cfg := db.InitTestReplDBWithCfg(t) + db, cfg := db.InitTestDBWithCfg(t) // dashboard store commands that should be called. dashStore, err := database.ProvideDashboardStore(db, cfg, features, tagimpl.ProvideService(db), quotatest.New(false, nil)) diff --git a/pkg/services/sqlstore/permissions/dashboards_bench_test.go b/pkg/services/sqlstore/permissions/dashboards_bench_test.go index dfb2fe43b81..4dc62cadd3a 100644 --- a/pkg/services/sqlstore/permissions/dashboards_bench_test.go +++ b/pkg/services/sqlstore/permissions/dashboards_bench_test.go @@ -74,7 +74,7 @@ func setupBenchMark(b *testing.B, usr user.SignedInUser, features featuremgmt.Fe nestingLevel = folder.MaxNestedFolderDepth } - store, cfg := db.InitTestReplDBWithCfg(b) + store, cfg := db.InitTestDBWithCfg(b) quotaService := quotatest.New(false, nil) diff --git a/pkg/services/sqlstore/replstore.go b/pkg/services/sqlstore/replstore.go deleted file mode 100644 index 68854acde0a..00000000000 --- a/pkg/services/sqlstore/replstore.go +++ /dev/null @@ -1,277 +0,0 @@ -package sqlstore - -import ( - "errors" - "fmt" - "regexp" - "sync/atomic" - "time" - - "github.com/dlmiddlecote/sqlstats" - "github.com/prometheus/client_golang/prometheus" - "xorm.io/xorm" - - "github.com/grafana/grafana/pkg/bus" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/infra/tracing" - "github.com/grafana/grafana/pkg/registry" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/sqlstore/migrations" - "github.com/grafana/grafana/pkg/services/sqlstore/migrator" - "github.com/grafana/grafana/pkg/services/sqlstore/sqlutil" - "github.com/grafana/grafana/pkg/setting" -) - -// ReplStore is a wrapper around a main SQLStore and a read-only SQLStore. The -// main SQLStore is anonymous, so the ReplStore may be used directly as a -// SQLStore. -type ReplStore struct { - *SQLStore - repls []*SQLStore - - // next is the index of the next read-only SQLStore in the chain. - next uint64 -} - -// DB returns the main SQLStore. -func (rs *ReplStore) DB() *SQLStore { - return rs.SQLStore -} - -// ReadReplica returns the read-only SQLStore. If no read replica is configured, -// it returns the main SQLStore. -func (rs *ReplStore) ReadReplica() *SQLStore { - if len(rs.repls) == 0 { - rs.log.Debug("ReadReplica not configured, using main SQLStore") - return rs.SQLStore - } - return rs.nextRepl() -} - -// nextRepl() returns the next read-only SQLStore in the chain. If no read replica is configured, the Primary is returned. -func (rs *ReplStore) nextRepl() *SQLStore { - // start by grabbing the replica at the current index - selected := rs.repls[(int(rs.next))%len(rs.repls)] - - // then increment the index for the next call - atomic.AddUint64(&rs.next, 1) - - return selected -} - -// ProvideServiceWithReadReplica creates a new *SQLStore connection intended for -// use as a ReadReplica of the main SQLStore. The primary SQLStore must already -// be initialized. -func ProvideServiceWithReadReplica(primary *SQLStore, cfg *setting.Cfg, - features featuremgmt.FeatureToggles, migrations registry.DatabaseMigrator, - bus bus.Bus, tracer tracing.Tracer) (*ReplStore, error) { - // start with the initialized SQLStore - replStore := &ReplStore{primary, nil, 0} - - // FeatureToggle fallback: If the FlagDatabaseReadReplica feature flag is not enabled, return a single SQLStore. - if !features.IsEnabledGlobally(featuremgmt.FlagDatabaseReadReplica) { - primary.log.Debug("ReadReplica feature flag not enabled, using main SQLStore") - return replStore, nil - } - - // This change will make xorm use an empty default schema for postgres and - // by that mimic the functionality of how it was functioning before - // xorm's changes above. - xorm.DefaultPostgresSchema = "" - - // Parsing the configuration to get the number of repls - replCfgs, err := NewRODatabaseConfigs(cfg, features) - if err != nil { - return nil, err - } - - if err := validateReplicaConfigs(primary.dbCfg, replCfgs); err != nil { - return nil, fmt.Errorf("failed to validate replica configurations: %w", err) - } - - if len(replCfgs) > 0 { - replStore.repls = make([]*SQLStore, len(replCfgs)) - } - - for i, replCfg := range replCfgs { - // If the database_instrument_queries feature is enabled, wrap the driver with hooks. - if cfg.DatabaseInstrumentQueries { - replCfg.Type = WrapDatabaseReplDriverWithHooks(replCfg.Type, uint(i), tracer) - } - - s, err := newReadOnlySQLStore(cfg, &replCfg, features, bus, tracer) - if err != nil { - return nil, err - } - - // initialize and register metrics wrapper around the *sql.DB - db := s.engine.DB().DB - - // register the go_sql_stats_connections_* metrics - if err := prometheus.Register(sqlstats.NewStatsCollector("grafana_repl", db)); err != nil { - s.log.Warn("Failed to register sqlstore stats collector", "error", err) - } - replStore.repls[i] = s - } - return replStore, nil -} - -// newReadOnlySQLStore creates a new *SQLStore intended for use with a -// fully-populated read replica of the main Grafana Database. It provides no -// write capabilities and does not run migrations, but other tracing and logging -// features are enabled. -func newReadOnlySQLStore(cfg *setting.Cfg, dbCfg *DatabaseConfig, features featuremgmt.FeatureToggles, bus bus.Bus, tracer tracing.Tracer) (*SQLStore, error) { - s := &SQLStore{ - log: log.New("replstore"), - bus: bus, - tracer: tracer, - features: features, - dbCfg: dbCfg, - cfg: cfg, - } - - err := s.initReadOnlyEngine(s.engine) - if err != nil { - return nil, err - } - - // When there are multiple read replicas, we append an index to the driver name (ex: mysqlWithHooks11). - // Remove the index from the end of the driver name to get the original driver name that xorm and other libraries recognize. - driverName := digitsRegexp.ReplaceAllString(s.engine.DriverName(), "") - - s.dialect = migrator.NewDialect(driverName) - return s, nil -} - -// digitsRegexp is used to remove the index from the end of the driver name. -var digitsRegexp = regexp.MustCompile("[0-9]+") - -// initReadOnlyEngine initializes ss.engine for read-only operations. The database must be a fully-populated read replica. -func (ss *SQLStore) initReadOnlyEngine(engine *xorm.Engine) error { - if ss.engine != nil { - ss.log.Debug("Already connected to database replica") - return nil - } - - if engine == nil { - var err error - engine, err = xorm.NewEngine(ss.dbCfg.Type, ss.dbCfg.ConnectionString) - if err != nil { - ss.log.Error("failed to connect to database replica", "error", err) - return err - } - // Only for MySQL or MariaDB, verify we can connect with the current connection string's system var for transaction isolation. - // If not, create a new engine with a compatible connection string. - if ss.dbCfg.Type == migrator.MySQL { - engine, err = ss.ensureTransactionIsolationCompatibility(engine, ss.dbCfg.ConnectionString) - if err != nil { - return err - } - } - } - - engine.SetMaxOpenConns(ss.dbCfg.MaxOpenConn) - engine.SetMaxIdleConns(ss.dbCfg.MaxIdleConn) - engine.SetConnMaxLifetime(time.Second * time.Duration(ss.dbCfg.ConnMaxLifetime)) - - // configure sql logging - debugSQL := ss.cfg.Raw.Section("database_replica").Key("log_queries").MustBool(false) - if !debugSQL { - engine.SetLogger(&xorm.DiscardLogger{}) - } else { - // add stack to database calls to be able to see what repository initiated queries. Top 7 items from the stack as they are likely in the xorm library. - engine.SetLogger(NewXormLogger(log.LvlInfo, log.WithSuffix(log.New("replsstore.xorm"), log.CallerContextKey, log.StackCaller(log.DefaultCallerDepth)))) - engine.ShowSQL(true) - engine.ShowExecTime(true) - } - - ss.engine = engine - return nil -} - -// NewRODatabaseConfig creates a new read-only database configuration. -func NewRODatabaseConfigs(cfg *setting.Cfg, features featuremgmt.FeatureToggles) ([]DatabaseConfig, error) { - if cfg == nil { - return nil, errors.New("cfg cannot be nil") - } - - // If one replica is configured in the database_replicas section, use it as the default - defaultReplCfg := DatabaseConfig{} - if err := defaultReplCfg.readConfigSection(cfg, "database_replicas"); err != nil { - return nil, err - } - err := defaultReplCfg.buildConnectionString(cfg, features) - if err != nil { - return nil, err - } - ret := []DatabaseConfig{defaultReplCfg} - - // Check for individual replicas in the database_replica section (e.g. database_replica.one, database_replica.cheetara) - repls := cfg.Raw.Section("database_replica") - if len(repls.ChildSections()) > 0 { - for _, sec := range repls.ChildSections() { - replCfg := DatabaseConfig{} - if err := replCfg.parseConfigIni(sec); err != nil { - return nil, err - } - if err := replCfg.buildConnectionString(cfg, features); err != nil { - return nil, err - } - ret = append(ret, replCfg) - } - } - - return ret, nil -} - -// ProvideServiceWithReadReplicaForTests wraps the SQLStore in a ReplStore, with the main sqlstore as both the primary and read replica. -// TODO: eventually this should be replaced with a more robust test setup which in -func ProvideServiceWithReadReplicaForTests(testDB *SQLStore, t sqlutil.ITestDB, cfg *setting.Cfg, features featuremgmt.FeatureToggles, migrations registry.DatabaseMigrator) (*ReplStore, error) { - return newReplStore(testDB, testDB), nil -} - -// InitTestReplDB initializes a test DB and returns it wrapped in a ReplStore with the main SQLStore as both the primary and read replica. -func InitTestReplDB(t sqlutil.ITestDB, opts ...InitTestDBOpt) (*ReplStore, *setting.Cfg) { - t.Helper() - features := getFeaturesForTesting(opts...) - cfg := getCfgForTesting(opts...) - ss, err := initTestDB(t, cfg, features, migrations.ProvideOSSMigrations(features), opts...) - if err != nil { - t.Fatalf("failed to initialize sql repl store: %s", err) - } - return newReplStore(ss, ss), cfg -} - -// InitTestReplDBWithMigration initializes the test DB given custom migrations. -func InitTestReplDBWithMigration(t sqlutil.ITestDB, migration registry.DatabaseMigrator, opts ...InitTestDBOpt) *ReplStore { - t.Helper() - features := getFeaturesForTesting(opts...) - cfg := getCfgForTesting(opts...) - ss, err := initTestDB(t, cfg, features, migration, opts...) - if err != nil { - t.Fatalf("failed to initialize sql store: %s", err) - } - return newReplStore(ss, ss) -} - -// newReplStore is a wrapper function that returns a ReplStore with the given primary and read replicas. -func newReplStore(primary *SQLStore, readReplicas ...*SQLStore) *ReplStore { - ret := &ReplStore{ - SQLStore: primary, - repls: make([]*SQLStore, len(readReplicas)), - next: 0, - } - ret.repls = readReplicas - return ret -} - -// FakeReplStoreFromStore returns a ReplStore with the given primary -// SQLStore and no read replicas. This is a bare-minimum wrapper for testing, -// and should be removed when all services are using ReplStore in favor of -// InitTestReplDB. -func FakeReplStoreFromStore(primary *SQLStore) *ReplStore { - return &ReplStore{ - SQLStore: primary, - next: 0, - } -} diff --git a/pkg/services/sqlstore/replstore_test.go b/pkg/services/sqlstore/replstore_test.go deleted file mode 100644 index 6cb475b3b06..00000000000 --- a/pkg/services/sqlstore/replstore_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package sqlstore - -import ( - "fmt" - "testing" - - "github.com/google/go-cmp/cmp" - "github.com/stretchr/testify/require" - "gopkg.in/ini.v1" - - "github.com/grafana/grafana/pkg/setting" -) - -func TestReplStore_ReadReplica(t *testing.T) { - // Using the connection strings to differentiate between the replicas - replStore, _ := InitTestReplDB(t) - replStore.repls[0].dbCfg.ConnectionString = "repl0" - - repl1 := &SQLStore{dbCfg: &DatabaseConfig{ConnectionString: "repl1"}} - repl2 := &SQLStore{dbCfg: &DatabaseConfig{ConnectionString: "repl2"}} - replStore.repls = append(replStore.repls, repl1, repl2) - - got := make([]string, 5) - for i := 0; i < 5; i++ { - got[i] = replStore.ReadReplica().dbCfg.ConnectionString - } - - want := []string{"repl0", "repl1", "repl2", "repl0", "repl1"} - if cmp.Equal(got, want) == false { - t.Fatal("wrong result. Got:", got, "Want:", want) - } -} - -func TestNewRODatabaseConfig(t *testing.T) { - t.Run("valid config", func(t *testing.T) { - inicfg, err := ini.Load([]byte(testReplCfg)) - require.NoError(t, err) - cfg, err := setting.NewCfgFromINIFile(inicfg) - require.NoError(t, err) - - dbCfgs, err := NewRODatabaseConfigs(cfg, nil) - require.NoError(t, err) - require.Len(t, dbCfgs, 3) - - var connStr = func(port int) string { - return fmt.Sprintf("grafana:password@tcp(127.0.0.1:%d)/grafana?collation=utf8mb4_unicode_ci&allowNativePasswords=true&clientFoundRows=true", port) - } - for i, c := range dbCfgs { - if !cmp.Equal(c.ConnectionString, connStr(i+3306)) { - t.Errorf("wrong result for connection string %d.\nGot: %s,\nWant: %s", i, c.ConnectionString, connStr(i+3306)) - } - } - }) -} - -var testReplCfg = ` -[database_replicas] -type = mysql -name = grafana -user = grafana -password = password -host = 127.0.0.1:3306 -[database_replica.one] -host = 127.0.0.1:3307 -type = mysql -name = grafana -user = grafana -password = password -[database_replica.two] -host = 127.0.0.1:3308 -type = mysql -name = grafana -user = grafana -password = password` diff --git a/pkg/services/stats/statsimpl/stats.go b/pkg/services/stats/statsimpl/stats.go index 7f580471212..e2139c80b57 100644 --- a/pkg/services/stats/statsimpl/stats.go +++ b/pkg/services/stats/statsimpl/stats.go @@ -9,7 +9,6 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/libraryelements/model" "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/services/stats" "github.com/grafana/grafana/pkg/setting" @@ -18,12 +17,12 @@ import ( const activeUserTimeLimit = time.Hour * 24 * 30 const dailyActiveUserTimeLimit = time.Hour * 24 -func ProvideService(cfg *setting.Cfg, db *sqlstore.ReplStore) stats.Service { +func ProvideService(cfg *setting.Cfg, db db.DB) stats.Service { return &sqlStatsService{cfg: cfg, db: db} } type sqlStatsService struct { - db *sqlstore.ReplStore + db db.DB cfg *setting.Cfg } @@ -63,8 +62,8 @@ func notServiceAccount(dialect migrator.Dialect) string { } func (ss *sqlStatsService) GetSystemStats(ctx context.Context, query *stats.GetSystemStatsQuery) (result *stats.SystemStats, err error) { - dialect := ss.db.ReadReplica().GetDialect() - err = ss.db.ReadReplica().WithDbSession(ctx, func(dbSession *db.Session) error { + dialect := ss.db.GetDialect() + err = ss.db.WithDbSession(ctx, func(dbSession *db.Session) error { sb := &db.SQLBuilder{} sb.Write("SELECT ") sb.Write(`(SELECT COUNT(*) FROM ` + dialect.Quote("user") + ` WHERE ` + notServiceAccount(dialect) + `) AS users,`) @@ -149,8 +148,8 @@ func (ss *sqlStatsService) roleCounterSQL(ctx context.Context) string { } func (ss *sqlStatsService) GetAdminStats(ctx context.Context, query *stats.GetAdminStatsQuery) (result *stats.AdminStats, err error) { - err = ss.db.ReadReplica().WithDbSession(ctx, func(dbSession *db.Session) error { - dialect := ss.db.ReadReplica().GetDialect() + err = ss.db.WithDbSession(ctx, func(dbSession *db.Session) error { + dialect := ss.db.GetDialect() now := time.Now() activeEndDate := now.Add(-activeUserTimeLimit) dailyActiveEndDate := now.Add(-dailyActiveUserTimeLimit) diff --git a/pkg/services/stats/statsimpl/stats_test.go b/pkg/services/stats/statsimpl/stats_test.go index 019f1ab7d47..e07c059bf9d 100644 --- a/pkg/services/stats/statsimpl/stats_test.go +++ b/pkg/services/stats/statsimpl/stats_test.go @@ -32,9 +32,9 @@ func TestIntegrationStatsDataAccess(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - store, cfg := db.InitTestReplDBWithCfg(t) - statsService := &sqlStatsService{db: store} - populateDB(t, store, cfg) + db, cfg := db.InitTestDBWithCfg(t) + statsService := &sqlStatsService{db: db} + populateDB(t, db, cfg) t.Run("Get system stats should not results in error", func(t *testing.T) { query := stats.GetSystemStatsQuery{} @@ -49,7 +49,7 @@ func TestIntegrationStatsDataAccess(t *testing.T) { assert.Equal(t, int64(0), result.APIKeys) assert.Equal(t, int64(2), result.Correlations) assert.NotNil(t, result.DatabaseCreatedTime) - assert.Equal(t, store.GetDialect().DriverName(), result.DatabaseDriver) + assert.Equal(t, db.GetDialect().DriverName(), result.DatabaseDriver) }) t.Run("Get system user count stats should not results in error", func(t *testing.T) { @@ -104,7 +104,7 @@ func populateDB(t *testing.T, db db.DB, cfg *setting.Cfg) { Config: correlations.CorrelationConfig{ Field: "field", Target: map[string]any{}, - Type: correlations.TypeQuery, + Type: correlations.CorrelationType("query"), }, } correlation, err := correlationsSvc.CreateCorrelation(context.Background(), cmd) @@ -157,8 +157,8 @@ func TestIntegration_GetAdminStats(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } - store, cfg := db.InitTestReplDBWithCfg(t) - statsService := ProvideService(cfg, store) + db, cfg := db.InitTestDBWithCfg(t) + statsService := ProvideService(cfg, db) query := stats.GetAdminStatsQuery{} _, err := statsService.GetAdminStats(context.Background(), &query) diff --git a/pkg/services/team/teamimpl/store.go b/pkg/services/team/teamimpl/store.go index 8d3b0a068ed..e1069cd9f4e 100644 --- a/pkg/services/team/teamimpl/store.go +++ b/pkg/services/team/teamimpl/store.go @@ -31,7 +31,7 @@ type store interface { } type xormStore struct { - db db.ReplDB + db db.DB cfg *setting.Cfg deletes []string } @@ -84,7 +84,7 @@ func (ss *xormStore) Create(name, email string, orgID int64) (team.Team, error) Created: time.Now(), Updated: time.Now(), } - err := ss.db.DB().WithTransactionalDbSession(context.Background(), func(sess *db.Session) error { + err := ss.db.WithTransactionalDbSession(context.Background(), func(sess *db.Session) error { if isNameTaken, err := isTeamNameTaken(orgID, name, 0, sess); err != nil { return err } else if isNameTaken { @@ -98,7 +98,7 @@ func (ss *xormStore) Create(name, email string, orgID int64) (team.Team, error) } func (ss *xormStore) Update(ctx context.Context, cmd *team.UpdateTeamCommand) error { - return ss.db.DB().WithTransactionalDbSession(ctx, func(sess *db.Session) error { + return ss.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { if isNameTaken, err := isTeamNameTaken(cmd.OrgID, cmd.Name, cmd.ID, sess); err != nil { return err } else if isNameTaken { @@ -129,7 +129,7 @@ func (ss *xormStore) Update(ctx context.Context, cmd *team.UpdateTeamCommand) er // DeleteTeam will delete a team, its member and any permissions connected to the team func (ss *xormStore) Delete(ctx context.Context, cmd *team.DeleteTeamCommand) error { - return ss.db.DB().WithTransactionalDbSession(ctx, func(sess *db.Session) error { + return ss.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { if _, err := teamExists(cmd.OrgID, cmd.ID, sess); err != nil { return err } @@ -180,7 +180,7 @@ func (ss *xormStore) Search(ctx context.Context, query *team.SearchTeamsQuery) ( queryResult := team.SearchTeamQueryResult{ Teams: make([]*team.TeamDTO, 0), } - err := ss.db.ReadReplica().WithDbSession(ctx, func(sess *db.Session) error { + err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { queryWithWildcards := "%" + query.Query + "%" var sql bytes.Buffer @@ -191,12 +191,12 @@ func (ss *xormStore) Search(ctx context.Context, query *team.SearchTeamsQuery) ( params = append(params, user) } - sql.WriteString(getTeamSelectSQLBase(ss.db.ReadReplica(), filteredUsers)) + sql.WriteString(getTeamSelectSQLBase(ss.db, filteredUsers)) sql.WriteString(` WHERE team.org_id = ?`) params = append(params, query.OrgID) if query.Query != "" { - sql.WriteString(` and team.name ` + ss.db.ReadReplica().GetDialect().LikeStr() + ` ?`) + sql.WriteString(` and team.name ` + ss.db.GetDialect().LikeStr() + ` ?`) params = append(params, queryWithWildcards) } @@ -233,7 +233,7 @@ func (ss *xormStore) Search(ctx context.Context, query *team.SearchTeamsQuery) ( if query.Limit != 0 { offset := query.Limit * (query.Page - 1) - sql.WriteString(ss.db.ReadReplica().GetDialect().LimitOffset(int64(query.Limit), int64(offset))) + sql.WriteString(ss.db.GetDialect().LimitOffset(int64(query.Limit), int64(offset))) } if err := sess.SQL(sql.String(), params...).Find(&queryResult.Teams); err != nil { @@ -245,7 +245,7 @@ func (ss *xormStore) Search(ctx context.Context, query *team.SearchTeamsQuery) ( countSess.Where("team.org_id=?", query.OrgID) if query.Query != "" { - countSess.Where(`name `+ss.db.ReadReplica().GetDialect().LikeStr()+` ?`, queryWithWildcards) + countSess.Where(`name `+ss.db.GetDialect().LikeStr()+` ?`, queryWithWildcards) } if query.Name != "" { @@ -268,12 +268,12 @@ func (ss *xormStore) Search(ctx context.Context, query *team.SearchTeamsQuery) ( func (ss *xormStore) GetByID(ctx context.Context, query *team.GetTeamByIDQuery) (*team.TeamDTO, error) { var queryResult *team.TeamDTO - err := ss.db.ReadReplica().WithDbSession(ctx, func(sess *db.Session) error { + err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { var sql bytes.Buffer params := make([]any, 0) filteredUsers := getFilteredUsers(query.SignedInUser, query.HiddenUsers) - sql.WriteString(getTeamSelectSQLBase(ss.db.ReadReplica(), filteredUsers)) + sql.WriteString(getTeamSelectSQLBase(ss.db, filteredUsers)) for _, user := range filteredUsers { params = append(params, user) } @@ -304,12 +304,12 @@ func (ss *xormStore) GetByID(ctx context.Context, query *team.GetTeamByIDQuery) // GetTeamsByUser is used by the Guardian when checking a users' permissions func (ss *xormStore) GetByUser(ctx context.Context, query *team.GetTeamsByUserQuery) ([]*team.TeamDTO, error) { queryResult := make([]*team.TeamDTO, 0) - err := ss.db.ReadReplica().WithDbSession(ctx, func(sess *db.Session) error { + err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { var sql bytes.Buffer var params []any params = append(params, query.OrgID, query.UserID) - sql.WriteString(getTeamSelectSQLBase(ss.db.ReadReplica(), []string{})) + sql.WriteString(getTeamSelectSQLBase(ss.db, []string{})) sql.WriteString(` INNER JOIN team_member on team.id = team_member.team_id`) sql.WriteString(` WHERE team.org_id = ? and team_member.user_id = ?`) @@ -333,7 +333,7 @@ func (ss *xormStore) GetByUser(ctx context.Context, query *team.GetTeamsByUserQu func (ss *xormStore) GetIDsByUser(ctx context.Context, query *team.GetTeamIDsByUserQuery) ([]int64, error) { queryResult := make([]int64, 0) - err := ss.db.ReadReplica().WithDbSession(ctx, func(sess *db.Session) error { + err := ss.db.WithDbSession(ctx, func(sess *db.Session) error { return sess.SQL(`SELECT tm.team_id FROM team_member as tm WHERE tm.user_id=? AND tm.org_id=?;`, query.UserID, query.OrgID).Find(&queryResult) @@ -363,7 +363,7 @@ func getTeamMember(sess *db.Session, orgId int64, teamId int64, userId int64) (t func (ss *xormStore) IsMember(orgId int64, teamId int64, userId int64) (bool, error) { var isMember bool - err := ss.db.ReadReplica().WithDbSession(context.Background(), func(sess *db.Session) error { + err := ss.db.WithDbSession(context.Background(), func(sess *db.Session) error { var err error isMember, err = isTeamMember(sess, orgId, teamId, userId) return err @@ -460,7 +460,7 @@ func removeTeamMember(sess *db.Session, cmd *team.RemoveTeamMemberCommand) error // RemoveUsersMemberships removes all the team membership entries for the given user. // Only used when removing a user from a Grafana instance. func (ss *xormStore) RemoveUsersMemberships(ctx context.Context, userID int64) error { - return ss.db.DB().WithTransactionalDbSession(ctx, func(sess *db.Session) error { + return ss.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { var rawSQL = "DELETE FROM team_member WHERE user_id = ?" _, err := sess.Exec(rawSQL, userID) return err @@ -488,7 +488,7 @@ func (ss *xormStore) GetMembers(ctx context.Context, query *team.GetTeamMembersQ // With accesscontrol we filter out users based on the SignedInUser's permissions // Note we assume that checking SignedInUser is allowed to see team members for this team has already been performed // If the signed in user is not set no member will be returned - sqlID := fmt.Sprintf("%s.%s", ss.db.ReadReplica().GetDialect().Quote("user"), ss.db.ReadReplica().GetDialect().Quote("id")) + sqlID := fmt.Sprintf("%s.%s", ss.db.GetDialect().Quote("user"), ss.db.GetDialect().Quote("id")) *acFilter, err = ac.Filter(query.SignedInUser, sqlID, "users:id:", ac.ActionOrgUsersRead) if err != nil { return nil, err @@ -500,16 +500,15 @@ func (ss *xormStore) GetMembers(ctx context.Context, query *team.GetTeamMembersQ // getTeamMembers return a list of members for the specified team func (ss *xormStore) getTeamMembers(ctx context.Context, query *team.GetTeamMembersQuery, acUserFilter *ac.SQLFilter) ([]*team.TeamMemberDTO, error) { queryResult := make([]*team.TeamMemberDTO, 0) - err := ss.db.ReadReplica().WithDbSession(ctx, func(dbSess *db.Session) error { - dialect := ss.db.ReadReplica().GetDialect() + err := ss.db.WithDbSession(ctx, func(dbSess *db.Session) error { sess := dbSess.Table("team_member") - sess.Join("INNER", dialect.Quote("user"), - fmt.Sprintf("team_member.user_id=%s.%s", dialect.Quote("user"), dialect.Quote("id")), + sess.Join("INNER", ss.db.GetDialect().Quote("user"), + fmt.Sprintf("team_member.user_id=%s.%s", ss.db.GetDialect().Quote("user"), ss.db.GetDialect().Quote("id")), ) sess.Join("INNER", "team", "team.id=team_member.team_id") // explicitly check for serviceaccounts - sess.Where(fmt.Sprintf("%s.is_service_account=?", dialect.Quote("user")), dialect.BooleanStr(false)) + sess.Where(fmt.Sprintf("%s.is_service_account=?", ss.db.GetDialect().Quote("user")), ss.db.GetDialect().BooleanStr(false)) if acUserFilter != nil { sess.Where(acUserFilter.Where, acUserFilter.Args...) @@ -521,7 +520,7 @@ func (ss *xormStore) getTeamMembers(ctx context.Context, query *team.GetTeamMemb FROM user_auth WHERE user_auth.user_id = team_member.user_id ORDER BY user_auth.created DESC ` + - dialect.Limit(1) + ")" + ss.db.GetDialect().Limit(1) + ")" sess.Join("LEFT", "user_auth", authJoinCondition) if query.OrgID != 0 { @@ -537,7 +536,7 @@ func (ss *xormStore) getTeamMembers(ctx context.Context, query *team.GetTeamMemb sess.Where("team_member.user_id=?", query.UserID) } if query.External { - sess.Where("team_member.external=?", dialect.BooleanStr(true)) + sess.Where("team_member.external=?", ss.db.GetDialect().BooleanStr(true)) } sess.Cols( "team_member.org_id", diff --git a/pkg/services/team/teamimpl/store_test.go b/pkg/services/team/teamimpl/store_test.go index 1041bdb86ed..066cc02b13e 100644 --- a/pkg/services/team/teamimpl/store_test.go +++ b/pkg/services/team/teamimpl/store_test.go @@ -16,7 +16,6 @@ import ( "github.com/grafana/grafana/pkg/services/org/orgimpl" "github.com/grafana/grafana/pkg/services/quota/quotaimpl" "github.com/grafana/grafana/pkg/services/serviceaccounts" - "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" "github.com/grafana/grafana/pkg/services/team" "github.com/grafana/grafana/pkg/services/team/sortopts" @@ -35,7 +34,7 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { t.Skip("skipping integration test") } t.Run("Testing Team commands and queries", func(t *testing.T) { - sqlStore, cfg := db.InitTestReplDBWithCfg(t) + sqlStore, cfg := db.InitTestDBWithCfg(t) teamSvc, err := ProvideService(sqlStore, cfg, tracing.InitializeTracerForTest()) require.NoError(t, err) testUser := &user.SignedInUser{ @@ -48,7 +47,7 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { }, }, } - quotaService := quotaimpl.ProvideService(sqlstore.FakeReplStoreFromStore(sqlStore.SQLStore), cfg) + quotaService := quotaimpl.ProvideService(sqlStore, cfg) orgSvc, err := orgimpl.ProvideService(sqlStore, cfg, quotaService) require.NoError(t, err) userSvc, err := userimpl.ProvideService( @@ -148,7 +147,7 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { }) t.Run("Should return latest auth module for users when getting team members", func(t *testing.T) { - sqlStore = db.InitTestReplDB(t) + sqlStore = db.InitTestDB(t) setup() userId := userIds[1] @@ -199,7 +198,7 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { }) t.Run("Should default to member permission level when updating a user with invalid permission level", func(t *testing.T) { - sqlStore = db.InitTestReplDB(t) + sqlStore = db.InitTestDB(t) setup() userID := userIds[0] @@ -313,7 +312,7 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { }) t.Run("Should be able to return all teams a user is member of", func(t *testing.T) { - sqlStore = db.InitTestReplDB(t) + sqlStore = db.InitTestDB(t) setup() groupId := team2.ID err = sqlStore.WithDbSession(context.Background(), func(sess *db.Session) error { @@ -374,7 +373,7 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { }) t.Run("A user should be able to remove the admin permission if there are other admins", func(t *testing.T) { - sqlStore = db.InitTestReplDB(t) + sqlStore = db.InitTestDB(t) setup() err = sqlStore.WithDbSession(context.Background(), func(sess *db.Session) error { @@ -393,7 +392,7 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { }) t.Run("Should not return hidden users in team member count", func(t *testing.T) { - sqlStore = db.InitTestReplDB(t) + sqlStore = db.InitTestDB(t) setup() signedInUser := &user.SignedInUser{ Login: "loginuser0", @@ -434,8 +433,8 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { }) t.Run("Should be able to exclude service accounts from teamembers", func(t *testing.T) { - sqlStore = db.InitTestReplDB(t) - quotaService := quotaimpl.ProvideService(sqlstore.FakeReplStoreFromStore(sqlStore.SQLStore), cfg) + sqlStore = db.InitTestDB(t) + quotaService := quotaimpl.ProvideService(sqlStore, cfg) orgSvc, err := orgimpl.ProvideService(sqlStore, cfg, quotaService) require.NoError(t, err) userSvc, err := userimpl.ProvideService( @@ -529,7 +528,7 @@ func TestIntegrationSQLStore_SearchTeams(t *testing.T) { }, } - store, cfg := db.InitTestReplDBWithCfg(t, db.InitTestDBOpt{}) + store, cfg := db.InitTestDBWithCfg(t, db.InitTestDBOpt{}) teamSvc, err := ProvideService(store, cfg, tracing.InitializeTracerForTest()) require.NoError(t, err) @@ -566,18 +565,18 @@ func TestIntegrationSQLStore_GetTeamMembers_ACFilter(t *testing.T) { userIds := make([]int64, 4) // Seed 2 teams with 2 members - setup := func(store db.ReplDB, cfg *setting.Cfg) { + setup := func(store db.DB, cfg *setting.Cfg) { teamSvc, err := ProvideService(store, cfg, tracing.InitializeTracerForTest()) require.NoError(t, err) team1, errCreateTeam := teamSvc.CreateTeam(context.Background(), "group1 name", "test1@example.org", testOrgID) require.NoError(t, errCreateTeam) team2, errCreateTeam := teamSvc.CreateTeam(context.Background(), "group2 name", "test2@example.org", testOrgID) require.NoError(t, errCreateTeam) - quotaService := quotaimpl.ProvideService(db.FakeReplDBFromDB(store.DB()), cfg) - orgSvc, err := orgimpl.ProvideService(store.DB(), cfg, quotaService) + quotaService := quotaimpl.ProvideService(store, cfg) + orgSvc, err := orgimpl.ProvideService(store, cfg, quotaService) require.NoError(t, err) userSvc, err := userimpl.ProvideService( - store.DB(), orgSvc, cfg, teamSvc, nil, tracing.InitializeTracerForTest(), + store, orgSvc, cfg, teamSvc, nil, tracing.InitializeTracerForTest(), quotaService, supportbundlestest.NewFakeBundleService(), ) require.NoError(t, err) @@ -593,7 +592,7 @@ func TestIntegrationSQLStore_GetTeamMembers_ACFilter(t *testing.T) { userIds[i] = user.ID } - errAddMembers := store.DB().WithDbSession(context.Background(), func(sess *db.Session) error { + errAddMembers := store.WithDbSession(context.Background(), func(sess *db.Session) error { err := AddOrUpdateTeamMemberHook(sess, userIds[0], testOrgID, team1.ID, false, 0) if err != nil { return err @@ -611,7 +610,7 @@ func TestIntegrationSQLStore_GetTeamMembers_ACFilter(t *testing.T) { require.NoError(t, errAddMembers) } - store, cfg := db.InitTestReplDBWithCfg(t, db.InitTestDBOpt{}) + store, cfg := db.InitTestDBWithCfg(t, db.InitTestDBOpt{}) setup(store, cfg) teamSvc, err := ProvideService(store, cfg, tracing.InitializeTracerForTest()) require.NoError(t, err) diff --git a/pkg/services/team/teamimpl/team.go b/pkg/services/team/teamimpl/team.go index 14212f22b7f..e7c9537b751 100644 --- a/pkg/services/team/teamimpl/team.go +++ b/pkg/services/team/teamimpl/team.go @@ -17,7 +17,7 @@ type Service struct { tracer tracing.Tracer } -func ProvideService(db db.ReplDB, cfg *setting.Cfg, tracer tracing.Tracer) (team.Service, error) { +func ProvideService(db db.DB, cfg *setting.Cfg, tracer tracing.Tracer) (team.Service, error) { return &Service{ store: &xormStore{db: db, cfg: cfg, deletes: []string{}}, tracer: tracer, diff --git a/pkg/services/user/userimpl/store_test.go b/pkg/services/user/userimpl/store_test.go index 86f38407821..45ed734c2f7 100644 --- a/pkg/services/user/userimpl/store_test.go +++ b/pkg/services/user/userimpl/store_test.go @@ -34,7 +34,7 @@ func TestIntegrationUserDataAccess(t *testing.T) { } ss, cfg := db.InitTestDBWithCfg(t) - quotaService := quotaimpl.ProvideService(sqlstore.FakeReplStoreFromStore(ss), cfg) + quotaService := quotaimpl.ProvideService(ss, cfg) orgService, err := orgimpl.ProvideService(ss, cfg, quotaService) require.NoError(t, err) userStore := ProvideStore(ss, setting.NewCfg()) @@ -903,7 +903,7 @@ func createFiveTestUsers(t *testing.T, svc user.Service, fn func(i int) *user.Cr func TestMetricsUsage(t *testing.T) { ss, cfg := db.InitTestDBWithCfg(t) userStore := ProvideStore(ss, setting.NewCfg()) - quotaService := quotaimpl.ProvideService(sqlstore.FakeReplStoreFromStore(ss), cfg) + quotaService := quotaimpl.ProvideService(ss, cfg) orgService, err := orgimpl.ProvideService(ss, cfg, quotaService) require.NoError(t, err) @@ -962,7 +962,7 @@ func assertEqualUser(t *testing.T, expected, got *user.User) { func createOrgAndUserSvc(t *testing.T, store db.DB, cfg *setting.Cfg) (org.Service, user.Service) { t.Helper() - quotaService := quotaimpl.ProvideService(db.FakeReplDBFromDB(store), cfg) + quotaService := quotaimpl.ProvideService(store, cfg) orgService, err := orgimpl.ProvideService(store, cfg, quotaService) require.NoError(t, err) usrSvc, err := ProvideService( diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 805f36a160a..2488ea5fddd 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -423,6 +423,8 @@ type Cfg struct { // LiveHAEngine is a type of engine to use to achieve HA with Grafana Live. // Zero value means in-memory single node setup. LiveHAEngine string + // LiveHAPRefix is a prefix for HA engine keys. + LiveHAPrefix string // LiveHAEngineAddress is a connection address for Live HA engine. LiveHAEngineAddress string LiveHAEnginePassword string @@ -531,7 +533,8 @@ type Cfg struct { } type UnifiedStorageConfig struct { - DualWriterMode rest.DualWriterMode + DualWriterMode rest.DualWriterMode + DualWriterPeriodicDataSyncJobEnabled bool } type InstallPlugin struct { @@ -2023,6 +2026,7 @@ func (cfg *Cfg) readLiveSettings(iniFile *ini.File) error { default: return fmt.Errorf("unsupported live HA engine type: %s", cfg.LiveHAEngine) } + cfg.LiveHAPrefix = section.Key("ha_prefix").MustString("") cfg.LiveHAEngineAddress = section.Key("ha_engine_address").MustString("127.0.0.1:6379") cfg.LiveHAEnginePassword = section.Key("ha_engine_password").MustString("") diff --git a/pkg/setting/setting_unified_storage.go b/pkg/setting/setting_unified_storage.go index d26dc6c3c06..c004ea29b45 100644 --- a/pkg/setting/setting_unified_storage.go +++ b/pkg/setting/setting_unified_storage.go @@ -25,7 +25,14 @@ func (cfg *Cfg) setUnifiedStorageConfig() { // parse dualWriter modes from the section dualWriterMode := section.Key("dualWriterMode").MustInt(0) - storageConfig[resourceName] = UnifiedStorageConfig{DualWriterMode: rest.DualWriterMode(dualWriterMode)} + + // parse dualWriter periodic data syncer config + dualWriterPeriodicDataSyncJobEnabled := section.Key("dualWriterPeriodicDataSyncJobEnabled").MustBool(false) + + storageConfig[resourceName] = UnifiedStorageConfig{ + DualWriterMode: rest.DualWriterMode(dualWriterMode), + DualWriterPeriodicDataSyncJobEnabled: dualWriterPeriodicDataSyncJobEnabled, + } } cfg.UnifiedStorage = storageConfig } diff --git a/pkg/setting/setting_unified_storage_test.go b/pkg/setting/setting_unified_storage_test.go index bb46ad9a18b..b9227e3a3ee 100644 --- a/pkg/setting/setting_unified_storage_test.go +++ b/pkg/setting/setting_unified_storage_test.go @@ -18,11 +18,17 @@ func TestCfg_setUnifiedStorageConfig(t *testing.T) { _, err = s.NewKey("dualWriterMode", "2") assert.NoError(t, err) + _, err = s.NewKey("dualWriterPeriodicDataSyncJobEnabled", "true") + assert.NoError(t, err) + cfg.setUnifiedStorageConfig() value, exists := cfg.UnifiedStorage["playlists.playlist.grafana.app"] assert.Equal(t, exists, true) - assert.Equal(t, value, UnifiedStorageConfig{DualWriterMode: 2}) + assert.Equal(t, value, UnifiedStorageConfig{ + DualWriterMode: 2, + DualWriterPeriodicDataSyncJobEnabled: true, + }) }) } diff --git a/pkg/tests/api/alerting/api_alertmanager_test.go b/pkg/tests/api/alerting/api_alertmanager_test.go index 9a943e1b1f8..e5d7b5fb01f 100644 --- a/pkg/tests/api/alerting/api_alertmanager_test.go +++ b/pkg/tests/api/alerting/api_alertmanager_test.go @@ -2691,17 +2691,17 @@ func rulesNamespaceWithoutVariableValues(t *testing.T, b []byte) (string, map[st return string(json), m } -func createUser(t *testing.T, store db.DB, cfg *setting.Cfg, cmd user.CreateUserCommand) int64 { +func createUser(t *testing.T, db db.DB, cfg *setting.Cfg, cmd user.CreateUserCommand) int64 { t.Helper() cfg.AutoAssignOrg = true cfg.AutoAssignOrgId = 1 - quotaService := quotaimpl.ProvideService(db.FakeReplDBFromDB(store), cfg) - orgService, err := orgimpl.ProvideService(store, cfg, quotaService) + quotaService := quotaimpl.ProvideService(db, cfg) + orgService, err := orgimpl.ProvideService(db, cfg, quotaService) require.NoError(t, err) usrSvc, err := userimpl.ProvideService( - store, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), + db, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), quotaService, supportbundlestest.NewFakeBundleService(), ) require.NoError(t, err) diff --git a/pkg/tests/api/correlations/common_test.go b/pkg/tests/api/correlations/common_test.go index 28525fe3854..2bd291db9e8 100644 --- a/pkg/tests/api/correlations/common_test.go +++ b/pkg/tests/api/correlations/common_test.go @@ -9,7 +9,6 @@ import ( "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/server" "github.com/grafana/grafana/pkg/services/correlations" @@ -145,7 +144,7 @@ func (c TestContext) createOrg(name string) int64 { c.t.Helper() store := c.env.SQLStore c.env.Cfg.AutoAssignOrg = false - quotaService := quotaimpl.ProvideService(db.FakeReplDBFromDB(store), c.env.Cfg) + quotaService := quotaimpl.ProvideService(store, c.env.Cfg) orgService, err := orgimpl.ProvideService(store, c.env.Cfg, quotaService) require.NoError(c.t, err) orgId, err := orgService.GetOrCreate(context.Background(), name) @@ -159,7 +158,7 @@ func (c TestContext) createUser(cmd user.CreateUserCommand) User { c.env.Cfg.AutoAssignOrg = true c.env.Cfg.AutoAssignOrgId = 1 - quotaService := quotaimpl.ProvideService(db.FakeReplDBFromDB(store), c.env.Cfg) + quotaService := quotaimpl.ProvideService(store, c.env.Cfg) orgService, err := orgimpl.ProvideService(store, c.env.Cfg, quotaService) require.NoError(c.t, err) usrSvc, err := userimpl.ProvideService( @@ -193,6 +192,11 @@ func (c TestContext) createCorrelation(cmd correlations.CreateCorrelationCommand return correlation } +func (c TestContext) createCorrelationPassError(cmd correlations.CreateCorrelationCommand) (correlations.Correlation, error) { + c.t.Helper() + return c.env.Server.HTTPServer.CorrelationsService.CreateCorrelation(context.Background(), cmd) +} + func (c TestContext) createOrUpdateCorrelation(cmd correlations.CreateCorrelationCommand) { c.t.Helper() err := c.env.Server.HTTPServer.CorrelationsService.CreateOrUpdateCorrelation(context.Background(), cmd) diff --git a/pkg/tests/api/correlations/correlations_create_test.go b/pkg/tests/api/correlations/correlations_create_test.go index 1f619d75235..1186477ce27 100644 --- a/pkg/tests/api/correlations/correlations_create_test.go +++ b/pkg/tests/api/correlations/correlations_create_test.go @@ -230,7 +230,7 @@ func TestIntegrationCreateCorrelation(t *testing.T) { description := "a description" label := "a label" fieldName := "fieldName" - corrType := correlations.TypeQuery + corrType := correlations.CorrelationType("query") transformation := correlations.Transformation{Type: "logfmt"} transformation2 := correlations.Transformation{Type: "regex", Expression: "testExpression", MapValue: "testVar"} res := ctx.Post(PostParams{ diff --git a/pkg/tests/api/correlations/correlations_delete_test.go b/pkg/tests/api/correlations/correlations_delete_test.go index 16eaf29b554..76dd80edd46 100644 --- a/pkg/tests/api/correlations/correlations_delete_test.go +++ b/pkg/tests/api/correlations/correlations_delete_test.go @@ -135,6 +135,7 @@ func TestIntegrationDeleteCorrelation(t *testing.T) { TargetUID: &writableDs, OrgId: writableDsOrgId, Provisioned: true, + Type: correlations.CorrelationType("query"), }) res := ctx.Delete(DeleteParams{ @@ -160,6 +161,7 @@ func TestIntegrationDeleteCorrelation(t *testing.T) { SourceUID: writableDs, TargetUID: &writableDs, OrgId: writableDsOrgId, + Type: correlations.CorrelationType("query"), }) res := ctx.Delete(DeleteParams{ @@ -192,6 +194,7 @@ func TestIntegrationDeleteCorrelation(t *testing.T) { SourceUID: writableDs, TargetUID: &readOnlyDS, OrgId: writableDsOrgId, + Type: correlations.CorrelationType("query"), }) res := ctx.Delete(DeleteParams{ @@ -225,6 +228,7 @@ func TestIntegrationDeleteCorrelation(t *testing.T) { TargetUID: &readOnlyDS, OrgId: writableDsOrgId, Provisioned: false, + Type: correlations.CorrelationType("query"), }) ctx.createCorrelation(correlations.CreateCorrelationCommand{ @@ -232,6 +236,7 @@ func TestIntegrationDeleteCorrelation(t *testing.T) { TargetUID: &readOnlyDS, OrgId: writableDsOrgId, Provisioned: true, + Type: correlations.CorrelationType("query"), }) res := ctx.Delete(DeleteParams{ diff --git a/pkg/tests/api/correlations/correlations_provisioning_api_test.go b/pkg/tests/api/correlations/correlations_provisioning_api_test.go index 31e48becddf..0baa39d30f0 100644 --- a/pkg/tests/api/correlations/correlations_provisioning_api_test.go +++ b/pkg/tests/api/correlations/correlations_provisioning_api_test.go @@ -2,6 +2,7 @@ package correlations import ( "encoding/json" + "fmt" "io" "net/http" "testing" @@ -38,8 +39,8 @@ func TestIntegrationCreateOrUpdateCorrelation(t *testing.T) { TargetUID: &dataSource.UID, OrgId: dataSource.OrgID, Label: "needs migration", + Type: correlations.CorrelationType("query"), Config: correlations.CorrelationConfig{ - Type: correlations.TypeQuery, Field: "foo", Target: map[string]any{}, Transformations: []correlations.Transformation{ @@ -54,8 +55,8 @@ func TestIntegrationCreateOrUpdateCorrelation(t *testing.T) { TargetUID: &dataSource.UID, OrgId: dataSource.OrgID, Label: "existing", + Type: correlations.CorrelationType("query"), Config: correlations.CorrelationConfig{ - Type: correlations.TypeQuery, Field: "foo", Target: map[string]any{}, Transformations: []correlations.Transformation{ @@ -65,6 +66,23 @@ func TestIntegrationCreateOrUpdateCorrelation(t *testing.T) { Provisioned: false, }) + // v1 correlation where type is in config + v1Correlation := ctx.createCorrelation(correlations.CreateCorrelationCommand{ + SourceUID: dataSource.UID, + TargetUID: &dataSource.UID, + OrgId: dataSource.OrgID, + Label: "v1 correlation", + Config: correlations.CorrelationConfig{ + Type: correlations.CorrelationType("query"), + Field: "foo", + Target: map[string]any{}, + Transformations: []correlations.Transformation{ + {Type: "logfmt"}, + }, + }, + Provisioned: true, + }) + t.Run("Correctly marks existing correlations as provisioned", func(t *testing.T) { // should be updated ctx.createOrUpdateCorrelation(correlations.CreateCorrelationCommand{ @@ -75,6 +93,7 @@ func TestIntegrationCreateOrUpdateCorrelation(t *testing.T) { Description: needsMigration.Description, Config: needsMigration.Config, Provisioned: true, + Type: needsMigration.Type, }) // should be added @@ -86,6 +105,7 @@ func TestIntegrationCreateOrUpdateCorrelation(t *testing.T) { Description: needsMigration.Description, Config: needsMigration.Config, Provisioned: true, + Type: needsMigration.Type, }) res := ctx.Get(GetParams{ @@ -101,7 +121,7 @@ func TestIntegrationCreateOrUpdateCorrelation(t *testing.T) { err = json.Unmarshal(responseBody, &response) require.NoError(t, err) - require.Len(t, response.Correlations, 3) + require.Len(t, response.Correlations, 4) unordered := make(map[string]correlations.Correlation) for _, v := range response.Correlations { @@ -117,4 +137,44 @@ func TestIntegrationCreateOrUpdateCorrelation(t *testing.T) { require.NoError(t, res.Body.Close()) }) + + t.Run("If Config.Type is query, provision without error but have value outside of config", func(t *testing.T) { + res := ctx.Get(GetParams{ + url: fmt.Sprintf("/api/datasources/uid/%s/correlations/%s", dataSource.UID, v1Correlation.UID), + user: adminUser, + }) + require.Equal(t, http.StatusOK, res.StatusCode) + responseBody, err := io.ReadAll(res.Body) + require.NoError(t, err) + + var response correlations.Correlation + err = json.Unmarshal(responseBody, &response) + require.NoError(t, err) + + require.EqualValues(t, response.Config.Type, "") + require.EqualValues(t, v1Correlation.Config.Type, response.Type) + + require.NoError(t, res.Body.Close()) + }) + + t.Run("If Config.type is not query, throw an error", func(t *testing.T) { + _, err := ctx.createCorrelationPassError(correlations.CreateCorrelationCommand{ + SourceUID: dataSource.UID, + TargetUID: &dataSource.UID, + OrgId: dataSource.OrgID, + Label: "bad v1 correlation", + Config: correlations.CorrelationConfig{ + Type: correlations.CorrelationType("external"), + Field: "foo", + Target: map[string]any{}, + Transformations: []correlations.Transformation{ + {Type: "logfmt"}, + }, + }, + Provisioned: true, + }) + + require.Error(t, err) + require.ErrorIs(t, err, correlations.ErrInvalidConfigType) + }) } diff --git a/pkg/tests/api/correlations/correlations_read_test.go b/pkg/tests/api/correlations/correlations_read_test.go index 79cc73bcf48..2db12b826e3 100644 --- a/pkg/tests/api/correlations/correlations_read_test.go +++ b/pkg/tests/api/correlations/correlations_read_test.go @@ -77,7 +77,7 @@ func TestIntegrationReadCorrelation(t *testing.T) { SourceUID: dsWithCorrelations.UID, TargetUID: &dsWithCorrelations.UID, OrgId: dsWithCorrelations.OrgID, - Type: correlations.TypeQuery, + Type: correlations.CorrelationType("query"), Config: correlations.CorrelationConfig{ Field: "foo", Target: map[string]any{}, diff --git a/pkg/tests/api/correlations/correlations_update_test.go b/pkg/tests/api/correlations/correlations_update_test.go index 2a203542770..2c42d37585e 100644 --- a/pkg/tests/api/correlations/correlations_update_test.go +++ b/pkg/tests/api/correlations/correlations_update_test.go @@ -128,6 +128,7 @@ func TestIntegrationUpdateCorrelation(t *testing.T) { TargetUID: &writableDs, OrgId: writableDsOrgId, Provisioned: true, + Type: correlations.CorrelationType("query"), }) res := ctx.Patch(PatchParams{ @@ -155,6 +156,7 @@ func TestIntegrationUpdateCorrelation(t *testing.T) { SourceUID: writableDs, TargetUID: &writableDs, OrgId: writableDsOrgId, + Type: correlations.CorrelationType("query"), }) // no params @@ -220,6 +222,7 @@ func TestIntegrationUpdateCorrelation(t *testing.T) { TargetUID: &writableDs, OrgId: writableDsOrgId, Label: "a label", + Type: correlations.CorrelationType("query"), }) res := ctx.Patch(PatchParams{ @@ -249,9 +252,9 @@ func TestIntegrationUpdateCorrelation(t *testing.T) { OrgId: writableDsOrgId, Label: "0", Description: "0", + Type: correlations.CorrelationType("query"), Config: correlations.CorrelationConfig{ Field: "fieldName", - Type: "query", Target: map[string]any{"expr": "foo"}, }, }) diff --git a/pkg/tests/api/dashboards/api_dashboards_test.go b/pkg/tests/api/dashboards/api_dashboards_test.go index f3a6d3c1a23..604f0df60b8 100644 --- a/pkg/tests/api/dashboards/api_dashboards_test.go +++ b/pkg/tests/api/dashboards/api_dashboards_test.go @@ -115,17 +115,17 @@ func TestIntegrationDashboardQuota(t *testing.T) { }) } -func createUser(t *testing.T, store db.DB, cfg *setting.Cfg, cmd user.CreateUserCommand) int64 { +func createUser(t *testing.T, db db.DB, cfg *setting.Cfg, cmd user.CreateUserCommand) int64 { t.Helper() cfg.AutoAssignOrg = true cfg.AutoAssignOrgId = 1 - quotaService := quotaimpl.ProvideService(db.FakeReplDBFromDB(store), cfg) - orgService, err := orgimpl.ProvideService(store, cfg, quotaService) + quotaService := quotaimpl.ProvideService(db, cfg) + orgService, err := orgimpl.ProvideService(db, cfg, quotaService) require.NoError(t, err) usrSvc, err := userimpl.ProvideService( - store, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), + db, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), quotaService, supportbundlestest.NewFakeBundleService(), ) require.NoError(t, err) diff --git a/pkg/tests/api/folders/api_folder_test.go b/pkg/tests/api/folders/api_folder_test.go index dcb2ef7e28c..c61e94b0cd9 100644 --- a/pkg/tests/api/folders/api_folder_test.go +++ b/pkg/tests/api/folders/api_folder_test.go @@ -9,9 +9,6 @@ import ( "github.com/go-openapi/runtime" "github.com/grafana/grafana-openapi-client-go/client/folders" "github.com/grafana/grafana-openapi-client-go/models" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -24,6 +21,8 @@ import ( "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tests" "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) const orgID = 1 @@ -208,17 +207,17 @@ func TestIntegrationNestedFoldersOn(t *testing.T) { }) } -func createUser(t *testing.T, store db.DB, cfg *setting.Cfg, cmd user.CreateUserCommand) int64 { +func createUser(t *testing.T, db db.DB, cfg *setting.Cfg, cmd user.CreateUserCommand) int64 { t.Helper() cfg.AutoAssignOrg = true cfg.AutoAssignOrgId = orgID - quotaService := quotaimpl.ProvideService(db.FakeReplDBFromDB(store), cfg) - orgService, err := orgimpl.ProvideService(store, cfg, quotaService) + quotaService := quotaimpl.ProvideService(db, cfg) + orgService, err := orgimpl.ProvideService(db, cfg, quotaService) require.NoError(t, err) usrSvc, err := userimpl.ProvideService( - store, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), + db, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), quotaService, supportbundlestest.NewFakeBundleService(), ) require.NoError(t, err) diff --git a/pkg/tests/api/plugins/api_plugins_test.go b/pkg/tests/api/plugins/api_plugins_test.go index cfdd8851692..ba04251c1fb 100644 --- a/pkg/tests/api/plugins/api_plugins_test.go +++ b/pkg/tests/api/plugins/api_plugins_test.go @@ -193,17 +193,17 @@ func TestIntegrationPluginAssets(t *testing.T) { }) } -func createUser(t *testing.T, store db.DB, cfg *setting.Cfg, cmd user.CreateUserCommand) { +func createUser(t *testing.T, db db.DB, cfg *setting.Cfg, cmd user.CreateUserCommand) { t.Helper() cfg.AutoAssignOrg = true cfg.AutoAssignOrgId = 1 - quotaService := quotaimpl.ProvideService(db.FakeReplDBFromDB(store), cfg) - orgService, err := orgimpl.ProvideService(store, cfg, quotaService) + quotaService := quotaimpl.ProvideService(db, cfg) + orgService, err := orgimpl.ProvideService(db, cfg, quotaService) require.NoError(t, err) usrSvc, err := userimpl.ProvideService( - store, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), + db, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), quotaService, supportbundlestest.NewFakeBundleService(), ) require.NoError(t, err) diff --git a/pkg/tests/api/stats/admin_test.go b/pkg/tests/api/stats/admin_test.go index eb65250778c..3e264a75e0c 100644 --- a/pkg/tests/api/stats/admin_test.go +++ b/pkg/tests/api/stats/admin_test.go @@ -81,17 +81,17 @@ func grafanaSetup(t *testing.T, opts testinfra.GrafanaOpts) string { return fmt.Sprintf("http://%s:%s@%s/api/admin/stats", "grafana", "password", grafanaListedAddr) } -func createUser(t *testing.T, store db.DB, cfg *setting.Cfg, cmd user.CreateUserCommand) int64 { +func createUser(t *testing.T, db db.DB, cfg *setting.Cfg, cmd user.CreateUserCommand) int64 { t.Helper() cfg.AutoAssignOrg = true cfg.AutoAssignOrgId = 1 - quotaService := quotaimpl.ProvideService(db.FakeReplDBFromDB(store), cfg) - orgService, err := orgimpl.ProvideService(store, cfg, quotaService) + quotaService := quotaimpl.ProvideService(db, cfg) + orgService, err := orgimpl.ProvideService(db, cfg, quotaService) require.NoError(t, err) usrSvc, err := userimpl.ProvideService( - store, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), + db, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), quotaService, supportbundlestest.NewFakeBundleService(), ) require.NoError(t, err) diff --git a/pkg/tests/apis/alerting/notifications/template_group/templates_group_test.go b/pkg/tests/apis/alerting/notifications/template_group/templates_group_test.go new file mode 100644 index 00000000000..6a942075285 --- /dev/null +++ b/pkg/tests/apis/alerting/notifications/template_group/templates_group_test.go @@ -0,0 +1,629 @@ +package templateGroup + +import ( + "context" + "encoding/json" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/api/errors" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1" + "github.com/grafana/grafana/pkg/generated/clientset/versioned" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" + "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" + "github.com/grafana/grafana/pkg/services/authz/zanzana" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/folder/foldertest" + "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/store" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/tests/apis" + "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" + "github.com/grafana/grafana/pkg/util" +) + +func TestMain(m *testing.M) { + testsuite.Run(m) +} + +func getTestHelper(t *testing.T) *apis.K8sTestHelper { + return apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + EnableFeatureToggles: []string{ + featuremgmt.FlagAlertingApiServer, + }, + }) +} + +func TestIntegrationResourceIdentifier(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + ctx := context.Background() + helper := getTestHelper(t) + adminK8sClient, err := versioned.NewForConfig(helper.Org1.Admin.NewRestConfig()) + require.NoError(t, err) + client := adminK8sClient.NotificationsV0alpha1().TemplateGroups("default") + + newTemplate := &v0alpha1.TemplateGroup{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + }, + Spec: v0alpha1.TemplateGroupSpec{ + Title: "templateGroup", + Content: `{{ define "test" }} test {{ end }}`, + }, + } + + t.Run("create should fail if object name is specified", func(t *testing.T) { + template := newTemplate.DeepCopy() + template.Name = "new-templateGroup" + _, err := client.Create(ctx, template, v1.CreateOptions{}) + assert.Error(t, err) + require.Truef(t, errors.IsBadRequest(err), "Expected BadRequest but got %s", err) + }) + + var resourceID string + t.Run("create should succeed and provide resource name", func(t *testing.T) { + actual, err := client.Create(ctx, newTemplate, v1.CreateOptions{}) + require.NoError(t, err) + require.NotEmptyf(t, actual.Name, "Resource name should not be empty") + require.NotEmptyf(t, actual.UID, "Resource UID should not be empty") + resourceID = actual.Name + }) + + var existingTemplateGroup *v0alpha1.TemplateGroup + t.Run("resource should be available by the identifier", func(t *testing.T) { + actual, err := client.Get(ctx, resourceID, v1.GetOptions{}) + require.NoError(t, err) + require.NotEmptyf(t, actual.Name, "Resource name should not be empty") + require.Equal(t, newTemplate.Spec, actual.Spec) + existingTemplateGroup = actual + }) + + t.Run("update should rename template if name in the specification changes", func(t *testing.T) { + if existingTemplateGroup == nil { + t.Skip() + } + updated := existingTemplateGroup.DeepCopy() + updated.Spec.Title = "another-templateGroup" + actual, err := client.Update(ctx, updated, v1.UpdateOptions{}) + require.NoError(t, err) + require.Equal(t, updated.Spec, actual.Spec) + require.NotEqualf(t, updated.Name, actual.Name, "Update should change the resource name but it didn't") + + resource, err := client.Get(ctx, actual.Name, v1.GetOptions{}) + require.NoError(t, err) + require.Equal(t, actual, resource) + }) +} + +func TestIntegrationAccessControl(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + ctx := context.Background() + helper := getTestHelper(t) + + org1 := helper.Org1 + + type testCase struct { + user apis.User + canRead bool + canUpdate bool + canCreate bool + canDelete bool + } + + reader := helper.CreateUser("TemplatesReader", apis.Org1, org.RoleNone, []resourcepermissions.SetResourcePermissionCommand{ + { + Actions: []string{ + accesscontrol.ActionAlertingNotificationsTemplatesRead, + }, + }, + }) + writer := helper.CreateUser("TemplatesWriter", "Org1", org.RoleNone, []resourcepermissions.SetResourcePermissionCommand{ + { + Actions: []string{ + accesscontrol.ActionAlertingNotificationsTemplatesRead, + accesscontrol.ActionAlertingNotificationsTemplatesWrite, + }, + }, + }) + + deleter := helper.CreateUser("TemplatesDeleter", apis.Org1, org.RoleNone, []resourcepermissions.SetResourcePermissionCommand{ + { + Actions: []string{ + accesscontrol.ActionAlertingNotificationsTemplatesRead, + accesscontrol.ActionAlertingNotificationsTemplatesDelete, + }, + }, + }) + + testCases := []testCase{ + { + user: org1.Admin, + canRead: true, + canUpdate: true, + canCreate: true, + canDelete: true, + }, + { + user: org1.Editor, + canRead: true, + canUpdate: true, + canCreate: true, + canDelete: true, + }, + { + user: org1.Viewer, + canRead: true, + }, + { + user: reader, + canRead: true, + }, + { + user: writer, + canRead: true, + canCreate: true, + canUpdate: true, + }, + { + user: deleter, + canRead: true, + canDelete: true, + }, + } + + admin := org1.Admin + adminK8sClient, err := versioned.NewForConfig(admin.NewRestConfig()) + require.NoError(t, err) + adminClient := adminK8sClient.NotificationsV0alpha1().TemplateGroups("default") + + for _, tc := range testCases { + t.Run(fmt.Sprintf("user '%s'", tc.user.Identity.GetLogin()), func(t *testing.T) { + k8sClient, err := versioned.NewForConfig(tc.user.NewRestConfig()) + require.NoError(t, err) + client := k8sClient.NotificationsV0alpha1().TemplateGroups("default") + + var expected = &v0alpha1.TemplateGroup{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + }, + Spec: v0alpha1.TemplateGroupSpec{ + Title: fmt.Sprintf("template-group-1-%s", tc.user.Identity.GetLogin()), + Content: `{{ define "test" }} test {{ end }}`, + }, + } + expected.SetProvenanceStatus("") + d, err := json.Marshal(expected) + require.NoError(t, err) + + if tc.canCreate { + t.Run("should be able to create template group", func(t *testing.T) { + actual, err := client.Create(ctx, expected, v1.CreateOptions{}) + require.NoErrorf(t, err, "Payload %s", string(d)) + require.Equal(t, expected.Spec, actual.Spec) + + t.Run("should fail if already exists", func(t *testing.T) { + _, err := client.Create(ctx, actual, v1.CreateOptions{}) + require.Truef(t, errors.IsBadRequest(err), "expected bad request but got %s", err) + }) + + expected = actual + }) + } else { + t.Run("should be forbidden to create", func(t *testing.T) { + _, err := client.Create(ctx, expected, v1.CreateOptions{}) + require.Truef(t, errors.IsForbidden(err), "Payload %s", string(d)) + }) + + // create resource to proceed with other tests + expected, err = adminClient.Create(ctx, expected, v1.CreateOptions{}) + require.NoErrorf(t, err, "Payload %s", string(d)) + require.NotNil(t, expected) + } + + if tc.canRead { + t.Run("should be able to list template groups", func(t *testing.T) { + list, err := client.List(ctx, v1.ListOptions{}) + require.NoError(t, err) + require.Len(t, list.Items, 1) + }) + + t.Run("should be able to read template group by resource identifier", func(t *testing.T) { + got, err := client.Get(ctx, expected.Name, v1.GetOptions{}) + require.NoError(t, err) + require.Equal(t, expected, got) + + t.Run("should get NotFound if resource does not exist", func(t *testing.T) { + _, err := client.Get(ctx, "Notfound", v1.GetOptions{}) + require.Truef(t, errors.IsNotFound(err), "Should get NotFound error but got: %s", err) + }) + }) + } else { + t.Run("should be forbidden to list template groups", func(t *testing.T) { + _, err := client.List(ctx, v1.ListOptions{}) + require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) + }) + + t.Run("should be forbidden to read template group by name", func(t *testing.T) { + _, err := client.Get(ctx, expected.Name, v1.GetOptions{}) + require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) + + t.Run("should get forbidden even if name does not exist", func(t *testing.T) { + _, err := client.Get(ctx, "Notfound", v1.GetOptions{}) + require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) + }) + }) + } + + updatedExpected := expected.DeepCopy() + updatedExpected.Spec.Content = `{{ define "another-test" }} test {{ end }}` + + d, err = json.Marshal(updatedExpected) + require.NoError(t, err) + + if tc.canUpdate { + t.Run("should be able to update template group", func(t *testing.T) { + updated, err := client.Update(ctx, updatedExpected, v1.UpdateOptions{}) + require.NoErrorf(t, err, "Payload %s", string(d)) + + expected = updated + + t.Run("should get NotFound if name does not exist", func(t *testing.T) { + up := updatedExpected.DeepCopy() + up.Name = "notFound" + _, err := client.Update(ctx, up, v1.UpdateOptions{}) + require.Truef(t, errors.IsNotFound(err), "Should get NotFound error but got: %s", err) + }) + }) + } else { + t.Run("should be forbidden to update template group", func(t *testing.T) { + _, err := client.Update(ctx, updatedExpected, v1.UpdateOptions{}) + require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) + + t.Run("should get forbidden even if resource does not exist", func(t *testing.T) { + up := updatedExpected.DeepCopy() + up.Name = "notFound" + _, err := client.Update(ctx, up, v1.UpdateOptions{}) + require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) + }) + }) + } + + deleteOptions := v1.DeleteOptions{Preconditions: &v1.Preconditions{ResourceVersion: util.Pointer(expected.ResourceVersion)}} + + if tc.canDelete { + t.Run("should be able to delete template group", func(t *testing.T) { + err := client.Delete(ctx, expected.Name, deleteOptions) + require.NoError(t, err) + + t.Run("should get NotFound if name does not exist", func(t *testing.T) { + err := client.Delete(ctx, "notfound", v1.DeleteOptions{}) + require.Truef(t, errors.IsNotFound(err), "Should get NotFound error but got: %s", err) + }) + }) + } else { + t.Run("should be forbidden to delete template group", func(t *testing.T) { + err := client.Delete(ctx, expected.Name, deleteOptions) + require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) + + t.Run("should be forbidden even if resource does not exist", func(t *testing.T) { + err := client.Delete(ctx, "notfound", v1.DeleteOptions{}) + require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) + }) + }) + require.NoError(t, adminClient.Delete(ctx, expected.Name, v1.DeleteOptions{})) + } + + if tc.canRead { + t.Run("should get empty list if no mute timings", func(t *testing.T) { + list, err := client.List(ctx, v1.ListOptions{}) + require.NoError(t, err) + require.Len(t, list.Items, 0) + }) + } + }) + } +} + +func TestIntegrationProvisioning(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + ctx := context.Background() + helper := getTestHelper(t) + + org := helper.Org1 + + admin := org.Admin + adminK8sClient, err := versioned.NewForConfig(admin.NewRestConfig()) + require.NoError(t, err) + adminClient := adminK8sClient.NotificationsV0alpha1().TemplateGroups("default") + + env := helper.GetEnv() + ac := acimpl.ProvideAccessControl(env.FeatureToggles, zanzana.NewNoopClient()) + db, err := store.ProvideDBStore(env.Cfg, env.FeatureToggles, env.SQLStore, &foldertest.FakeService{}, &dashboards.FakeDashboardService{}, ac) + require.NoError(t, err) + + created, err := adminClient.Create(ctx, &v0alpha1.TemplateGroup{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + }, + Spec: v0alpha1.TemplateGroupSpec{ + Title: "template-group-1", + Content: `{{ define "test" }} test {{ end }}`, + }, + }, v1.CreateOptions{}) + require.NoError(t, err) + require.Equal(t, "none", created.GetProvenanceStatus()) + + t.Run("should provide provenance status", func(t *testing.T) { + require.NoError(t, db.SetProvenance(ctx, &definitions.NotificationTemplate{ + Name: created.Spec.Title, + }, admin.Identity.GetOrgID(), "API")) + + got, err := adminClient.Get(ctx, created.Name, v1.GetOptions{}) + require.NoError(t, err) + require.Equal(t, "API", got.GetProvenanceStatus()) + }) + t.Run("should not let update if provisioned", func(t *testing.T) { + updated := created.DeepCopy() + updated.Spec.Content = `{{ define "another-test" }} test {{ end }}` + + _, err := adminClient.Update(ctx, updated, v1.UpdateOptions{}) + require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) + }) + + t.Run("should not let delete if provisioned", func(t *testing.T) { + err := adminClient.Delete(ctx, created.Name, v1.DeleteOptions{}) + require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err) + }) +} + +func TestIntegrationOptimisticConcurrency(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + ctx := context.Background() + helper := getTestHelper(t) + + adminK8sClient, err := versioned.NewForConfig(helper.Org1.Admin.NewRestConfig()) + require.NoError(t, err) + adminClient := adminK8sClient.NotificationsV0alpha1().TemplateGroups("default") + + template := v0alpha1.TemplateGroup{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + }, + Spec: v0alpha1.TemplateGroupSpec{ + Title: "template-group-1", + Content: `{{ define "test" }} test {{ end }}`, + }, + } + + created, err := adminClient.Create(ctx, &template, v1.CreateOptions{}) + require.NoError(t, err) + require.NotNil(t, created) + require.NotEmpty(t, created.ResourceVersion) + + t.Run("should forbid if version does not match", func(t *testing.T) { + updated := created.DeepCopy() + updated.ResourceVersion = "test" + _, err := adminClient.Update(ctx, updated, v1.UpdateOptions{}) + require.Truef(t, errors.IsConflict(err), "should get Forbidden error but got %s", err) + }) + t.Run("should update if version matches", func(t *testing.T) { + updated := created.DeepCopy() + updated.Spec.Content = `{{ define "test-another" }} test {{ end }}` + actualUpdated, err := adminClient.Update(ctx, updated, v1.UpdateOptions{}) + require.NoError(t, err) + require.EqualValues(t, updated.Spec, actualUpdated.Spec) + require.NotEqual(t, updated.ResourceVersion, actualUpdated.ResourceVersion) + }) + t.Run("should update if version is empty", func(t *testing.T) { + updated := created.DeepCopy() + updated.ResourceVersion = "" + updated.Spec.Content = `{{ define "test-another-2" }} test {{ end }}` + + actualUpdated, err := adminClient.Update(ctx, updated, v1.UpdateOptions{}) + require.NoError(t, err) + require.EqualValues(t, updated.Spec, actualUpdated.Spec) + require.NotEqual(t, created.ResourceVersion, actualUpdated.ResourceVersion) + }) + t.Run("should fail to delete if version does not match", func(t *testing.T) { + actual, err := adminClient.Get(ctx, created.Name, v1.GetOptions{}) + require.NoError(t, err) + + err = adminClient.Delete(ctx, actual.Name, v1.DeleteOptions{ + Preconditions: &v1.Preconditions{ + ResourceVersion: util.Pointer("something"), + }, + }) + require.Truef(t, errors.IsConflict(err), "should get Forbidden error but got %s", err) + }) + t.Run("should succeed if version matches", func(t *testing.T) { + actual, err := adminClient.Get(ctx, created.Name, v1.GetOptions{}) + require.NoError(t, err) + + err = adminClient.Delete(ctx, actual.Name, v1.DeleteOptions{ + Preconditions: &v1.Preconditions{ + ResourceVersion: util.Pointer(actual.ResourceVersion), + }, + }) + require.NoError(t, err) + }) + t.Run("should succeed if version is empty", func(t *testing.T) { + actual, err := adminClient.Create(ctx, &template, v1.CreateOptions{}) + require.NoError(t, err) + + err = adminClient.Delete(ctx, actual.Name, v1.DeleteOptions{ + Preconditions: &v1.Preconditions{ + ResourceVersion: util.Pointer(actual.ResourceVersion), + }, + }) + require.NoError(t, err) + }) +} + +func TestIntegrationPatch(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + ctx := context.Background() + helper := getTestHelper(t) + + adminK8sClient, err := versioned.NewForConfig(helper.Org1.Admin.NewRestConfig()) + require.NoError(t, err) + adminClient := adminK8sClient.NotificationsV0alpha1().TemplateGroups("default") + + template := v0alpha1.TemplateGroup{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + }, + Spec: v0alpha1.TemplateGroupSpec{ + Title: "template-group", + Content: `{{ define "test" }} test {{ end }}`, + }, + } + + current, err := adminClient.Create(ctx, &template, v1.CreateOptions{}) + require.NoError(t, err) + require.NotNil(t, current) + require.NotEmpty(t, current.ResourceVersion) + + t.Run("should patch with merge patch", func(t *testing.T) { + patch := `{ + "spec": { + "content" : "{{ define \"test-another\" }} test {{ end }}" + } + }` + + result, err := adminClient.Patch(ctx, current.Name, types.MergePatchType, []byte(patch), v1.PatchOptions{}) + require.NoError(t, err) + require.Equal(t, `{{ define "test-another" }} test {{ end }}`, result.Spec.Content) + current = result + }) + + t.Run("should patch with json patch", func(t *testing.T) { + expected := `{{ define "test-json-patch" }} test {{ end }}` + + patch := []map[string]interface{}{ + { + "op": "replace", + "path": "/spec/content", + "value": expected, + }, + } + + patchData, err := json.Marshal(patch) + require.NoError(t, err) + + result, err := adminClient.Patch(ctx, current.Name, types.JSONPatchType, patchData, v1.PatchOptions{}) + require.NoError(t, err) + expectedSpec := *current.Spec.DeepCopy() + expectedSpec.Content = expected + require.EqualValues(t, expectedSpec, result.Spec) + current = result + }) +} + +func TestIntegrationListSelector(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + ctx := context.Background() + helper := getTestHelper(t) + + adminK8sClient, err := versioned.NewForConfig(helper.Org1.Admin.NewRestConfig()) + require.NoError(t, err) + adminClient := adminK8sClient.NotificationsV0alpha1().TemplateGroups("default") + + template1 := &v0alpha1.TemplateGroup{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + }, + Spec: v0alpha1.TemplateGroupSpec{ + Title: "test1", + Content: `{{ define "test1" }} test {{ end }}`, + }, + } + template1, err = adminClient.Create(ctx, template1, v1.CreateOptions{}) + require.NoError(t, err) + + template2 := &v0alpha1.TemplateGroup{ + ObjectMeta: v1.ObjectMeta{ + Namespace: "default", + }, + Spec: v0alpha1.TemplateGroupSpec{ + Title: "test2", + Content: `{{ define "test2" }} test {{ end }}`, + }, + } + template2, err = adminClient.Create(ctx, template2, v1.CreateOptions{}) + require.NoError(t, err) + env := helper.GetEnv() + ac := acimpl.ProvideAccessControl(env.FeatureToggles, zanzana.NewNoopClient()) + db, err := store.ProvideDBStore(env.Cfg, env.FeatureToggles, env.SQLStore, &foldertest.FakeService{}, &dashboards.FakeDashboardService{}, ac) + require.NoError(t, err) + require.NoError(t, db.SetProvenance(ctx, &definitions.NotificationTemplate{ + Name: template2.Spec.Title, + }, helper.Org1.Admin.Identity.GetOrgID(), "API")) + template2, err = adminClient.Get(ctx, template2.Name, v1.GetOptions{}) + + require.NoError(t, err) + + templates, err := adminClient.List(ctx, v1.ListOptions{}) + require.NoError(t, err) + require.Len(t, templates.Items, 2) + + t.Run("should filter by template name", func(t *testing.T) { + list, err := adminClient.List(ctx, v1.ListOptions{ + FieldSelector: "spec.title=" + template1.Spec.Title, + }) + require.NoError(t, err) + require.Len(t, list.Items, 1) + require.Equal(t, template1.Name, list.Items[0].Name) + }) + + t.Run("should filter by template metadata name", func(t *testing.T) { + list, err := adminClient.List(ctx, v1.ListOptions{ + FieldSelector: "metadata.name=" + template2.Name, + }) + require.NoError(t, err) + require.Len(t, list.Items, 1) + require.Equal(t, template2.Name, list.Items[0].Name) + }) + + t.Run("should filter by multiple filters", func(t *testing.T) { + list, err := adminClient.List(ctx, v1.ListOptions{ + FieldSelector: fmt.Sprintf("metadata.name=%s,metadata.provenance=%s", template2.Name, "API"), + }) + require.NoError(t, err) + require.Len(t, list.Items, 1) + require.Equal(t, template2.Name, list.Items[0].Name) + }) + + t.Run("should be empty when filter does not match", func(t *testing.T) { + list, err := adminClient.List(ctx, v1.ListOptions{ + FieldSelector: fmt.Sprintf("metadata.name=%s,metadata.provenance=%s", template2.Name, "unknown"), + }) + require.NoError(t, err) + require.Empty(t, list.Items) + }) +} diff --git a/pkg/tests/apis/datasource/testdata_test.go b/pkg/tests/apis/datasource/testdata_test.go index da3283ec8e3..3ed3b620c16 100644 --- a/pkg/tests/apis/datasource/testdata_test.go +++ b/pkg/tests/apis/datasource/testdata_test.go @@ -2,6 +2,7 @@ package dashboards import ( "context" + "fmt" "testing" "github.com/stretchr/testify/require" @@ -42,7 +43,7 @@ func TestIntegrationTestDatasource(t *testing.T) { t.Run("Check discovery client", func(t *testing.T) { disco := helper.GetGroupVersionInfoJSON("testdata.datasource.grafana.app") - // fmt.Printf("%s", disco) + fmt.Printf("%s", disco) require.JSONEq(t, `[ { @@ -103,7 +104,20 @@ func TestIntegrationTestDatasource(t *testing.T) { "get", "list" ] - } + }, + { + "resource": "queryconvert", + "responseKind": { + "group": "", + "kind": "QueryDataRequest", + "version": "" + }, + "scope": "Namespaced", + "singularResource": "queryconvert", + "verbs": [ + "create" + ] + } ], "version": "v0alpha1" } diff --git a/pkg/tests/apis/folder/folders_test.go b/pkg/tests/apis/folder/folders_test.go index 6cb319b9b03..edfd10d6ed7 100644 --- a/pkg/tests/apis/folder/folders_test.go +++ b/pkg/tests/apis/folder/folders_test.go @@ -296,10 +296,6 @@ func doFolderTests(t *testing.T, helper *apis.K8sTestHelper) *apis.K8sTestHelper "apiVersion": "folder.grafana.app/v0alpha1", "kind": "Folder", "metadata": { - "annotations": { - "grafana.app/originPath": "${originPath}", - "grafana.app/originName": "SQL" - }, "creationTimestamp": "${creationTimestamp}", "name": "` + uid + `", "namespace": "default", diff --git a/pkg/tests/apis/helper.go b/pkg/tests/apis/helper.go index 27869465c28..12f1aa512ed 100644 --- a/pkg/tests/apis/helper.go +++ b/pkg/tests/apis/helper.go @@ -20,12 +20,12 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/serializer/yaml" yamlutil "k8s.io/apimachinery/pkg/util/yaml" + "k8s.io/client-go/discovery" "k8s.io/client-go/dynamic" "k8s.io/client-go/rest" "github.com/grafana/grafana/pkg/apimachinery/identity" - "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/server" @@ -187,7 +187,7 @@ func (c *K8sResourceClient) SpecJSON(v *unstructured.UnstructuredList) string { // remove the meta keys that are expected to change each time func (c *K8sResourceClient) SanitizeJSON(v *unstructured.Unstructured, replaceMeta ...string) string { c.t.Helper() - copy := c.sanitizeObject(v) + copy := c.sanitizeObject(v, replaceMeta...) out, err := json.MarshalIndent(copy, "", " ") // fmt.Printf("%s", out) @@ -200,20 +200,8 @@ func (c *K8sResourceClient) sanitizeObject(v *unstructured.Unstructured, replace c.t.Helper() deep := v.DeepCopy() - anno := deep.GetAnnotations() - if anno["grafana.app/originPath"] != "" { - anno["grafana.app/originPath"] = "${originPath}" - } - if anno["grafana.app/originHash"] != "" { - anno["grafana.app/originHash"] = "${originHash}" - } - // Remove annotations that are not added by legacy storage - delete(anno, utils.AnnoKeyOriginTimestamp) - delete(anno, utils.AnnoKeyCreatedBy) - delete(anno, utils.AnnoKeyUpdatedBy) - delete(anno, utils.AnnoKeyUpdatedTimestamp) - - deep.SetAnnotations(anno) + deep.SetAnnotations(nil) + deep.SetManagedFields(nil) copy := deep.Object meta, ok := copy["metadata"].(map[string]any) require.True(c.t, ok) @@ -226,6 +214,7 @@ func (c *K8sResourceClient) sanitizeObject(v *unstructured.Unstructured, replace meta[key] = fmt.Sprintf("${%s}", key) } } + deep.Object["metadata"] = meta return deep } @@ -449,13 +438,12 @@ func (c *K8sTestHelper) CreateUser(name string, orgName string, basicRole org.Ro c.t.Helper() store := c.env.SQLStore - replStore := c.env.ReadReplStore defer func() { c.env.Cfg.AutoAssignOrg = false c.env.Cfg.AutoAssignOrgId = 1 // the default }() - quotaService := quotaimpl.ProvideService(replStore, c.env.Cfg) + quotaService := quotaimpl.ProvideService(store, c.env.Cfg) orgService, err := orgimpl.ProvideService(store, c.env.Cfg, quotaService) require.NoError(c.t, err) @@ -476,7 +464,7 @@ func (c *K8sTestHelper) CreateUser(name string, orgName string, basicRole org.Ro c.env.Cfg.AutoAssignOrg = true c.env.Cfg.AutoAssignOrgId = int(orgId) - teamSvc, err := teamimpl.ProvideService(replStore, c.env.Cfg, tracing.InitializeTracerForTest()) + teamSvc, err := teamimpl.ProvideService(store, c.env.Cfg, tracing.InitializeTracerForTest()) require.NoError(c.t, err) cache := localcache.ProvideService() @@ -545,7 +533,7 @@ func (c *K8sTestHelper) SetPermissions(user User, permissions []resourcepermissi } func (c *K8sTestHelper) AddOrUpdateTeamMember(user User, teamID int64, permission team.PermissionType) { - teamSvc, err := teamimpl.ProvideService(c.env.ReadReplStore, c.env.Cfg, tracing.InitializeTracerForTest()) + teamSvc, err := teamimpl.ProvideService(c.env.SQLStore, c.env.Cfg, tracing.InitializeTracerForTest()) require.NoError(c.t, err) orgService, err := orgimpl.ProvideService(c.env.SQLStore, c.env.Cfg, c.env.Server.HTTPServer.QuotaService) diff --git a/pkg/tests/apis/iam/iam_test.go b/pkg/tests/apis/iam/iam_test.go index 2d836827317..39deafaccb3 100644 --- a/pkg/tests/apis/iam/iam_test.go +++ b/pkg/tests/apis/iam/iam_test.go @@ -58,10 +58,6 @@ func TestIntegrationIdentity(t *testing.T) { "apiVersion": "iam.grafana.app/v0alpha1", "kind": "Team", "metadata": { - "annotations": { - "grafana.app/originName": "SQL", - "grafana.app/originPath": "${originPath}" - }, "creationTimestamp": "${creationTimestamp}", "name": "${name}", "namespace": "default", diff --git a/pkg/tests/apis/playlist/playlist_test.go b/pkg/tests/apis/playlist/playlist_test.go index ec828fb317a..ca5ba3dac1e 100644 --- a/pkg/tests/apis/playlist/playlist_test.go +++ b/pkg/tests/apis/playlist/playlist_test.go @@ -154,6 +154,22 @@ func TestIntegrationPlaylist(t *testing.T) { })) }) + t.Run("with dual write (file, mode 5)", func(t *testing.T) { + doPlaylistTests(t, apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + AppModeProduction: true, + DisableAnonymous: true, + APIServerStorageType: "file", // write the files to disk + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + RESOURCEGROUP: { + DualWriterMode: grafanarest.Mode5, + }, + }, + EnableFeatureToggles: []string{ + featuremgmt.FlagKubernetesPlaylists, // Required so that legacy calls are also written + }, + })) + }) + t.Run("with dual write (unified storage, mode 0)", func(t *testing.T) { doPlaylistTests(t, apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ AppModeProduction: false, // required for unified storage @@ -211,6 +227,22 @@ func TestIntegrationPlaylist(t *testing.T) { })) }) + t.Run("with dual write (unified storage, mode 5)", func(t *testing.T) { + doPlaylistTests(t, apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + AppModeProduction: false, // required for unified storage + DisableAnonymous: true, + APIServerStorageType: "unified", // use the entity api tables + EnableFeatureToggles: []string{ + featuremgmt.FlagKubernetesPlaylists, // Required so that legacy calls are also written + }, + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + RESOURCEGROUP: { + DualWriterMode: grafanarest.Mode5, + }, + }, + })) + }) + t.Run("with dual write (etcd, mode 0)", func(t *testing.T) { // NOTE: running local etcd, that will be wiped clean! t.Skip("local etcd testing") @@ -431,10 +463,6 @@ func doPlaylistTests(t *testing.T, helper *apis.K8sTestHelper) *apis.K8sTestHelp "apiVersion": "playlist.grafana.app/v0alpha1", "kind": "Playlist", "metadata": { - "annotations": { - "grafana.app/originPath": "${originPath}", - "grafana.app/originName": "SQL" - }, "creationTimestamp": "${creationTimestamp}", "name": "` + uid + `", "namespace": "default", @@ -504,6 +532,7 @@ func doPlaylistTests(t *testing.T, helper *apis.K8sTestHelper) *apis.K8sTestHelp }) t.Run("Do CRUD via k8s (and check that legacy api still works)", func(t *testing.T) { + t.Skip() client := helper.GetResourceClient(apis.ResourceClientArgs{ User: helper.Org1.Editor, GVR: gvr, diff --git a/pkg/tests/testinfra/testinfra.go b/pkg/tests/testinfra/testinfra.go index 9f6f5a0220b..8468cb154e2 100644 --- a/pkg/tests/testinfra/testinfra.go +++ b/pkg/tests/testinfra/testinfra.go @@ -498,7 +498,7 @@ func CreateUser(t *testing.T, store db.DB, cfg *setting.Cfg, cmd user.CreateUser cfg.AutoAssignOrgId = 1 cmd.OrgID = 1 - quotaService := quotaimpl.ProvideService(db.FakeReplDBFromDB(store), cfg) + quotaService := quotaimpl.ProvideService(store, cfg) orgService, err := orgimpl.ProvideService(store, cfg, quotaService) require.NoError(t, err) usrSvc, err := userimpl.ProvideService( diff --git a/pkg/tests/utils.go b/pkg/tests/utils.go index c1208211e95..f81683c80f4 100644 --- a/pkg/tests/utils.go +++ b/pkg/tests/utils.go @@ -9,8 +9,6 @@ import ( "github.com/go-openapi/strfmt" goapi "github.com/grafana/grafana-openapi-client-go/client" - "github.com/stretchr/testify/require" - "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" @@ -21,19 +19,20 @@ import ( "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/setting" + "github.com/stretchr/testify/require" ) -func CreateUser(t *testing.T, store db.DB, cfg *setting.Cfg, cmd user.CreateUserCommand) int64 { +func CreateUser(t *testing.T, db db.DB, cfg *setting.Cfg, cmd user.CreateUserCommand) int64 { t.Helper() cfg.AutoAssignOrg = true cfg.AutoAssignOrgId = 1 - quotaService := quotaimpl.ProvideService(db.FakeReplDBFromDB(store), cfg) - orgService, err := orgimpl.ProvideService(store, cfg, quotaService) + quotaService := quotaimpl.ProvideService(db, cfg) + orgService, err := orgimpl.ProvideService(db, cfg, quotaService) require.NoError(t, err) usrSvc, err := userimpl.ProvideService( - store, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), + db, orgService, cfg, nil, nil, tracing.InitializeTracerForTest(), quotaService, supportbundlestest.NewFakeBundleService(), ) require.NoError(t, err) diff --git a/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource.go b/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource.go index 44c22852513..f06ef71f098 100644 --- a/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource.go +++ b/pkg/tsdb/azuremonitor/loganalytics/azure-log-analytics-datasource.go @@ -6,7 +6,6 @@ import ( "context" "encoding/base64" "encoding/json" - "errors" "fmt" "io" "net/http" @@ -26,6 +25,7 @@ import ( "github.com/grafana/grafana/pkg/tsdb/azuremonitor/kinds/dataquery" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/macros" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" + "github.com/grafana/grafana/pkg/tsdb/azuremonitor/utils" ) func (e *AzureLogAnalyticsDatasource) ResourceRequest(rw http.ResponseWriter, req *http.Request, cli *http.Client) (http.ResponseWriter, error) { @@ -243,11 +243,7 @@ func (e *AzureLogAnalyticsDatasource) buildQuery(ctx context.Context, query back azureLogAnalyticsQuery, err = buildLogAnalyticsQuery(query, dsInfo, appInsightsRegExp, fromAlert) if err != nil { errorMessage := fmt.Errorf("failed to build azure log analytics query: %w", err) - var sourceError errorsource.Error - if errors.As(err, &sourceError) { - return nil, errorsource.SourceError(sourceError.Source(), errorMessage, false) - } - return nil, errorMessage + return nil, utils.ApplySourceFromError(errorMessage, err) } } @@ -262,11 +258,7 @@ func (e *AzureLogAnalyticsDatasource) buildQuery(ctx context.Context, query back azureAppInsightsQuery, err := buildAppInsightsQuery(ctx, query, dsInfo, appInsightsRegExp, e.Logger) if err != nil { errorMessage := fmt.Errorf("failed to build azure application insights query: %w", err) - var sourceError errorsource.Error - if errors.As(err, &sourceError) { - return nil, errorsource.SourceError(sourceError.Source(), errorMessage, false) - } - return nil, errorMessage + return nil, utils.ApplySourceFromError(errorMessage, err) } azureLogAnalyticsQuery = azureAppInsightsQuery } @@ -612,11 +604,11 @@ func getCorrelationWorkspaces(ctx context.Context, baseResource string, resource res, err := azMonService.HTTPClient.Do(req) if err != nil { - return AzureCorrelationAPIResponse{}, err + return AzureCorrelationAPIResponse{}, errorsource.DownstreamError(err, false) } body, err := io.ReadAll(res.Body) if err != nil { - return AzureCorrelationAPIResponse{}, err + return AzureCorrelationAPIResponse{}, errorsource.DownstreamError(err, false) } defer func() { diff --git a/pkg/tsdb/azuremonitor/loganalytics/traces.go b/pkg/tsdb/azuremonitor/loganalytics/traces.go index fbc8e961799..e5c2824ca07 100644 --- a/pkg/tsdb/azuremonitor/loganalytics/traces.go +++ b/pkg/tsdb/azuremonitor/loganalytics/traces.go @@ -201,7 +201,8 @@ func buildAppInsightsQuery(ctx context.Context, query backend.DataQuery, dsInfo if query.QueryType == string(dataquery.AzureQueryTypeTraceql) { subscription, err := utils.GetFirstSubscriptionOrDefault(ctx, dsInfo, logger) if err != nil { - return nil, fmt.Errorf("failed to retrieve subscription for trace exemplars query: %w", err) + errorMessage := fmt.Errorf("failed to retrieve subscription for trace exemplars query: %w", err) + return nil, utils.ApplySourceFromError(errorMessage, err) } resources = []string{fmt.Sprintf("/subscriptions/%s", subscription)} } @@ -222,7 +223,8 @@ func buildAppInsightsQuery(ctx context.Context, query backend.DataQuery, dsInfo operationId = *queryJSONModel.AzureTraces.OperationId resourcesMap, err = getCorrelationWorkspaces(ctx, resourceOrWorkspace, resourcesMap, dsInfo, operationId) if err != nil { - return nil, fmt.Errorf("failed to retrieve correlation resources for operation ID - %s: %s", operationId, err) + errorMessage := fmt.Errorf("failed to retrieve correlation resources for operation ID - %s: %s", operationId, err) + return nil, utils.ApplySourceFromError(errorMessage, err) } } diff --git a/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource.go b/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource.go index d1252ed167c..22667b74968 100644 --- a/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource.go +++ b/pkg/tsdb/azuremonitor/resourcegraph/azure-resource-graph-datasource.go @@ -137,7 +137,6 @@ func (e *AzureResourceGraphDatasource) executeQuery(ctx context.Context, query * } req, err := e.createRequest(ctx, reqBody, dsURL) - if err != nil { return nil, err } diff --git a/pkg/tsdb/azuremonitor/utils/utils.go b/pkg/tsdb/azuremonitor/utils/utils.go index d6149c8e21b..bcf9026f533 100644 --- a/pkg/tsdb/azuremonitor/utils/utils.go +++ b/pkg/tsdb/azuremonitor/utils/utils.go @@ -3,10 +3,12 @@ package utils import ( "context" "encoding/json" + "errors" "fmt" "net/http" "github.com/grafana/grafana-plugin-sdk-go/backend/log" + "github.com/grafana/grafana-plugin-sdk-go/experimental/errorsource" "github.com/grafana/grafana/pkg/tsdb/azuremonitor/types" ) @@ -25,7 +27,7 @@ func GetFirstSubscriptionOrDefault(ctx context.Context, dsInfo types.DatasourceI res, err := dsInfo.Services["Azure Monitor"].HTTPClient.Do(request) if err != nil { - return "", fmt.Errorf("failed to retrieve subscriptions: %v", err) + return "", errorsource.DownstreamError(fmt.Errorf("failed to retrieve subscriptions: %v", err), false) } defer func() { if err := res.Body.Close(); err != nil { @@ -39,7 +41,7 @@ func GetFirstSubscriptionOrDefault(ctx context.Context, dsInfo types.DatasourceI } if len(subscriptions) == 0 { - return "", fmt.Errorf("no subscriptions found: %v", err) + return "", errorsource.DownstreamError(fmt.Errorf("no subscriptions found: %v", err), false) } return subscriptions[0], nil @@ -68,3 +70,11 @@ func ParseSubscriptions(res *http.Response, logger log.Logger) ([]string, error) return result, nil } + +func ApplySourceFromError(errorMessage error, err error) error { + var sourceError errorsource.Error + if errors.As(err, &sourceError) { + return errorsource.SourceError(sourceError.Source(), errorMessage, false) + } + return errorMessage +} diff --git a/pkg/tsdb/influxdb/fsql/arrow.go b/pkg/tsdb/influxdb/fsql/arrow.go index 965e17f8357..e8db01c730d 100644 --- a/pkg/tsdb/influxdb/fsql/arrow.go +++ b/pkg/tsdb/influxdb/fsql/arrow.go @@ -151,6 +151,9 @@ func newField(f arrow.Field) *data.Field { return newDataField[time.Time](f) case arrow.DURATION: return newDataField[int64](f) + case arrow.LIST: + nestedType := f.Type.(*arrow.ListType).ElemField() + return newField(nestedType) default: return newDataField[json.RawMessage](f) } @@ -166,6 +169,8 @@ func newDataField[T any](f arrow.Field) *data.Field { } // copyData copies the contents of an Arrow column into a Data Frame field. +// +//nolint:gocyclo func copyData(field *data.Field, col arrow.Array) error { defer func() { if r := recover(); r != nil { @@ -174,7 +179,6 @@ func copyData(field *data.Field, col arrow.Array) error { }() colData := col.Data() - switch col.DataType().ID() { case arrow.TIMESTAMP: v := array.NewTimestampData(colData) @@ -221,6 +225,19 @@ func copyData(field *data.Field, col arrow.Array) error { } field.Append(json.RawMessage(b)) } + case arrow.LIST: + v := array.NewListData(colData) + for i := 0; i < v.Len(); i++ { + sc, err := scalar.GetScalar(v, i) + if err != nil { + return err + } + + err = copyData(field, sc.(*scalar.List).Value) + if err != nil { + return err + } + } case arrow.STRING: copyBasic[string](field, array.NewStringData(colData)) case arrow.UINT8: diff --git a/pkg/tsdb/influxdb/fsql/arrow_test.go b/pkg/tsdb/influxdb/fsql/arrow_test.go index 32d260a1152..1bbdc51e919 100644 --- a/pkg/tsdb/influxdb/fsql/arrow_test.go +++ b/pkg/tsdb/influxdb/fsql/arrow_test.go @@ -37,6 +37,8 @@ func TestNewQueryDataResponse(t *testing.T) { {Name: "utf8", Type: &arrow.StringType{}}, {Name: "duration", Type: &arrow.DurationType{}}, {Name: "timestamp", Type: &arrow.TimestampType{}}, + + {Name: "item", Type: arrow.ListOf(&arrow.StringType{})}, }, nil, ) @@ -58,6 +60,8 @@ func TestNewQueryDataResponse(t *testing.T) { newJSONArray(`["foo", "bar", "baz"]`, &arrow.StringType{}), newJSONArray(`[0, 1, -2]`, &arrow.DurationType{}), newJSONArray(`[0, 1, 2]`, &arrow.TimestampType{}), + + newJSONArray(`[["test", "test1", "test2"],[],[]]`, arrow.ListOf(&arrow.StringType{})), } arr := make([]arrow.Array, 0, len(strValues)) @@ -82,7 +86,7 @@ func TestNewQueryDataResponse(t *testing.T) { resp := newQueryDataResponse(errReader{RecordReader: reader}, query, metadata.MD{}) assert.NoError(t, resp.Error) assert.Len(t, resp.Frames, 1) - assert.Len(t, resp.Frames[0].Fields, 13) + assert.Len(t, resp.Frames[0].Fields, 14) frame := resp.Frames[0] f0 := frame.Fields[0] @@ -156,6 +160,14 @@ func TestNewQueryDataResponse(t *testing.T) { }, extractFieldValues[time.Time](t, f12), ) + + s1 := "test" + s2 := "test1" + s3 := "test2" + f13 := frame.Fields[13] + assert.Equal(t, f13.Name, "item") + assert.Equal(t, f13.Type(), data.FieldTypeNullableString) + assert.Equal(t, []*string{&s1, &s2, &s3}, extractFieldValues[*string](t, f13)) } type jsonArray struct { diff --git a/public/api-enterprise-spec.json b/public/api-enterprise-spec.json index cfcba3e9b58..ac04c23d278 100644 --- a/public/api-enterprise-spec.json +++ b/public/api-enterprise-spec.json @@ -5434,7 +5434,8 @@ "enum": [ "DASHBOARD", "DATASOURCE", - "FOLDER" + "FOLDER", + "LIBRARY_ELEMENT" ] } } diff --git a/public/api-merged.json b/public/api-merged.json index beecac17066..87416169e3a 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -13793,6 +13793,7 @@ } }, "CorrelationType": { + "description": "the type of correlation, either query for containing query information, or external for containing an external URL\n+enum", "type": "string" }, "CounterResetHint": { @@ -16867,6 +16868,9 @@ "message": { "type": "string" }, + "name": { + "type": "string" + }, "refId": { "type": "string" }, @@ -16885,7 +16889,8 @@ "enum": [ "DASHBOARD", "DATASOURCE", - "FOLDER" + "FOLDER", + "LIBRARY_ELEMENT" ] } } diff --git a/public/app/app.ts b/public/app/app.ts index 64d22d7a504..97e50653ca5 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -62,7 +62,7 @@ import { setMonacoEnv } from './core/monacoEnv'; import { interceptLinkClicks } from './core/navigation/patch/interceptLinkClicks'; import { NewFrontendAssetsChecker } from './core/services/NewFrontendAssetsChecker'; import { backendSrv } from './core/services/backend_srv'; -import { contextSrv } from './core/services/context_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'; @@ -196,6 +196,10 @@ export class GrafanaApp { getPanelPluginFromCache: syncGetPanelPlugin, }); + if (config.featureToggles.useSessionStorageForRedirection) { + handleRedirectTo(); + } + locationUtil.initialize({ config, getTimeRangeForUrl: getTimeSrv().timeRangeForUrl, @@ -382,4 +386,30 @@ function reportMetricPerformanceMark(metricName: string, prefix = '', suffix = ' } } +function handleRedirectTo(): void { + const queryParams = locationService.getSearch(); + const redirectToParamKey = 'redirectTo'; + + if (queryParams.has(redirectToParamKey) && window.location.pathname !== '/') { + const rawRedirectTo = queryParams.get(redirectToParamKey)!; + window.sessionStorage.setItem(RedirectToUrlKey, encodeURIComponent(rawRedirectTo)); + queryParams.delete(redirectToParamKey); + window.history.replaceState({}, '', `${window.location.pathname}${queryParams.size > 0 ? `?${queryParams}` : ''}`); + return; + } + + if (!contextSrv.user.isSignedIn) { + locationService.replace('/login'); + return; + } + + const redirectTo = window.sessionStorage.getItem(RedirectToUrlKey); + if (!redirectTo) { + return; + } + + window.sessionStorage.removeItem(RedirectToUrlKey); + locationService.replace(decodeURIComponent(redirectTo)); +} + export default new GrafanaApp(); diff --git a/public/app/core/components/AppChrome/AppChrome.tsx b/public/app/core/components/AppChrome/AppChrome.tsx index 64d9b0bb058..e41bc143940 100644 --- a/public/app/core/components/AppChrome/AppChrome.tsx +++ b/public/app/core/components/AppChrome/AppChrome.tsx @@ -3,7 +3,7 @@ import classNames from 'classnames'; import { PropsWithChildren, useEffect } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { locationSearchToObject, locationService } from '@grafana/runtime'; +import { config, locationSearchToObject, locationService } from '@grafana/runtime'; import { useStyles2, LinkButton, useTheme2 } from '@grafana/ui'; import { useGrafana } from 'app/core/context/GrafanaContext'; import { useMediaQueryChange } from 'app/core/hooks/useMediaQueryChange'; @@ -17,6 +17,7 @@ import { DOCKED_LOCAL_STORAGE_KEY, DOCKED_MENU_OPEN_LOCAL_STORAGE_KEY } from './ import { MegaMenu, MENU_WIDTH } from './MegaMenu/MegaMenu'; import { NavToolbar } from './NavToolbar/NavToolbar'; import { ReturnToPrevious } from './ReturnToPrevious/ReturnToPrevious'; +import { SingleTopBar } from './TopBar/SingleTopBar'; import { TopSearchBar } from './TopBar/TopSearchBar'; import { TOP_BAR_LEVEL_HEIGHT } from './types'; @@ -34,7 +35,7 @@ export function AppChrome({ children }: Props) { const menuDockedAndOpen = !state.chromeless && state.megaMenuDocked && state.megaMenuOpen; const scopesDashboardsState = useScopesDashboardsState(); const isScopesDashboardsOpen = Boolean(scopesDashboardsState?.isEnabled && scopesDashboardsState?.isPanelOpened); - + const isSingleTopNav = config.featureToggles.singleTopNav; useMediaQueryChange({ breakpoint: dockedMenuBreakpoint, onChange: (e) => { @@ -91,17 +92,28 @@ export function AppChrome({ children }: Props) { Skip to main content -
- {!searchBarHidden && } - +
+ {isSingleTopNav ? ( + + ) : ( + <> + {!searchBarHidden && } + + + )}
)} @@ -140,11 +152,12 @@ export function AppChrome({ children }: Props) { } const getStyles = (theme: GrafanaTheme2, searchBarHidden: boolean) => { + const isSingleTopNav = config.featureToggles.singleTopNav; return { content: css({ display: 'flex', flexDirection: 'column', - paddingTop: TOP_BAR_LEVEL_HEIGHT * 2, + paddingTop: isSingleTopNav ? TOP_BAR_LEVEL_HEIGHT : TOP_BAR_LEVEL_HEIGHT * 2, flexGrow: 1, height: 'auto', }), @@ -169,11 +182,15 @@ const getStyles = (theme: GrafanaTheme2, searchBarHidden: boolean) => { position: 'fixed', height: `calc(100% - ${searchBarHidden ? TOP_BAR_LEVEL_HEIGHT : TOP_BAR_LEVEL_HEIGHT * 2}px)`, zIndex: 2, + }, + isSingleTopNav && { + height: '100%', + top: 0, } ), scopesDashboardsContainer: css({ position: 'fixed', - height: `calc(100% - ${searchBarHidden ? TOP_BAR_LEVEL_HEIGHT : TOP_BAR_LEVEL_HEIGHT * 2}px)`, + height: `calc(100% - ${searchBarHidden || isSingleTopNav ? TOP_BAR_LEVEL_HEIGHT : TOP_BAR_LEVEL_HEIGHT * 2}px)`, zIndex: 1, }), scopesDashboardsContainerDocked: css({ @@ -188,6 +205,9 @@ const getStyles = (theme: GrafanaTheme2, searchBarHidden: boolean) => { background: theme.colors.background.primary, flexDirection: 'column', }), + topNavMenuDocked: css({ + left: MENU_WIDTH, + }), panes: css({ display: 'flex', flexDirection: 'column', diff --git a/public/app/core/components/AppChrome/AppChromeMenu.tsx b/public/app/core/components/AppChrome/AppChromeMenu.tsx index fb670aff321..7926e41aae9 100644 --- a/public/app/core/components/AppChrome/AppChromeMenu.tsx +++ b/public/app/core/components/AppChrome/AppChromeMenu.tsx @@ -6,6 +6,7 @@ import { useRef } from 'react'; import CSSTransition from 'react-transition-group/CSSTransition'; import { GrafanaTheme2 } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { useStyles2, useTheme2 } from '@grafana/ui'; import { useGrafana } from 'app/core/context/GrafanaContext'; import { KioskMode } from 'app/types'; @@ -76,7 +77,11 @@ export function AppChromeMenu({}: Props) { } const getStyles = (theme: GrafanaTheme2, searchBarHidden?: boolean) => { - const topPosition = searchBarHidden ? TOP_BAR_LEVEL_HEIGHT : TOP_BAR_LEVEL_HEIGHT * 2; + let topPosition = searchBarHidden ? TOP_BAR_LEVEL_HEIGHT : TOP_BAR_LEVEL_HEIGHT * 2; + + if (config.featureToggles.singleTopNav) { + topPosition = 0; + } return { backdrop: css({ diff --git a/public/app/core/components/AppChrome/AppChromeService.tsx b/public/app/core/components/AppChrome/AppChromeService.tsx index 5c1d88ebb41..c041574aff9 100644 --- a/public/app/core/components/AppChrome/AppChromeService.tsx +++ b/public/app/core/components/AppChrome/AppChromeService.tsx @@ -213,7 +213,7 @@ export class AppChromeService { private getNextKioskMode() { const { kioskMode, searchBarHidden } = this.state.getValue(); - if (searchBarHidden || kioskMode === KioskMode.TV) { + if (searchBarHidden || kioskMode === KioskMode.TV || config.featureToggles.singleTopNav) { appEvents.emit(AppEvents.alertInfo, [t('navigation.kiosk.tv-alert', 'Press ESC to exit kiosk mode')]); return KioskMode.Full; } diff --git a/public/app/core/components/AppChrome/MegaMenu/MegaMenu.tsx b/public/app/core/components/AppChrome/MegaMenu/MegaMenu.tsx index eafdf5426c2..d7deaaeb3cd 100644 --- a/public/app/core/components/AppChrome/MegaMenu/MegaMenu.tsx +++ b/public/app/core/components/AppChrome/MegaMenu/MegaMenu.tsx @@ -123,7 +123,7 @@ export const MegaMenu = memo(
    {navItems.map((link, index) => ( - + {index === 0 && ( ({ }), dockMenuButton: css({ display: 'none', + position: 'relative', + top: theme.spacing(1), [theme.breakpoints.up('xl')]: { display: 'inline-flex', diff --git a/public/app/core/components/AppChrome/MegaMenu/hooks.ts b/public/app/core/components/AppChrome/MegaMenu/hooks.ts index e4cb39d7edc..35c1f4be482 100644 --- a/public/app/core/components/AppChrome/MegaMenu/hooks.ts +++ b/public/app/core/components/AppChrome/MegaMenu/hooks.ts @@ -4,7 +4,7 @@ import { config } from '@grafana/runtime'; import { useGetUserPreferencesQuery } from 'app/features/preferences/api'; export const usePinnedItems = () => { - const preferences = useGetUserPreferencesQuery(); + const preferences = useGetUserPreferencesQuery(undefined, { skip: !config.bootData.user.isSignedIn }); const pinnedItems = useMemo(() => preferences.data?.navbar?.bookmarkUrls || [], [preferences]); if (config.featureToggles.pinNavItems) { diff --git a/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx b/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx new file mode 100644 index 00000000000..1499882fdf9 --- /dev/null +++ b/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx @@ -0,0 +1,131 @@ +import { css } from '@emotion/css'; +import { cloneDeep } from 'lodash'; +import { memo } from 'react'; + +import { GrafanaTheme2, NavModelItem } from '@grafana/data'; +import { Dropdown, Icon, ToolbarButton, useStyles2 } from '@grafana/ui'; +import { config } from 'app/core/config'; +import { contextSrv } from 'app/core/core'; +import { HOME_NAV_ID } from 'app/core/reducers/navModel'; +import { ScopesSelector } from 'app/features/scopes'; +import { useSelector } from 'app/types'; + +import { Branding } from '../../Branding/Branding'; +import { Breadcrumbs } from '../../Breadcrumbs/Breadcrumbs'; +import { buildBreadcrumbs } from '../../Breadcrumbs/utils'; +import { enrichHelpItem } from '../MegaMenu/utils'; +import { NewsContainer } from '../News/NewsContainer'; +import { OrganizationSwitcher } from '../OrganizationSwitcher/OrganizationSwitcher'; +import { QuickAdd } from '../QuickAdd/QuickAdd'; +import { TOP_BAR_LEVEL_HEIGHT } from '../types'; + +import { SignInLink } from './SignInLink'; +import { TopNavBarMenu } from './TopNavBarMenu'; +import { TopSearchBarCommandPaletteTrigger } from './TopSearchBarCommandPaletteTrigger'; +import { TopSearchBarSection } from './TopSearchBarSection'; + +interface Props { + sectionNav: NavModelItem; + pageNav?: NavModelItem; + onToggleMegaMenu(): void; + onToggleKioskMode(): void; +} + +export const SingleTopBar = memo(function SingleTopBar({ + onToggleMegaMenu, + onToggleKioskMode, + pageNav, + sectionNav, +}: Props) { + const styles = useStyles2(getStyles); + const navIndex = useSelector((state) => state.navIndex); + + const helpNode = cloneDeep(navIndex['help']); + const enrichedHelpNode = helpNode ? enrichHelpItem(helpNode) : undefined; + const profileNode = navIndex['profile']; + const homeNav = useSelector((state) => state.navIndex)[HOME_NAV_ID]; + const breadcrumbs = buildBreadcrumbs(sectionNav, pageNav, homeNav); + + return ( +
    + + + + + + + + + + + + + {enrichedHelpNode && ( + } placement="bottom-end"> + + + )} + {config.newsFeedEnabled && } + {!contextSrv.user.isSignedIn && } + {profileNode && ( + } placement="bottom-end"> + + + )} + + + + +
    + ); +}); + +const getStyles = (theme: GrafanaTheme2) => ({ + layout: css({ + height: TOP_BAR_LEVEL_HEIGHT, + display: 'flex', + gap: theme.spacing(1), + alignItems: 'center', + padding: theme.spacing(0, 1), + borderBottom: `1px solid ${theme.colors.border.weak}`, + justifyContent: 'space-between', + + [theme.breakpoints.up('sm')]: { + gridTemplateColumns: '2fr minmax(240px, 1fr)', // TODO probably change these values + display: 'grid', + + justifyContent: 'flex-start', + }, + }), + breadcrumbsWrapper: css({ + display: 'flex', + overflow: 'hidden', + [theme.breakpoints.down('sm')]: { + minWidth: '50%', + }, + }), + img: css({ + alignSelf: 'center', + height: theme.spacing(3), + width: theme.spacing(3), + }), + profileButton: css({ + padding: theme.spacing(0, 0.25), + img: { + borderRadius: theme.shape.radius.circle, + height: '24px', + marginRight: 0, + width: '24px', + }, + }), + kioskToggle: css({ + [theme.breakpoints.down('lg')]: { + display: 'none', + }, + }), +}); diff --git a/public/app/core/components/Login/LoginCtrl.tsx b/public/app/core/components/Login/LoginCtrl.tsx index 580c372ceab..a8612613173 100644 --- a/public/app/core/components/Login/LoginCtrl.tsx +++ b/public/app/core/components/Login/LoginCtrl.tsx @@ -118,7 +118,11 @@ export class LoginCtrl extends PureComponent { }; toGrafana = () => { - // Use window.location.href to force page reload + if (config.featureToggles.useSessionStorageForRedirection) { + window.location.assign(config.appSubUrl + '/'); + return; + } + if (this.result?.redirectUrl) { if (config.appSubUrl !== '' && !this.result.redirectUrl.startsWith(config.appSubUrl)) { window.location.assign(config.appSubUrl + this.result.redirectUrl); diff --git a/public/app/core/components/QueryOperationRow/QueryOperationRowHeader.tsx b/public/app/core/components/QueryOperationRow/QueryOperationRowHeader.tsx index 077614193b1..abd98c64977 100644 --- a/public/app/core/components/QueryOperationRow/QueryOperationRowHeader.tsx +++ b/public/app/core/components/QueryOperationRow/QueryOperationRowHeader.tsx @@ -111,6 +111,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ label: Column; display: flex; align-items: center; + overflow: hidden; `, dragIcon: css` cursor: grab; diff --git a/public/app/core/context/GrafanaContext.ts b/public/app/core/context/GrafanaContext.ts index 068f55b48e0..424bd71ee68 100644 --- a/public/app/core/context/GrafanaContext.ts +++ b/public/app/core/context/GrafanaContext.ts @@ -1,7 +1,7 @@ import { createContext, useCallback, useContext } from 'react'; import { GrafanaConfig } from '@grafana/data'; -import { LocationService, locationService, BackendSrv } from '@grafana/runtime'; +import { LocationService, locationService, BackendSrv, config } from '@grafana/runtime'; import { AppChromeService } from '../components/AppChrome/AppChromeService'; import { NewFrontendAssetsChecker } from '../services/NewFrontendAssetsChecker'; @@ -50,7 +50,7 @@ export function useChromeHeaderHeight() { if (kioskMode || chromeless) { return 0; - } else if (searchBarHidden) { + } else if (searchBarHidden || config.featureToggles.singleTopNav) { return SINGLE_HEADER_BAR_HEIGHT; } else { return SINGLE_HEADER_BAR_HEIGHT * 2; diff --git a/public/app/core/services/context_srv.ts b/public/app/core/services/context_srv.ts index aeb1371a855..54e5c27594b 100644 --- a/public/app/core/services/context_srv.ts +++ b/public/app/core/services/context_srv.ts @@ -19,6 +19,7 @@ import config from '../../core/config'; // When set to auto, the interval will be based on the query range // NOTE: this is defined here rather than TimeSrv so we avoid circular dependencies export const AutoRefreshInterval = 'auto'; +export const RedirectToUrlKey = 'redirectTo'; export class User implements Omit { isSignedIn: boolean; @@ -119,6 +120,12 @@ export class ContextSrv { * Indicate the user has been logged out */ setLoggedOut() { + if (config.featureToggles.useSessionStorageForRedirection) { + window.sessionStorage.setItem( + RedirectToUrlKey, + encodeURIComponent(window.location.href.substring(window.location.origin.length)) + ); + } this.cancelTokenRotationJob(); this.user.isSignedIn = false; this.isSignedIn = false; diff --git a/public/app/core/utils/navBarItem-translations.ts b/public/app/core/utils/navBarItem-translations.ts index eca9f15e11a..57be9f87a5f 100644 --- a/public/app/core/utils/navBarItem-translations.ts +++ b/public/app/core/utils/navBarItem-translations.ts @@ -11,6 +11,8 @@ export function getNavTitle(navId: string | undefined) { switch (navId) { case 'home': return t('nav.home.title', 'Home'); + case 'home-setup-guide': + return t('nav.setup-guide.title', 'Setup guide'); case 'new': return t('nav.new.title', 'New'); case 'create': diff --git a/public/app/features/admin/ldap/LdapDrawer.tsx b/public/app/features/admin/ldap/LdapDrawer.tsx index 42b3abdc660..caa5edefa1f 100644 --- a/public/app/features/admin/ldap/LdapDrawer.tsx +++ b/public/app/features/admin/ldap/LdapDrawer.tsx @@ -60,7 +60,7 @@ export const LdapDrawerComponent = ({ useEffect(() => { const { client_cert, client_key, root_ca_cert } = getValues(serverConfig); setEncryptionProvider( - !client_cert.length && !client_key.length && !root_ca_cert?.length + !client_cert?.length && !client_key?.length && !root_ca_cert?.length ? EncryptionProvider.Base64 : EncryptionProvider.FilePath ); @@ -83,19 +83,21 @@ export const LdapDrawerComponent = ({ ); const useTlsDescription = ( - - For a complete list of supported ciphers and TLS versions, refer to:{' '} - { - - https://go.dev/src/crypto/tls/cipher_suites.go - - } - + <> + + For a complete list of supported ciphers and TLS versions, refer to: + {' '} + {/* eslint-disable-next-line @grafana/no-untranslated-strings */} + + https://go.dev/src/crypto/tls/cipher_suites.go + + ); const onAddGroupMapping = () => { + const groupMappings = getValues(`${serverConfig}.group_mappings`) || []; setValue(`${serverConfig}.group_mappings`, [ - ...getValues(`${serverConfig}.group_mappings`), + ...groupMappings, { group_dn: '', org_id: 1, @@ -338,10 +340,12 @@ export const LdapDrawerComponent = ({ ( + render={({ field: { onChange, ref, value, ...field } }) => ( onChange(v.map(({ value }) => String(value)))} value={value?.map((v) => ({ label: renderMultiSelectLabel(v), value: v }))} /> @@ -447,5 +451,10 @@ function getStyles(theme: GrafanaTheme2) { button: css({ marginBottom: theme.spacing(4), }), + multiSelect: css({ + svg: { + display: 'none', + }, + }), }; } diff --git a/public/app/features/admin/ldap/LdapSettingsPage.tsx b/public/app/features/admin/ldap/LdapSettingsPage.tsx index 7b7335cc649..736a40a76c5 100644 --- a/public/app/features/admin/ldap/LdapSettingsPage.tsx +++ b/public/app/features/admin/ldap/LdapSettingsPage.tsx @@ -4,7 +4,7 @@ import { Controller, FormProvider, useForm } from 'react-hook-form'; import { connect } from 'react-redux'; import { AppEvents, GrafanaTheme2, NavModelItem } from '@grafana/data'; -import { getBackendSrv, getAppEvents } from '@grafana/runtime'; +import { getBackendSrv, getAppEvents, reportInteraction } from '@grafana/runtime'; import { useStyles2, Alert, @@ -15,12 +15,14 @@ import { Input, LinkButton, Menu, + Modal, Stack, Text, TextLink, Dropdown, MultiSelect, } from '@grafana/ui'; +import { FormPrompt } from 'app/core/components/FormPrompt/FormPrompt'; import { Page } from 'app/core/components/Page/Page'; import config from 'app/core/config'; import { t, Trans } from 'app/core/internationalization'; @@ -94,6 +96,7 @@ const emptySettings: LdapPayload = { export const LdapSettingsPage = () => { const [isLoading, setIsLoading] = useState(true); const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const [isModalOpen, setIsModalOpen] = useState(false); const [mapKeyCertConfigured, setMapKeyCertConfigured] = useState({ // values @@ -107,14 +110,25 @@ export const LdapSettingsPage = () => { }); const methods = useForm({ defaultValues: emptySettings }); - const { control, getValues, handleSubmit, register, reset, watch } = methods; + const { + control, + formState: { isDirty }, + getValues, + handleSubmit, + register, + reset, + watch, + } = methods; const styles = useStyles2(getStyles); useEffect(() => { async function init() { const payload = await getSettings(); - const serverConfig = payload.settings.config.servers[0]; + let serverConfig = emptySettings.settings.config.servers[0]; + if (payload.settings.config.servers?.length > 0) { + serverConfig = payload.settings.config.servers[0]; + } setMapKeyCertConfigured({ rootCaCertValue: serverConfig.root_ca_cert_value?.length > 0, clientCertValue: serverConfig.client_cert_value !== '', @@ -203,12 +217,14 @@ export const LdapSettingsPage = () => { /** * Button's Actions */ - const submitAndEnableLdapSettings = (payload: LdapPayload) => { + const submitAndEnableLdapSettings = async (payload: LdapPayload) => { payload.settings.enabled = true; - putPayload(payload); + await putPayload(payload); + reportInteraction('authentication_ldap_enabled'); }; - const saveForm = () => { - putPayload(getValues()); + const saveForm = async () => { + await putPayload(getValues()); + reportInteraction('authentication_ldap_saved'); }; const deleteLDAPConfig = async () => { try { @@ -220,6 +236,7 @@ export const LdapSettingsPage = () => { payload: [t('ldap-settings-page.alert.discard-success', 'LDAP settings discarded')], }); reset(payload); + reportInteraction('authentication_ldap_deleted'); } catch (error) { appEvents.publish({ type: AppEvents.alertError.name, @@ -230,6 +247,10 @@ export const LdapSettingsPage = () => { } }; + const onDiscard = () => { + reportInteraction('authentication_ldap_abandoned'); + }; + const subTitle = ( The LDAP integration in Grafana allows your Grafana users to log in with their LDAP credentials. Find out more in @@ -259,6 +280,7 @@ export const LdapSettingsPage = () => { {config.disableLoginForm && disabledFormAlert}
    + {isLoading && } {!isLoading && (
    @@ -266,7 +288,7 @@ export const LdapSettingsPage = () => { Basic Settings { /> { ( + render={({ field: { onChange, ref, ...field } }) => ( onChange(v.map(({ value }) => String(value)))} /> )} @@ -346,20 +370,20 @@ export const LdapSettingsPage = () => { - Discard + Discard { + setIsModalOpen(false)} + > + + + Are you sure you want to abandon the changes you‘ve made to the LDAP configuration? All changes will + be lost. + + + + + + + ); }; @@ -401,6 +445,11 @@ function getStyles(theme: GrafanaTheme2) { form: css({ width: theme.spacing(68), }), + multiSelect: css({ + svg: { + display: 'none', + }, + }), }; } diff --git a/public/app/features/alerting/unified/components/expressions/Expression.tsx b/public/app/features/alerting/unified/components/expressions/Expression.tsx index 9110dbbf8fa..20a0a9561bb 100644 --- a/public/app/features/alerting/unified/components/expressions/Expression.tsx +++ b/public/app/features/alerting/unified/components/expressions/Expression.tsx @@ -51,7 +51,7 @@ export const Expression: FC = ({ onSetCondition, onUpdateRefId, onRemoveExpression, - onUpdateExpressionType, + onUpdateExpressionType, // this method is not used? maybe we should remove it onChangeQuery, }) => { const styles = useStyles2(getStyles); diff --git a/public/app/features/alerting/unified/components/rule-editor/QueryWrapper.tsx b/public/app/features/alerting/unified/components/rule-editor/QueryWrapper.tsx index 5263fea8af6..d5468f64cde 100644 --- a/public/app/features/alerting/unified/components/rule-editor/QueryWrapper.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/QueryWrapper.tsx @@ -1,7 +1,8 @@ import { css } from '@emotion/css'; import { cloneDeep } from 'lodash'; -import { ChangeEvent, useState } from 'react'; import * as React from 'react'; +import { ChangeEvent, useState } from 'react'; +import { useFormContext } from 'react-hook-form'; import { CoreApp, @@ -14,11 +15,12 @@ import { ThresholdsConfig, } from '@grafana/data'; import { DataQuery } from '@grafana/schema'; -import { GraphThresholdsStyleMode, Icon, InlineField, Input, Tooltip, useStyles2, Stack } from '@grafana/ui'; +import { GraphThresholdsStyleMode, Icon, InlineField, Input, Stack, Tooltip, useStyles2 } from '@grafana/ui'; import { logInfo } from 'app/features/alerting/unified/Analytics'; import { QueryEditorRow } from 'app/features/query/components/QueryEditorRow'; import { AlertDataQuery, AlertQuery } from 'app/types/unified-alerting-dto'; +import { RuleFormValues } from '../../types/rule-form'; import { msToSingleUnitDuration } from '../../utils/time'; import { ExpressionStatusIndicator } from '../expressions/ExpressionStatusIndicator'; @@ -78,6 +80,9 @@ export const QueryWrapper = ({ const [dsInstance, setDsInstance] = useState(); const defaults = dsInstance?.getDefaultQuery ? dsInstance.getDefaultQuery(CoreApp.UnifiedAlerting) : {}; + const { getValues } = useFormContext(); + const isAdvancedMode = getValues('editorSettings.simplifiedQueryEditor') !== true; + const queryWithDefaults = { ...defaults, ...cloneDeep(query.model), @@ -123,7 +128,17 @@ export const QueryWrapper = ({ } // TODO add a warning label here too when the data looks like time series data and is used as an alert condition - function HeaderExtras({ query, error, index }: { query: AlertQuery; error?: Error; index: number }) { + function HeaderExtras({ + query, + error, + index, + isAdvancedMode = true, + }: { + query: AlertQuery; + error?: Error; + index: number; + isAdvancedMode?: boolean; + }) { const queryOptions: AlertQueryOptions = { maxDataPoints: query.model.maxDataPoints, minInterval: query.model.intervalMs ? msToSingleUnitDuration(query.model.intervalMs) : undefined, @@ -145,7 +160,12 @@ export const QueryWrapper = ({ onChangeQueryOptions={onChangeQueryOptions} index={index} /> - onSetCondition(query.refId)} isCondition={isAlertCondition} /> + {isAdvancedMode && ( + onSetCondition(query.refId)} + isCondition={isAlertCondition} + /> + )} ); } @@ -160,6 +180,7 @@ export const QueryWrapper = ({
    alerting + hideActionButtons={!isAdvancedMode} collapsable={false} dataSource={dsSettings} onDataSourceLoaded={setDsInstance} @@ -174,7 +195,9 @@ export const QueryWrapper = ({ onAddQuery={() => onDuplicateQuery(cloneDeep(query))} onRunQuery={onRunQueries} queries={editorQueries} - renderHeaderExtras={() => } + renderHeaderExtras={() => ( + + )} app={CoreApp.UnifiedAlerting} hideHideQueryButton={true} /> diff --git a/public/app/features/alerting/unified/components/rule-editor/RuleEditorSection.tsx b/public/app/features/alerting/unified/components/rule-editor/RuleEditorSection.tsx index 200e1bc8583..3c0083fe47c 100644 --- a/public/app/features/alerting/unified/components/rule-editor/RuleEditorSection.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/RuleEditorSection.tsx @@ -1,15 +1,19 @@ import { css, cx } from '@emotion/css'; -import { ReactElement } from 'react'; import * as React from 'react'; +import { ReactElement } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { FieldSet, Text, useStyles2, Stack } from '@grafana/ui'; +import { FieldSet, InlineSwitch, Stack, Text, useStyles2 } from '@grafana/ui'; export interface RuleEditorSectionProps { title: string; stepNo: number; description?: string | ReactElement; fullWidth?: boolean; + switchMode?: { + isAdvancedMode: boolean; + setAdvancedMode: (isAdvanced: boolean) => void; + }; } export const RuleEditorSection = ({ @@ -18,17 +22,33 @@ export const RuleEditorSection = ({ children, fullWidth = false, description, + switchMode, }: React.PropsWithChildren) => { const styles = useStyles2(getStyles); - return (
    - {stepNo}. {title} - + + + {stepNo}. {title} + + {switchMode && ( + + { + switchMode.setAdvancedMode(event.currentTarget.checked); + }} + label="Advanced options" + showLabel + transparent + /> + + )} + } > diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx index 22eda969175..7c010fcfc5c 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx @@ -35,13 +35,14 @@ import { RuleFormType, RuleFormValues } from '../../../types/rule-form'; import { DEFAULT_GROUP_EVALUATION_INTERVAL, MANUAL_ROUTING_KEY, + SIMPLIFIED_QUERY_EDITOR_KEY, formValuesFromExistingRule, + formValuesToRulerGrafanaRuleDTO, + formValuesToRulerRuleDTO, getDefaultFormValues, getDefaultQueries, ignoreHiddenQueries, normalizeDefaultAnnotations, - formValuesToRulerGrafanaRuleDTO, - formValuesToRulerRuleDTO, } from '../../../utils/rule-form'; import { fromRulerRule, fromRulerRuleAndRuleGroupIdentifier, stringifyIdentifier } from '../../../utils/rule-id'; import { GrafanaRuleExporter } from '../../export/GrafanaRuleExporter'; @@ -135,15 +136,6 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => { trackAlertRuleFormSaved({ formAction: existing ? 'update' : 'create', ruleType: values.type }); - // when creating a new rule, we save the manual routing setting in local storage - if (!existing) { - if (values.manualRouting) { - localStorage.setItem(MANUAL_ROUTING_KEY, 'true'); - } else { - localStorage.setItem(MANUAL_ROUTING_KEY, 'false'); - } - } - const ruleDefinition = grafanaTypeRule ? formValuesToRulerGrafanaRuleDTO(values) : formValuesToRulerRuleDTO(values); const ruleGroupIdentifier = existing @@ -153,6 +145,8 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => { // @TODO what is "evaluateEvery" being used for? // @TODO move this to a hook too to make sure the logic here is tested for regressions? if (!existing) { + // when creating a new rule, we save the manual routing setting , and editorSettings.simplifiedQueryEditor to the local storage + storeInLocalStorageValues(values); await addRuleToRuleGroup.execute(ruleGroupIdentifier, ruleDefinition, values.evaluateEvery); } else { const ruleIdentifier = fromRulerRuleAndRuleGroupIdentifier(ruleGroupIdentifier, existing.rule); @@ -353,6 +347,21 @@ function formValuesFromPrefill(rule: Partial): RuleFormValues { }); } +function storeInLocalStorageValues(values: RuleFormValues) { + if (values.manualRouting) { + localStorage.setItem(MANUAL_ROUTING_KEY, 'true'); + } else { + localStorage.setItem(MANUAL_ROUTING_KEY, 'false'); + } + if (values.editorSettings) { + if (values.editorSettings.simplifiedQueryEditor) { + localStorage.setItem(SIMPLIFIED_QUERY_EDITOR_KEY, 'true'); + } else { + localStorage.setItem(SIMPLIFIED_QUERY_EDITOR_KEY, 'false'); + } + } +} + const getStyles = (theme: GrafanaTheme2) => ({ buttonSpinner: css({ marginRight: theme.spacing(1), diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx index ed2f94a7542..65ffe897e4a 100644 --- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/QueryAndExpressionsStep.tsx @@ -3,15 +3,34 @@ import { cloneDeep } from 'lodash'; import { useCallback, useEffect, useMemo, useReducer, useState } from 'react'; import { Controller, useFormContext } from 'react-hook-form'; -import { getDefaultRelativeTimeRange, GrafanaTheme2 } from '@grafana/data'; +import { getDefaultRelativeTimeRange, GrafanaTheme2, ReducerID } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { config, getDataSourceSrv } from '@grafana/runtime'; -import { Alert, Button, Dropdown, Field, Icon, Menu, MenuItem, Stack, Tooltip, useStyles2 } from '@grafana/ui'; +import { + Alert, + Button, + ConfirmModal, + Dropdown, + Field, + Icon, + Menu, + MenuItem, + Stack, + Tooltip, + useStyles2, +} from '@grafana/ui'; import { Text } from '@grafana/ui/src/components/Text/Text'; +import { EvalFunction } from 'app/features/alerting/state/alertDef'; import { isExpressionQuery } from 'app/features/expressions/guards'; -import { ExpressionDatasourceUID, ExpressionQueryType, expressionTypes } from 'app/features/expressions/types'; +import { + ExpressionDatasourceUID, + ExpressionQuery, + ExpressionQueryType, + expressionTypes, + ReducerMode, +} from 'app/features/expressions/types'; import { useDispatch } from 'app/types'; -import { AlertQuery } from 'app/types/unified-alerting-dto'; +import { AlertDataQuery, AlertQuery } from 'app/types/unified-alerting-dto'; import { useRulesSourcesWithRuler } from '../../../hooks/useRuleSourcesWithRuler'; import { fetchAllPromBuildInfoAction } from '../../../state/actions'; @@ -34,6 +53,14 @@ import { RuleEditorSection } from '../RuleEditorSection'; import { errorFromCurrentCondition, errorFromPreviewData, findRenamedDataQueryReferences, refIdExists } from '../util'; import { CloudDataSourceSelector } from './CloudDataSourceSelector'; +import { + getSimpleConditionFromExpressions, + SIMPLE_CONDITION_QUERY_ID, + SIMPLE_CONDITION_REDUCER_ID, + SIMPLE_CONDITION_THRESHOLD_ID, + SimpleCondition, + SimpleConditionEditor, +} from './SimpleCondition'; import { SmartAlertTypeDetector } from './SmartAlertTypeDetector'; import { DESCRIPTIONS } from './descriptions'; import { @@ -44,6 +71,7 @@ import { queriesAndExpressionsReducer, removeExpression, removeExpressions, + resetToSimpleCondition, rewireExpressions, setDataQueries, setRecordingRulesQueries, @@ -54,6 +82,44 @@ import { } from './reducer'; import { useAlertQueryRunner } from './useAlertQueryRunner'; +export function areQueriesTransformableToSimpleCondition( + dataQueries: Array>, + expressionQueries: Array> +) { + if (dataQueries.length !== 1) { + return false; + } + + if (expressionQueries.length !== 2) { + return false; + } + + const query = dataQueries[0]; + + if (query.refId !== SIMPLE_CONDITION_QUERY_ID) { + return false; + } + + const reduceExpressionIndex = expressionQueries.findIndex( + (query) => query.model.type === ExpressionQueryType.reduce && query.refId === SIMPLE_CONDITION_REDUCER_ID + ); + const reduceExpression = expressionQueries.at(reduceExpressionIndex); + const reduceOk = + reduceExpression && + reduceExpressionIndex === 0 && + (reduceExpression.model.settings?.mode === ReducerMode.Strict || + reduceExpression.model.settings?.mode === undefined); + + const thresholdExpressionIndex = expressionQueries.findIndex( + (query) => query.model.type === ExpressionQueryType.threshold && query.refId === SIMPLE_CONDITION_THRESHOLD_ID + ); + const thresholdExpression = expressionQueries.at(thresholdExpressionIndex); + const conditions = thresholdExpression?.model.conditions ?? []; + const thresholdOk = + thresholdExpression && thresholdExpressionIndex === 1 && conditions[0]?.unloadEvaluator === undefined; + return Boolean(reduceOk) && Boolean(thresholdOk); +} + interface Props { editingExistingRule: boolean; onDataChange: (error: string) => void; @@ -69,18 +135,59 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P } = useFormContext(); const { queryPreviewData, runQueries, cancelQueries, isPreviewLoading, clearPreviewData } = useAlertQueryRunner(); + const isSwitchModeEnabled = config.featureToggles.alertingQueryAndExpressionsStepMode ?? false; const initialState = { queries: getValues('queries'), }; const [{ queries }, dispatch] = useReducer(queriesAndExpressionsReducer, initialState); - const [type, condition, dataSourceName] = watch(['type', 'condition', 'dataSourceName']); + + // data queries only + const dataQueries = useMemo(() => { + return queries.filter((query) => !isExpressionQuery(query.model)); + }, [queries]); + + // expression queries only + const expressionQueries = useMemo(() => { + return queries.filter((query) => isExpressionQueryInAlert(query)); + }, [queries]); + + const [type, condition, dataSourceName, editorSettings] = watch([ + 'type', + 'condition', + 'dataSourceName', + 'editorSettings', + ]); + //if its a new rule, look at the local storage const isGrafanaAlertingType = isGrafanaAlertingRuleByType(type); const isRecordingRuleType = isCloudRecordingRuleByType(type); const isCloudAlertRuleType = isCloudAlertingRuleByType(type); + const isAdvancedMode = editorSettings?.simplifiedQueryEditor !== true || !isGrafanaAlertingType; + + const [showResetModeModal, setShowResetModal] = useState(false); + + const [simpleCondition, setSimpleCondition] = useState( + isGrafanaAlertingType && areQueriesTransformableToSimpleCondition(dataQueries, expressionQueries) + ? getSimpleConditionFromExpressions(expressionQueries) + : { + whenField: ReducerID.last, + evaluator: { + params: [0], + type: EvalFunction.IsAbove, + }, + } + ); + + // If we switch to simple mode we need to update the simple condition with the data in the queries reducer + useEffect(() => { + if (!isAdvancedMode && isGrafanaAlertingType) { + setSimpleCondition(getSimpleConditionFromExpressions(expressionQueries)); + } + }, [isAdvancedMode, expressionQueries, isGrafanaAlertingType]); + const dispatchReduxAction = useDispatch(); useEffect(() => { dispatchReduxAction(fetchAllPromBuildInfoAction()); @@ -95,10 +202,15 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P // Grafana Managed rules and recording rules do return; } - - runQueries(getValues('queries'), condition || (getValues('condition') ?? '')); + // we need to be sure the condition is set once we switch to simple mode + if (!isAdvancedMode) { + setValue('condition', SIMPLE_CONDITION_THRESHOLD_ID); + runQueries(getValues('queries'), SIMPLE_CONDITION_THRESHOLD_ID); + } else { + runQueries(getValues('queries'), condition || (getValues('condition') ?? '')); + } }, - [isCloudAlertRuleType, runQueries, getValues] + [isCloudAlertRuleType, runQueries, getValues, isAdvancedMode, setValue] ); // whenever we update the queries we have to update the form too @@ -108,16 +220,6 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P const noCompatibleDataSources = getDefaultOrFirstCompatibleDataSource() === undefined; - // data queries only - const dataQueries = useMemo(() => { - return queries.filter((query) => !isExpressionQuery(query.model)); - }, [queries]); - - // expression queries only - const expressionQueries = useMemo(() => { - return queries.filter((query) => isExpressionQuery(query.model)); - }, [queries]); - const emptyQueries = queries.length === 0; // apply some validations and asserts to the results of the evaluation when creating or editing @@ -368,168 +470,221 @@ export const QueryAndExpressionsStep = ({ editingExistingRule, onDataChange }: P ]); const { sectionTitle, helpLabel, helpContent, helpLink } = DESCRIPTIONS[type ?? RuleFormType.grafana]; + if (!type) { return null; } + + const switchMode = + isGrafanaAlertingType && isSwitchModeEnabled + ? { + isAdvancedMode, + setAdvancedMode: (isAdvanced: boolean) => { + if (!isAdvanced) { + if (!areQueriesTransformableToSimpleCondition(dataQueries, expressionQueries)) { + setShowResetModal(true); + return; + } + } + setValue('editorSettings', { simplifiedQueryEditor: !isAdvanced }); + }, + } + : undefined; + return ( - - - {helpLabel} - - - - } - > - {/* This is the cloud data source selector */} - {isDataSourceManagedRuleByType(type) && ( - - )} + <> + + + {helpLabel} + + + + } + switchMode={switchMode} + > + {/* This is the cloud data source selector */} + {isDataSourceManagedRuleByType(type) && ( + + )} - {/* This is the PromQL Editor for recording rules */} - {isRecordingRuleType && dataSourceName && ( - - runQueriesPreview()} - onChangeQuery={onChangeRecordingRulesQueries} - panelData={queryPreviewData} - /> - - )} - - {/* This is the PromQL Editor for Cloud rules */} - {isCloudAlertRuleType && dataSourceName && ( - + {/* This is the PromQL Editor for recording rules */} + {isRecordingRuleType && dataSourceName && ( - { - return ( - - ); - }} - control={control} - rules={{ - required: { value: true, message: 'A valid expression is required' }, - }} + runQueriesPreview()} + onChangeQuery={onChangeRecordingRulesQueries} + panelData={queryPreviewData} /> - - - )} + )} - {/* This is the editor for Grafana managed rules and Grafana managed recording rules */} - {isGrafanaManagedRuleByType(type) && ( - - {/* Data Queries */} - runQueriesPreview()} - onChangeQueries={onChangeQueries} - onDuplicateQuery={onDuplicateQuery} - panelData={queryPreviewData} - condition={condition} - onSetCondition={handleSetCondition} - /> - - - - {/* We only show Switch for Grafana managed alerts */} - {isGrafanaAlertingType && ( + {/* This is the PromQL Editor for Cloud rules */} + {isCloudAlertRuleType && dataSourceName && ( + + + { + return ( + + ); + }} + control={control} + rules={{ + required: { value: true, message: 'A valid expression is required' }, + }} + /> + - )} - {/* Expression Queries */} - - Expressions - - Manipulate data returned from queries with math and other operations. - + )} - { - dispatch(removeExpression(refId)); - }} - onUpdateRefId={onUpdateRefId} - onUpdateExpressionType={(refId, type) => { - dispatch(updateExpressionType({ refId, type })); - }} - onUpdateQueryExpression={(model) => { - dispatch(updateExpression(model)); - }} - /> - {/* action buttons */} - - {config.expressionsEnabled && } - - {isPreviewLoading && ( - + {/* This is the editor for Grafana managed rules and Grafana managed recording rules */} + {isGrafanaManagedRuleByType(type) && ( + + {/* Data Queries */} + runQueriesPreview()} + onChangeQueries={onChangeQueries} + onDuplicateQuery={onDuplicateQuery} + panelData={queryPreviewData} + condition={condition} + onSetCondition={handleSetCondition} + /> + {isAdvancedMode && ( + + + )} - {!isPreviewLoading && ( - + {/* We only show Switch for Grafana managed alerts */} + {isGrafanaAlertingType && isAdvancedMode && ( + + )} + {/* Expression Queries */} + {isAdvancedMode && isGrafanaAlertingType && ( + <> + + Expressions + + Manipulate data returned from queries with math and other operations. + + + + { + dispatch(removeExpression(refId)); + }} + onUpdateRefId={onUpdateRefId} + onUpdateExpressionType={(refId, type) => { + dispatch(updateExpressionType({ refId, type })); + }} + onUpdateQueryExpression={(model) => { + dispatch(updateExpression(model)); + }} + /> + + )} + {/* action buttons */} + + {!isAdvancedMode && ( + + )} + + {isAdvancedMode && config.expressionsEnabled && } + + {isPreviewLoading && ( + + )} + {!isPreviewLoading && ( + + )} + + + + {/* No Queries */} + {emptyQueries && ( + + Create at least one query or expression to be alerted on + )} + )} + - {/* No Queries */} - {emptyQueries && ( - - Create at least one query or expression to be alerted on - - )} - - )} - + { + setValue('editorSettings', { simplifiedQueryEditor: true }); + setShowResetModal(false); + dispatch(resetToSimpleCondition()); + }} + onDismiss={() => setShowResetModal(false)} + /> + ); }; @@ -602,3 +757,9 @@ const useSetExpressionAndDataSource = () => { } }; }; + +function isExpressionQueryInAlert( + query: AlertQuery +): query is AlertQuery { + return isExpressionQuery(query.model); +} diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/SimpleCondition.tsx b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/SimpleCondition.tsx new file mode 100644 index 00000000000..6dcd0204d5f --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/SimpleCondition.tsx @@ -0,0 +1,241 @@ +import { css } from '@emotion/css'; +import { produce } from 'immer'; +import { Dispatch, FormEvent } from 'react'; +import { UnknownAction } from 'redux'; + +import { GrafanaTheme2, PanelData, ReducerID, SelectableValue } from '@grafana/data'; +import { ButtonSelect, InlineField, InlineFieldRow, Input, Select, Stack, Text, useStyles2 } from '@grafana/ui'; +import { Trans } from 'app/core/internationalization'; +import { EvalFunction } from 'app/features/alerting/state/alertDef'; +import { ExpressionQuery, ExpressionQueryType, reducerTypes, thresholdFunctions } from 'app/features/expressions/types'; +import { getReducerType } from 'app/features/expressions/utils/expressionTypes'; +import { AlertQuery } from 'app/types/unified-alerting-dto'; + +import { ExpressionResult } from '../../expressions/Expression'; + +import { updateExpression } from './reducer'; + +export const SIMPLE_CONDITION_QUERY_ID = 'A'; +export const SIMPLE_CONDITION_REDUCER_ID = 'B'; +export const SIMPLE_CONDITION_THRESHOLD_ID = 'C'; + +export interface SimpleCondition { + whenField: string; + evaluator: { + params: number[]; + type: EvalFunction; + }; +} + +/** + * This is the simple condition editor if the user is in the simple mode in the query section + */ +export interface SimpleConditionEditorProps { + simpleCondition: SimpleCondition; + onChange: (condition: SimpleCondition) => void; + expressionQueriesList: Array>; + dispatch: Dispatch; + previewData?: PanelData; +} + +/** + * + * This represents the simple condition editor for the alerting query section + * The state for this simple condition is kept in the parent component + * But we have also to keep the reducer state in sync with this condition state (both kept in the parent) + */ + +export const SimpleConditionEditor = ({ + simpleCondition, + onChange, + expressionQueriesList, + dispatch, + previewData, +}: SimpleConditionEditorProps) => { + const onReducerTypeChange = (value: SelectableValue) => { + onChange({ ...simpleCondition, whenField: value.value ?? ReducerID.last }); + updateReduceExpression(value.value ?? ReducerID.last, expressionQueriesList, dispatch); + }; + + const isRange = + simpleCondition.evaluator.type === EvalFunction.IsWithinRange || + simpleCondition.evaluator.type === EvalFunction.IsOutsideRange; + + const thresholdFunction = thresholdFunctions.find((fn) => fn.value === simpleCondition.evaluator?.type); + + const onEvalFunctionChange = (value: SelectableValue) => { + // change the condition kept in the parent + onChange({ + ...simpleCondition, + evaluator: { ...simpleCondition.evaluator, type: value.value ?? EvalFunction.IsAbove }, + }); + // update the reducer state where we store the queries + updateThresholdFunction(value.value ?? EvalFunction.IsAbove, expressionQueriesList, dispatch); + }; + + const onEvaluateValueChange = (event: FormEvent, index?: number) => { + if (isRange) { + const newParams = produce(simpleCondition.evaluator.params, (draft) => { + draft[index ?? 0] = parseFloat(event.currentTarget.value); + }); + // update the condition kept in the parent + onChange({ ...simpleCondition, evaluator: { ...simpleCondition.evaluator, params: newParams } }); + // update the reducer state where we store the queries + updateThresholdValue(parseFloat(event.currentTarget.value), index ?? 0, expressionQueriesList, dispatch); + } else { + // update the condition kept in the parent + onChange({ + ...simpleCondition, + evaluator: { ...simpleCondition.evaluator, params: [parseFloat(event.currentTarget.value)] }, + }); + // update the reducer state where we store the queries + updateThresholdValue(parseFloat(event.currentTarget.value), 0, expressionQueriesList, dispatch); + } + }; + + const styles = useStyles2(getStyles); + + return ( +
    + +
    + + Alert condition + +
    + + + onEvaluateValueChange(event, 0)} + /> +
    + TO +
    + onEvaluateValueChange(event, 1)} + /> + + ) : ( + + )} +
    + + + {previewData?.series && } + +
    + ); +}; + +function updateReduceExpression( + reducer: string, + expressionQueriesList: Array>, + dispatch: Dispatch +) { + const reduceExpression = expressionQueriesList.find( + (query) => query.model.type === ExpressionQueryType.reduce && query.model.refId === SIMPLE_CONDITION_REDUCER_ID + ); + + const newReduceExpression = reduceExpression + ? produce(reduceExpression?.model, (draft) => { + if (draft && draft.conditions) { + draft.reducer = reducer; + draft.conditions[0].reducer.type = getReducerType(reducer) ?? ReducerID.last; + } + }) + : undefined; + newReduceExpression && dispatch(updateExpression(newReduceExpression)); +} + +function updateThresholdFunction( + evaluator: EvalFunction, + expressionQueriesList: Array>, + dispatch: Dispatch +) { + const thresholdExpression = expressionQueriesList.find( + (query) => query.model.type === ExpressionQueryType.threshold && query.model.refId === SIMPLE_CONDITION_THRESHOLD_ID + ); + + const newThresholdExpression = produce(thresholdExpression, (draft) => { + if (draft && draft.model.conditions) { + draft.model.conditions[0].evaluator.type = evaluator; + } + }); + newThresholdExpression && dispatch(updateExpression(newThresholdExpression.model)); +} + +function updateThresholdValue( + value: number, + index: number, + expressionQueriesList: Array>, + dispatch: Dispatch +) { + const thresholdExpression = expressionQueriesList.find( + (query) => query.model.type === ExpressionQueryType.threshold && query.model.refId === SIMPLE_CONDITION_THRESHOLD_ID + ); + + const newThresholdExpression = produce(thresholdExpression, (draft) => { + if (draft && draft.model.conditions) { + draft.model.conditions[0].evaluator.params[index] = value; + } + }); + newThresholdExpression && dispatch(updateExpression(newThresholdExpression.model)); +} + +export function getSimpleConditionFromExpressions(expressions: Array>): SimpleCondition { + const reduceExpression = expressions.find( + (query) => query.model.type === ExpressionQueryType.reduce && query.refId === SIMPLE_CONDITION_REDUCER_ID + ); + const thresholdExpression = expressions.find( + (query) => query.model.type === ExpressionQueryType.threshold && query.refId === SIMPLE_CONDITION_THRESHOLD_ID + ); + const conditionsFromThreshold = thresholdExpression?.model.conditions ?? []; + return { + whenField: reduceExpression?.model.reducer ?? ReducerID.last, + evaluator: { + params: [...conditionsFromThreshold[0]?.evaluator?.params] ?? [0], + type: conditionsFromThreshold[0]?.evaluator?.type ?? EvalFunction.IsAbove, + }, + }; +} + +const getStyles = (theme: GrafanaTheme2) => ({ + condition: { + wrapper: css({ + display: 'flex', + border: `solid 1px ${theme.colors.border.medium}`, + flex: 1, + height: 'fit-content', + borderRadius: theme.shape.radius.default, + }), + header: css({ + background: theme.colors.background.secondary, + padding: `${theme.spacing(0.5)} ${theme.spacing(1)}`, + borderBottom: `solid 1px ${theme.colors.border.weak}`, + flex: 1, + }), + }, +}); diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/__snapshots__/areQueriesTransformableToSimpleCondition.test.ts b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/__snapshots__/areQueriesTransformableToSimpleCondition.test.ts new file mode 100644 index 00000000000..0357d99f4d1 --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/__snapshots__/areQueriesTransformableToSimpleCondition.test.ts @@ -0,0 +1,121 @@ +// QueryAndExpressionsStep.test.tsx + +import { produce } from 'immer'; + +import { EvalFunction } from 'app/features/alerting/state/alertDef'; +import { ExpressionQuery, ExpressionQueryType, ReducerMode } from 'app/features/expressions/types'; +import { AlertDataQuery, AlertQuery } from 'app/types/unified-alerting-dto'; + +import { areQueriesTransformableToSimpleCondition } from '../QueryAndExpressionsStep'; +import { + SIMPLE_CONDITION_QUERY_ID, + SIMPLE_CONDITION_REDUCER_ID, + SIMPLE_CONDITION_THRESHOLD_ID, +} from '../SimpleCondition'; + +const dataQuery: AlertQuery = { + refId: SIMPLE_CONDITION_QUERY_ID, + datasourceUid: 'abc123', + queryType: '', + model: { refId: SIMPLE_CONDITION_QUERY_ID }, +}; + +const reduceExpression: AlertQuery = { + refId: SIMPLE_CONDITION_REDUCER_ID, + queryType: 'expression', + datasourceUid: '__expr__', + model: { + type: ExpressionQueryType.reduce, + refId: SIMPLE_CONDITION_REDUCER_ID, + settings: { mode: ReducerMode.Strict }, + }, +}; +const thresholdExpression: AlertQuery = { + refId: SIMPLE_CONDITION_THRESHOLD_ID, + queryType: 'expression', + datasourceUid: '__expr__', + model: { + type: ExpressionQueryType.threshold, + refId: SIMPLE_CONDITION_THRESHOLD_ID, + }, +}; + +const expressionQueries: Array> = [reduceExpression, thresholdExpression]; + +describe('areQueriesTransformableToSimpleCondition', () => { + it('should return false if dataQueries length is not 1', () => { + // zero dataQueries + expect(areQueriesTransformableToSimpleCondition([], expressionQueries)).toBe(false); + // more than one dataQueries + expect(areQueriesTransformableToSimpleCondition([dataQuery, dataQuery], expressionQueries)).toBe(false); + }); + it('should return false if expressionQueries length is not 2', () => { + const dataQueries: Array> = [dataQuery]; + const result = areQueriesTransformableToSimpleCondition(dataQueries, []); + expect(result).toBe(false); + }); + + it('should return false if the dataQuery refId does not match SIMPLE_CONDITION_QUERY_ID', () => { + const dataQueries: Array> = [ + { refId: 'notSimpleCondition', datasourceUid: 'abc123', queryType: '', model: { refId: 'notSimpleCondition' } }, + ]; + const result = areQueriesTransformableToSimpleCondition(dataQueries, expressionQueries); + expect(result).toBe(false); + }); + it('should return false if no reduce expression is found with correct type and refId', () => { + const dataQueries: Array> = [dataQuery]; + const result = areQueriesTransformableToSimpleCondition(dataQueries, [ + { ...reduceExpression, refId: 'hello' }, + thresholdExpression, + ]); + expect(result).toBe(false); + }); + + it('should return false if no threshold expression is found with correct type and refId', () => { + const dataQueries: Array> = [dataQuery]; + const result = areQueriesTransformableToSimpleCondition(dataQueries, [ + reduceExpression, + { ...thresholdExpression, refId: 'hello' }, + ]); + expect(result).toBe(false); + }); + + it('should return false if reduceExpression settings mode is not ReducerMode.Strict', () => { + const dataQueries: Array> = [dataQuery]; + const transformedReduceExpression = produce(reduceExpression, (draft) => { + draft.model.settings = { mode: ReducerMode.DropNonNumbers }; + }); + + const result = areQueriesTransformableToSimpleCondition(dataQueries, [ + transformedReduceExpression, + thresholdExpression, + ]); + expect(result).toBe(false); + }); + + it('should return false if thresholdExpression unloadEvaluator has a value', () => { + const dataQueries: Array> = [dataQuery]; + + const transformedThresholdExpression = produce(thresholdExpression, (draft) => { + draft.model.conditions = [ + { + evaluator: { params: [1], type: EvalFunction.IsAbove }, + unloadEvaluator: { params: [1], type: EvalFunction.IsAbove }, + query: { params: ['A'] }, + reducer: { params: [], type: 'avg' }, + type: 'query', + }, + ]; + }); + const result = areQueriesTransformableToSimpleCondition(dataQueries, [ + reduceExpression, + transformedThresholdExpression, + ]); + expect(result).toBe(false); + }); + it('should return true when all conditions are met', () => { + const dataQueries: Array> = [dataQuery]; + const result = areQueriesTransformableToSimpleCondition(dataQueries, expressionQueries); + expect(result).toBe(true); + }); +}); diff --git a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.ts b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.ts index 44ebf2f1f18..e1e7f66092b 100644 --- a/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.ts +++ b/public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/reducer.ts @@ -16,6 +16,7 @@ import { AlertQuery } from 'app/types/unified-alerting-dto'; import { logError } from '../../../Analytics'; import { getDefaultOrFirstCompatibleDataSource } from '../../../utils/datasource'; +import { getDefaultQueries } from '../../../utils/rule-form'; import { createDagFromQueries, getOriginOfRefId } from '../dag'; import { queriesWithUpdatedReferences, refIdExists } from '../util'; @@ -58,6 +59,8 @@ export const updateExpressionTimeRange = createAction('updateExpressionTimeRange export const updateMaxDataPoints = createAction<{ refId: string; maxDataPoints: number }>('updateMaxDataPoints'); export const updateMinInterval = createAction<{ refId: string; minInterval: string }>('updateMinInterval'); +export const resetToSimpleCondition = createAction('resetToSimpleCondition'); + export const setRecordingRulesQueries = createAction<{ recordingRuleQueries: AlertQuery[]; expression: string }>( 'setRecordingRulesQueries' ); @@ -65,6 +68,10 @@ export const setRecordingRulesQueries = createAction<{ recordingRuleQueries: Ale export const queriesAndExpressionsReducer = createReducer(initialState, (builder) => { // data queries actions builder + // simple condition actions + .addCase(resetToSimpleCondition, (state) => { + state.queries = getDefaultQueries(); + }) .addCase(duplicateQuery, (state, { payload }) => { state.queries = addQuery(state.queries, payload); }) diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/CentralHistoryRuntimeDataSource.ts b/public/app/features/alerting/unified/components/rules/central-state-history/CentralHistoryRuntimeDataSource.ts index 287eceabbf1..0f4eedfef85 100644 --- a/public/app/features/alerting/unified/components/rules/central-state-history/CentralHistoryRuntimeDataSource.ts +++ b/public/app/features/alerting/unified/components/rules/central-state-history/CentralHistoryRuntimeDataSource.ts @@ -9,7 +9,7 @@ import { stateHistoryApi } from '../../../api/stateHistoryApi'; import { DataSourceInformation } from '../../../home/Insights'; import { LIMIT_EVENTS } from './EventListSceneObject'; -import { historyResultToDataFrame } from './utils'; +import { getStateFilterFromInQueryParams, getStateFilterToInQueryParams, historyResultToDataFrame } from './utils'; const historyDataSourceUid = '__history_api_ds_uid__'; const historyDataSourcePluginId = '__history_api_ds_pluginId__'; @@ -47,8 +47,14 @@ class HistoryAPIDatasource extends RuntimeDataSource { const from = request.range.from.unix(); const to = request.range.to.unix(); + // Get the labels and states filters from the URL + const stateTo = getStateFilterToInQueryParams(); + const stateFrom = getStateFilterFromInQueryParams(); + + const historyResult = await getHistory(from, to); + return { - data: historyResultToDataFrame(await getHistory(from, to)), + data: historyResultToDataFrame(historyResult, { stateTo, stateFrom }), }; } diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/EventListSceneObject.tsx b/public/app/features/alerting/unified/components/rules/central-state-history/EventListSceneObject.tsx index 0ba6f9d4e7a..0c57ef96c0a 100644 --- a/public/app/features/alerting/unified/components/rules/central-state-history/EventListSceneObject.tsx +++ b/public/app/features/alerting/unified/components/rules/central-state-history/EventListSceneObject.tsx @@ -1,9 +1,9 @@ import { css, cx } from '@emotion/css'; -import { ReactElement, useMemo, useState } from 'react'; +import { ReactElement, useState } from 'react'; import { useLocation } from 'react-router'; import { useMeasure } from 'react-use'; -import { DataFrameJSON, GrafanaTheme2, IconName, TimeRange } from '@grafana/data'; +import { GrafanaTheme2, IconName, TimeRange } from '@grafana/data'; import { CustomVariable, SceneComponentProps, @@ -25,18 +25,17 @@ import { import { trackUseCentralHistoryFilterByClicking, trackUseCentralHistoryMaxEventsReached } from '../../../Analytics'; import { stateHistoryApi } from '../../../api/stateHistoryApi'; import { usePagination } from '../../../hooks/usePagination'; -import { combineMatcherStrings, labelsMatchMatchers } from '../../../utils/alertmanager'; +import { combineMatcherStrings } from '../../../utils/alertmanager'; import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource'; -import { parsePromQLStyleMatcherLooseSafe } from '../../../utils/matchers'; import { createRelativeUrl } from '../../../utils/url'; import { AlertLabels } from '../../AlertLabels'; import { CollapseToggle } from '../../CollapseToggle'; import { LogRecord } from '../state-history/common'; -import { isLine, isNumbers } from '../state-history/useRuleHistoryRecords'; -import { LABELS_FILTER, STATE_FILTER_FROM, STATE_FILTER_TO, StateFilterValues } from './CentralAlertHistoryScene'; +import { LABELS_FILTER, STATE_FILTER_FROM, STATE_FILTER_TO } from './CentralAlertHistoryScene'; import { EventDetails } from './EventDetails'; import { HistoryErrorMessage } from './HistoryErrorMessage'; +import { useRuleHistoryRecords } from './useRuleHistoryRecords'; export const LIMIT_EVENTS = 5000; // limit is hard-capped at 5000 at the BE level. const PAGE_SIZE = 100; @@ -75,12 +74,11 @@ export const HistoryEventsList = ({ limit: LIMIT_EVENTS, }); - const { historyRecords: historyRecordsNotSorted } = useRuleHistoryRecords( - valueInLabelFilter.toString(), - valueInStateToFilter.toString(), - valueInStateFromFilter.toString(), - stateHistory - ); + const { historyRecords: historyRecordsNotSorted } = useRuleHistoryRecords(stateHistory, { + labels: valueInLabelFilter.toString(), + stateFrom: valueInStateFromFilter.toString(), + stateTo: valueInStateToFilter.toString(), + }); const historyRecords = historyRecordsNotSorted.sort((a, b) => b.timestamp - a.timestamp); @@ -542,54 +540,3 @@ export function HistoryEventsListObjectRenderer({ model }: SceneComponentProps ); } -/** - * This hook filters the history records based on the label, stateTo and stateFrom filters. - * @param filterInLabel - * @param filterInStateTo - * @param filterInStateFrom - * @param stateHistory the original history records - * @returns the filtered history records - */ -function useRuleHistoryRecords( - filterInLabel: string, - filterInStateTo: string, - filterInStateFrom: string, - stateHistory?: DataFrameJSON -) { - return useMemo(() => { - if (!stateHistory?.data) { - return { historyRecords: [] }; - } - - const filterMatchers = filterInLabel ? parsePromQLStyleMatcherLooseSafe(filterInLabel) : []; - - const [tsValues, lines] = stateHistory.data.values; - const timestamps = isNumbers(tsValues) ? tsValues : []; - - // merge timestamp with "line" - const logRecords = timestamps.reduce((acc: LogRecord[], timestamp: number, index: number) => { - const line = lines[index]; - if (!isLine(line)) { - return acc; - } - // values property can be undefined for some instance states (e.g. NoData) - const filterMatch = line.labels && labelsMatchMatchers(line.labels, filterMatchers); - if (!isGrafanaAlertState(line.current) || !isGrafanaAlertState(line.previous)) { - return acc; - } - const baseStateTo = mapStateWithReasonToBaseState(line.current); - const baseStateFrom = mapStateWithReasonToBaseState(line.previous); - const stateToMatch = filterInStateTo !== StateFilterValues.all ? filterInStateTo === baseStateTo : true; - const stateFromMatch = filterInStateFrom !== StateFilterValues.all ? filterInStateFrom === baseStateFrom : true; - if (filterMatch && stateToMatch && stateFromMatch) { - acc.push({ timestamp, line }); - } - - return acc; - }, []); - - return { - historyRecords: logRecords, - }; - }, [stateHistory, filterInLabel, filterInStateTo, filterInStateFrom]); -} diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/__fixtures__/alert-state-history.ts b/public/app/features/alerting/unified/components/rules/central-state-history/__fixtures__/alert-state-history.ts new file mode 100644 index 00000000000..8debd4d8498 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/central-state-history/__fixtures__/alert-state-history.ts @@ -0,0 +1,896 @@ +import { DataFrameJSON } from '@grafana/data'; + +const data: DataFrameJSON = { + data: { + values: [ + [ + 1727189670000, 1727189670000, 1727189670000, 1727189670000, 1727189670000, 1727189670000, 1727189670000, + 1727189670000, 1727189670000, 1727189670000, 1727189680000, 1727189700000, 1727189690000, 1727189690000, + 1727189690000, 1727189690000, 1727189690000, 1727189690000, 1727189690000, 1727189690000, 1727189680000, + 1727189670000, 1727189700000, 1727189710000, 1727189710000, 1727189710000, 1727189710000, 1727189710000, + 1727189710000, 1727189710000, 1727189710000, 1727189710000, + ], + [ + { + schemaVersion: 1, + previous: 'Normal', + current: 'NoData', + values: {}, + condition: 'C', + dashboardUID: '-W9yw_X7k', + panelID: 3, + fingerprint: '009794b4d3074732', + ruleTitle: 'CPU Usage 6', + ruleID: 10605, + ruleUID: '-rRg_HcVz', + labels: { + alertname: 'CPU Usage 6', + datasource_uid: 'gdev-prometheus', + grafana_folder: 'Demonstrations', + ref_id: 'A', + team: 'operations', + type: 'test', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'NoData', + values: {}, + condition: 'C', + dashboardUID: 'stzZvVw7k', + panelID: 8, + fingerprint: '186e7d0c9173ddf4', + ruleTitle: 'CPU Usage 4', + ruleID: 150, + ruleUID: 's984p7n4k', + labels: { + alertname: 'CPU Usage 4', + datasource_uid: 'gdev-prometheus', + grafana_folder: 'Demonstrations', + ref_id: 'A', + region: 'EMEA', + team: 'operations', + }, + }, + { + schemaVersion: 1, + previous: 'Alerting', + current: 'Normal', + values: { + B: -6.886811320670644, + C: 0, + }, + condition: 'C', + dashboardUID: '', + panelID: 0, + fingerprint: 'bcf38efbb616d8d2', + ruleTitle: 'direct to contact point', + ruleID: 10656, + ruleUID: 'ddc5zx4l3zls0a', + labels: { + alertname: 'direct to contact point', + grafana_folder: 'Testing and reproducing', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'NoData', + values: {}, + condition: 'D', + dashboardUID: 'iYSJkozVk', + panelID: 13, + fingerprint: 'd3f3ec7ad90a37fd', + ruleTitle: 'With Disabled Query', + ruleID: 10631, + ruleUID: 'ab2ac850-1669-4dac-b566-c91728425f39', + labels: { + alertname: 'With Disabled Query', + datasource_uid: 'gdev-prometheus', + grafana_folder: 'Testing and reproducing', + ref_id: 'A,B', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'NoData', + values: {}, + condition: 'C', + dashboardUID: '', + panelID: 0, + fingerprint: 'f4c073dd4c60e897', + ruleTitle: 'Alert State History Random', + ruleID: 10614, + ruleUID: 'e4d7252b-c66f-4f5c-b7db-28b9936150bd', + labels: { + alertname: 'Alert State History Random', + datasource_uid: 'gdev-prometheus', + grafana_folder: 'Testing and reproducing', + ref_id: 'A', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'Pending', + values: { + B: 54.137611900622574, + C: 1, + }, + condition: 'C', + dashboardUID: '', + panelID: 0, + fingerprint: 'e11b0ff29886e8f2', + ruleTitle: 'node_namespace_pod_container:container_cpu_usage_seconds_total:sum_irate4', + ruleID: 10662, + ruleUID: 'ddgfq7c57fqpsc', + labels: { + alertname: 'node_namespace_pod_container:container_cpu_usage_seconds_total:sum_irate4', + grafana_folder: 'Testing and reproducing', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'NoData', + values: {}, + condition: 'B', + dashboardUID: '', + panelID: 0, + fingerprint: 'd6d7e256298c8ea8', + ruleTitle: 'delete-ds', + ruleID: 144, + ruleUID: 'wXS0SnWVz', + labels: { + alertname: 'delete-ds', + datasource_uid: 'gdev-prometheus', + grafana_folder: 'Testing and reproducing', + ref_id: 'A', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'NoData', + values: {}, + condition: 'C', + dashboardUID: '', + panelID: 0, + fingerprint: 'bf1b309577b4fc0c', + ruleTitle: 'Threshold Test', + ruleID: 148, + ruleUID: 'u0QVOqMVz', + labels: { + alertname: 'Threshold Test', + datasource_uid: 'gdev-prometheus', + grafana_folder: 'Testing and reproducing', + ref_id: 'A', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'NoData', + values: {}, + condition: 'C', + dashboardUID: '', + panelID: 0, + fingerprint: 'd6b1cde6ab63bfca', + ruleTitle: 'Alert History fingerprint', + ruleID: 10619, + ruleUID: 'c7a7989f-f12c-4aa5-8fb3-27f4509a5809', + labels: { + alertname: 'Alert History fingerprint', + datasource_uid: 'PDDA8E780A17E7EF1', + grafana_folder: 'Testing and reproducing', + ref_id: 'A', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'Pending', + values: { + B: 0.0020875, + C: 1, + }, + condition: 'C', + dashboardUID: 'd4c61dfe-24d8-433e-984e-701a2f14faab', + panelID: 1, + fingerprint: '40ddb0f6e97426e9', + ruleTitle: 'UniFi CPU Usage', + ruleID: 10638, + ruleUID: 'd10fc550-3552-4083-a3aa-f6b68058bc7f', + labels: { + alertname: 'UniFi CPU Usage', + grafana_folder: 'UniFi', + instance: 'unpoller:9130', + job: 'unifipoller', + name: 'Dream Router', + site_name: 'Default (default)', + source: 'https://192.168.1.1', + type: 'udm', + }, + }, + { + schemaVersion: 1, + previous: 'Pending', + current: 'Alerting', + values: { + B0: 36.956598435022215, + }, + condition: 'B', + dashboardUID: '', + panelID: 0, + fingerprint: '10716534bbf8086b', + ruleTitle: 'XSS attack vector', + ruleID: 37, + ruleUID: 'p8TBxnq7k', + labels: { + alertname: 'XSS attack vector', + grafana_folder: 'gdev dashboards', + }, + }, + { + schemaVersion: 1, + previous: 'NoData', + current: 'Normal (MissingSeries)', + values: {}, + condition: 'C', + dashboardUID: 'iYSJkozVk', + panelID: 5, + fingerprint: '4a5519a42c382438', + ruleTitle: 'CPU Usage', + ruleID: 7635, + ruleUID: '8nEmeE44k', + labels: { + alertname: 'CPU Usage', + datasource_uid: 'gdev-prometheus', + grafana_folder: 'Demonstrations', + ref_id: 'A', + team: 'operations', + type: 'cpu', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'Alerting', + values: { + B: 0.067575, + C: 1, + }, + condition: 'C', + dashboardUID: 'iYSJkozVk', + panelID: 5, + fingerprint: 'cc4aceb3018da2e8', + ruleTitle: 'CPU Usage', + ruleID: 7635, + ruleUID: '8nEmeE44k', + labels: { + alertname: 'CPU Usage', + cpu: '7', + grafana_folder: 'Demonstrations', + team: 'operations', + type: 'cpu', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'Alerting', + values: { + B: 0.06216900000000001, + C: 1, + }, + condition: 'C', + dashboardUID: 'iYSJkozVk', + panelID: 5, + fingerprint: '20dfd648881e698f', + ruleTitle: 'CPU Usage', + ruleID: 7635, + ruleUID: '8nEmeE44k', + labels: { + alertname: 'CPU Usage', + cpu: '2', + grafana_folder: 'Demonstrations', + team: 'operations', + type: 'cpu', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'Alerting', + values: { + B: 0.07027800000000001, + C: 1, + }, + condition: 'C', + dashboardUID: 'iYSJkozVk', + panelID: 5, + fingerprint: '234db8cd1e160f6a', + ruleTitle: 'CPU Usage', + ruleID: 7635, + ruleUID: '8nEmeE44k', + labels: { + alertname: 'CPU Usage', + cpu: '1', + grafana_folder: 'Demonstrations', + team: 'operations', + type: 'cpu', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'Alerting', + values: { + B: 0.06727466666666668, + C: 1, + }, + condition: 'C', + dashboardUID: 'iYSJkozVk', + panelID: 5, + fingerprint: '4e1f85b7efc3ed75', + ruleTitle: 'CPU Usage', + ruleID: 7635, + ruleUID: '8nEmeE44k', + labels: { + alertname: 'CPU Usage', + cpu: '0', + grafana_folder: 'Demonstrations', + team: 'operations', + type: 'cpu', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'Alerting', + values: { + B: 0.05315900000000001, + C: 1, + }, + condition: 'C', + dashboardUID: 'iYSJkozVk', + panelID: 5, + fingerprint: '50bf9f17b9534593', + ruleTitle: 'CPU Usage', + ruleID: 7635, + ruleUID: '8nEmeE44k', + labels: { + alertname: 'CPU Usage', + cpu: '6', + grafana_folder: 'Demonstrations', + team: 'operations', + type: 'cpu', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'Alerting', + values: { + B: 0.046551666666666686, + C: 1, + }, + condition: 'C', + dashboardUID: 'iYSJkozVk', + panelID: 5, + fingerprint: '2fb0af95df9b2bfe', + ruleTitle: 'CPU Usage', + ruleID: 7635, + ruleUID: '8nEmeE44k', + labels: { + alertname: 'CPU Usage', + cpu: '5', + grafana_folder: 'Demonstrations', + team: 'operations', + type: 'cpu', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'Alerting', + values: { + B: 0.05466066666666668, + C: 1, + }, + condition: 'C', + dashboardUID: 'iYSJkozVk', + panelID: 5, + fingerprint: 'd0303e26f6c376e9', + ruleTitle: 'CPU Usage', + ruleID: 7635, + ruleUID: '8nEmeE44k', + labels: { + alertname: 'CPU Usage', + cpu: '4', + grafana_folder: 'Demonstrations', + team: 'operations', + type: 'cpu', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'Alerting', + values: { + B: 0.059165666666666665, + C: 1, + }, + condition: 'C', + dashboardUID: 'iYSJkozVk', + panelID: 5, + fingerprint: 'af82d1220e70c3b4', + ruleTitle: 'CPU Usage', + ruleID: 7635, + ruleUID: '8nEmeE44k', + labels: { + alertname: 'CPU Usage', + cpu: '3', + grafana_folder: 'Demonstrations', + team: 'operations', + type: 'cpu', + }, + }, + { + schemaVersion: 1, + previous: 'Error', + current: 'Normal (MissingSeries)', + values: {}, + condition: 'C', + dashboardUID: 'iYSJkozVk', + panelID: 5, + fingerprint: '4a5519a42c382438', + ruleTitle: 'CPU Usage', + ruleID: 7635, + ruleUID: '8nEmeE44k', + labels: { + alertname: 'CPU Usage', + datasource_uid: 'gdev-prometheus', + grafana_folder: 'Demonstrations', + ref_id: 'A', + team: 'operations', + type: 'cpu', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'NoData', + values: {}, + condition: 'C', + dashboardUID: 'iYSJkozVk', + panelID: 5, + fingerprint: '4a5519a42c382438', + ruleTitle: 'CPU Usage', + ruleID: 7635, + ruleUID: '8nEmeE44k', + labels: { + alertname: 'CPU Usage', + datasource_uid: 'gdev-prometheus', + grafana_folder: 'Demonstrations', + ref_id: 'A', + team: 'operations', + type: 'cpu', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'Pending', + values: { + C0: 9.319781010143206, + }, + condition: 'C', + dashboardUID: '', + panelID: 0, + fingerprint: 'abeb2444057d7f01', + ruleTitle: 'test-read-only-threshold-indicators', + ruleID: 10606, + ruleUID: 'Wq5jJlc4k', + labels: { + alertname: 'test-read-only-threshold-indicators', + grafana_folder: 'Testing and reproducing', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'Pending', + values: { + reducer: 0.07305751032920166, + threshold: 1, + }, + condition: 'threshold', + dashboardUID: '', + panelID: 0, + fingerprint: '2e7f5e6f5a5f2902', + ruleTitle: 'CPU Usage 5', + ruleID: 10583, + ruleUID: 'LvMu_IHVz', + labels: { + alertname: 'CPU Usage 5', + cpu: '6', + grafana_folder: 'Demonstrations', + team: 'operations', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'Pending', + values: { + reducer: 0.06820108623217379, + threshold: 1, + }, + condition: 'threshold', + dashboardUID: '', + panelID: 0, + fingerprint: '1c1b21f738468ea5', + ruleTitle: 'CPU Usage 5', + ruleID: 10583, + ruleUID: 'LvMu_IHVz', + labels: { + alertname: 'CPU Usage 5', + cpu: '5', + grafana_folder: 'Demonstrations', + team: 'operations', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'Pending', + values: { + reducer: 0.07685819005731041, + threshold: 1, + }, + condition: 'threshold', + dashboardUID: '', + panelID: 0, + fingerprint: '23ee5f02850e38f8', + ruleTitle: 'CPU Usage 5', + ruleID: 10583, + ruleUID: 'LvMu_IHVz', + labels: { + alertname: 'CPU Usage 5', + cpu: '4', + grafana_folder: 'Demonstrations', + team: 'operations', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'Pending', + values: { + reducer: 0.08614874050379848, + threshold: 1, + }, + condition: 'threshold', + dashboardUID: '', + panelID: 0, + fingerprint: '2a810fa152d6407b', + ruleTitle: 'CPU Usage 5', + ruleID: 10583, + ruleUID: 'LvMu_IHVz', + labels: { + alertname: 'CPU Usage 5', + cpu: '3', + grafana_folder: 'Demonstrations', + team: 'operations', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'Pending', + values: { + reducer: 0.0926943555910969, + threshold: 1, + }, + condition: 'threshold', + dashboardUID: '', + panelID: 0, + fingerprint: '48d127e8826c6fb6', + ruleTitle: 'CPU Usage 5', + ruleID: 10583, + ruleUID: 'LvMu_IHVz', + labels: { + alertname: 'CPU Usage 5', + cpu: '2', + grafana_folder: 'Demonstrations', + team: 'operations', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'Pending', + values: { + reducer: 0.1097974143675863, + threshold: 1, + }, + condition: 'threshold', + dashboardUID: '', + panelID: 0, + fingerprint: 'c9582e3d28402fe9', + ruleTitle: 'CPU Usage 5', + ruleID: 10583, + ruleUID: 'LvMu_IHVz', + labels: { + alertname: 'CPU Usage 5', + cpu: '1', + grafana_folder: 'Demonstrations', + team: 'operations', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'Pending', + values: { + reducer: 0.12985655737704918, + threshold: 1, + }, + condition: 'threshold', + dashboardUID: '', + panelID: 0, + fingerprint: 'fb3e32509acec7ec', + ruleTitle: 'CPU Usage 5', + ruleID: 10583, + ruleUID: 'LvMu_IHVz', + labels: { + alertname: 'CPU Usage 5', + cpu: '0', + grafana_folder: 'Demonstrations', + team: 'operations', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'Pending', + values: { + B0: 8.406488484275167, + }, + condition: 'B', + dashboardUID: '', + panelID: 0, + fingerprint: '91a8126f41689d98', + ruleTitle: 'Random Values', + ruleID: 146, + ruleUID: 'I1M_iCWVk', + labels: { + alertname: 'Random Values', + grafana_folder: 'Demonstrations', + }, + }, + { + schemaVersion: 1, + previous: 'Normal', + current: 'Pending', + values: { + reducer: 0.08973827135812343, + threshold: 1, + }, + condition: 'threshold', + dashboardUID: '', + panelID: 0, + fingerprint: '6cd2ab200de1cf77', + ruleTitle: 'CPU Usage 5', + ruleID: 10583, + ruleUID: 'LvMu_IHVz', + labels: { + alertname: 'CPU Usage 5', + cpu: '7', + grafana_folder: 'Demonstrations', + team: 'operations', + }, + }, + ], + [ + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'system-metrics', + orgID: '1', + }, + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'default 2', + orgID: '1', + }, + { + folderUID: 'a3eyrfqnk', + from: 'state-history', + group: 'default', + orgID: '1', + }, + { + folderUID: 'a3eyrfqnk', + from: 'state-history', + group: 'default', + orgID: '1', + }, + { + folderUID: 'a3eyrfqnk', + from: 'state-history', + group: 'default', + orgID: '1', + }, + { + folderUID: 'a3eyrfqnk', + from: 'state-history', + group: 'default', + orgID: '1', + }, + { + folderUID: 'a3eyrfqnk', + from: 'state-history', + group: 'default', + orgID: '1', + }, + { + folderUID: 'a3eyrfqnk', + from: 'state-history', + group: 'default', + orgID: '1', + }, + { + folderUID: 'a3eyrfqnk', + from: 'state-history', + group: 'default', + orgID: '1', + }, + { + folderUID: 'Qh4ctRZVz', + from: 'state-history', + group: 'system', + orgID: '1', + }, + { + folderUID: 'pPyg37Qnk', + from: 'state-history', + group: 'security', + orgID: '1', + }, + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'usage', + orgID: '1', + }, + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'usage', + orgID: '1', + }, + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'usage', + orgID: '1', + }, + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'usage', + orgID: '1', + }, + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'usage', + orgID: '1', + }, + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'usage', + orgID: '1', + }, + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'usage', + orgID: '1', + }, + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'usage', + orgID: '1', + }, + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'usage', + orgID: '1', + }, + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'usage', + orgID: '1', + }, + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'usage', + orgID: '1', + }, + { + folderUID: 'a3eyrfqnk', + from: 'state-history', + group: 'thresholds', + orgID: '1', + }, + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'default', + orgID: '1', + }, + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'default', + orgID: '1', + }, + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'default', + orgID: '1', + }, + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'default', + orgID: '1', + }, + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'default', + orgID: '1', + }, + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'default', + orgID: '1', + }, + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'default', + orgID: '1', + }, + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'default', + orgID: '1', + }, + { + folderUID: 'lG5pfeRVk', + from: 'state-history', + group: 'default', + orgID: '1', + }, + ], + ], + }, +}; + +export default data; diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/__snapshots__/useRuleHistoryRecords.test.ts.snap b/public/app/features/alerting/unified/components/rules/central-state-history/__snapshots__/useRuleHistoryRecords.test.ts.snap new file mode 100644 index 00000000000..b1ff6c28eb6 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/central-state-history/__snapshots__/useRuleHistoryRecords.test.ts.snap @@ -0,0 +1,822 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ruleHistoryToRecords should be empty with no matches 1`] = ` +{ + "historyRecords": [], +} +`; + +exports[`ruleHistoryToRecords should convert rule history JSON response to log records 1`] = ` +{ + "historyRecords": [ + { + "line": { + "condition": "C", + "current": "NoData", + "dashboardUID": "-W9yw_X7k", + "fingerprint": "009794b4d3074732", + "labels": { + "alertname": "CPU Usage 6", + "datasource_uid": "gdev-prometheus", + "grafana_folder": "Demonstrations", + "ref_id": "A", + "team": "operations", + "type": "test", + }, + "panelID": 3, + "previous": "Normal", + "ruleID": 10605, + "ruleTitle": "CPU Usage 6", + "ruleUID": "-rRg_HcVz", + "schemaVersion": 1, + "values": {}, + }, + "timestamp": 1727189670000, + }, + { + "line": { + "condition": "C", + "current": "NoData", + "dashboardUID": "stzZvVw7k", + "fingerprint": "186e7d0c9173ddf4", + "labels": { + "alertname": "CPU Usage 4", + "datasource_uid": "gdev-prometheus", + "grafana_folder": "Demonstrations", + "ref_id": "A", + "region": "EMEA", + "team": "operations", + }, + "panelID": 8, + "previous": "Normal", + "ruleID": 150, + "ruleTitle": "CPU Usage 4", + "ruleUID": "s984p7n4k", + "schemaVersion": 1, + "values": {}, + }, + "timestamp": 1727189670000, + }, + { + "line": { + "condition": "C", + "current": "Normal", + "dashboardUID": "", + "fingerprint": "bcf38efbb616d8d2", + "labels": { + "alertname": "direct to contact point", + "grafana_folder": "Testing and reproducing", + }, + "panelID": 0, + "previous": "Alerting", + "ruleID": 10656, + "ruleTitle": "direct to contact point", + "ruleUID": "ddc5zx4l3zls0a", + "schemaVersion": 1, + "values": { + "B": -6.886811320670644, + "C": 0, + }, + }, + "timestamp": 1727189670000, + }, + { + "line": { + "condition": "D", + "current": "NoData", + "dashboardUID": "iYSJkozVk", + "fingerprint": "d3f3ec7ad90a37fd", + "labels": { + "alertname": "With Disabled Query", + "datasource_uid": "gdev-prometheus", + "grafana_folder": "Testing and reproducing", + "ref_id": "A,B", + }, + "panelID": 13, + "previous": "Normal", + "ruleID": 10631, + "ruleTitle": "With Disabled Query", + "ruleUID": "ab2ac850-1669-4dac-b566-c91728425f39", + "schemaVersion": 1, + "values": {}, + }, + "timestamp": 1727189670000, + }, + { + "line": { + "condition": "C", + "current": "NoData", + "dashboardUID": "", + "fingerprint": "f4c073dd4c60e897", + "labels": { + "alertname": "Alert State History Random", + "datasource_uid": "gdev-prometheus", + "grafana_folder": "Testing and reproducing", + "ref_id": "A", + }, + "panelID": 0, + "previous": "Normal", + "ruleID": 10614, + "ruleTitle": "Alert State History Random", + "ruleUID": "e4d7252b-c66f-4f5c-b7db-28b9936150bd", + "schemaVersion": 1, + "values": {}, + }, + "timestamp": 1727189670000, + }, + { + "line": { + "condition": "C", + "current": "Pending", + "dashboardUID": "", + "fingerprint": "e11b0ff29886e8f2", + "labels": { + "alertname": "node_namespace_pod_container:container_cpu_usage_seconds_total:sum_irate4", + "grafana_folder": "Testing and reproducing", + }, + "panelID": 0, + "previous": "Normal", + "ruleID": 10662, + "ruleTitle": "node_namespace_pod_container:container_cpu_usage_seconds_total:sum_irate4", + "ruleUID": "ddgfq7c57fqpsc", + "schemaVersion": 1, + "values": { + "B": 54.137611900622574, + "C": 1, + }, + }, + "timestamp": 1727189670000, + }, + { + "line": { + "condition": "B", + "current": "NoData", + "dashboardUID": "", + "fingerprint": "d6d7e256298c8ea8", + "labels": { + "alertname": "delete-ds", + "datasource_uid": "gdev-prometheus", + "grafana_folder": "Testing and reproducing", + "ref_id": "A", + }, + "panelID": 0, + "previous": "Normal", + "ruleID": 144, + "ruleTitle": "delete-ds", + "ruleUID": "wXS0SnWVz", + "schemaVersion": 1, + "values": {}, + }, + "timestamp": 1727189670000, + }, + { + "line": { + "condition": "C", + "current": "NoData", + "dashboardUID": "", + "fingerprint": "bf1b309577b4fc0c", + "labels": { + "alertname": "Threshold Test", + "datasource_uid": "gdev-prometheus", + "grafana_folder": "Testing and reproducing", + "ref_id": "A", + }, + "panelID": 0, + "previous": "Normal", + "ruleID": 148, + "ruleTitle": "Threshold Test", + "ruleUID": "u0QVOqMVz", + "schemaVersion": 1, + "values": {}, + }, + "timestamp": 1727189670000, + }, + { + "line": { + "condition": "C", + "current": "NoData", + "dashboardUID": "", + "fingerprint": "d6b1cde6ab63bfca", + "labels": { + "alertname": "Alert History fingerprint", + "datasource_uid": "PDDA8E780A17E7EF1", + "grafana_folder": "Testing and reproducing", + "ref_id": "A", + }, + "panelID": 0, + "previous": "Normal", + "ruleID": 10619, + "ruleTitle": "Alert History fingerprint", + "ruleUID": "c7a7989f-f12c-4aa5-8fb3-27f4509a5809", + "schemaVersion": 1, + "values": {}, + }, + "timestamp": 1727189670000, + }, + { + "line": { + "condition": "C", + "current": "Pending", + "dashboardUID": "d4c61dfe-24d8-433e-984e-701a2f14faab", + "fingerprint": "40ddb0f6e97426e9", + "labels": { + "alertname": "UniFi CPU Usage", + "grafana_folder": "UniFi", + "instance": "unpoller:9130", + "job": "unifipoller", + "name": "Dream Router", + "site_name": "Default (default)", + "source": "https://192.168.1.1", + "type": "udm", + }, + "panelID": 1, + "previous": "Normal", + "ruleID": 10638, + "ruleTitle": "UniFi CPU Usage", + "ruleUID": "d10fc550-3552-4083-a3aa-f6b68058bc7f", + "schemaVersion": 1, + "values": { + "B": 0.0020875, + "C": 1, + }, + }, + "timestamp": 1727189670000, + }, + { + "line": { + "condition": "B", + "current": "Alerting", + "dashboardUID": "", + "fingerprint": "10716534bbf8086b", + "labels": { + "alertname": "XSS attack vector", + "grafana_folder": "gdev dashboards", + }, + "panelID": 0, + "previous": "Pending", + "ruleID": 37, + "ruleTitle": "XSS attack vector", + "ruleUID": "p8TBxnq7k", + "schemaVersion": 1, + "values": { + "B0": 36.956598435022215, + }, + }, + "timestamp": 1727189680000, + }, + { + "line": { + "condition": "C", + "current": "Normal (MissingSeries)", + "dashboardUID": "iYSJkozVk", + "fingerprint": "4a5519a42c382438", + "labels": { + "alertname": "CPU Usage", + "datasource_uid": "gdev-prometheus", + "grafana_folder": "Demonstrations", + "ref_id": "A", + "team": "operations", + "type": "cpu", + }, + "panelID": 5, + "previous": "NoData", + "ruleID": 7635, + "ruleTitle": "CPU Usage", + "ruleUID": "8nEmeE44k", + "schemaVersion": 1, + "values": {}, + }, + "timestamp": 1727189700000, + }, + { + "line": { + "condition": "C", + "current": "Alerting", + "dashboardUID": "iYSJkozVk", + "fingerprint": "cc4aceb3018da2e8", + "labels": { + "alertname": "CPU Usage", + "cpu": "7", + "grafana_folder": "Demonstrations", + "team": "operations", + "type": "cpu", + }, + "panelID": 5, + "previous": "Normal", + "ruleID": 7635, + "ruleTitle": "CPU Usage", + "ruleUID": "8nEmeE44k", + "schemaVersion": 1, + "values": { + "B": 0.067575, + "C": 1, + }, + }, + "timestamp": 1727189690000, + }, + { + "line": { + "condition": "C", + "current": "Alerting", + "dashboardUID": "iYSJkozVk", + "fingerprint": "20dfd648881e698f", + "labels": { + "alertname": "CPU Usage", + "cpu": "2", + "grafana_folder": "Demonstrations", + "team": "operations", + "type": "cpu", + }, + "panelID": 5, + "previous": "Normal", + "ruleID": 7635, + "ruleTitle": "CPU Usage", + "ruleUID": "8nEmeE44k", + "schemaVersion": 1, + "values": { + "B": 0.06216900000000001, + "C": 1, + }, + }, + "timestamp": 1727189690000, + }, + { + "line": { + "condition": "C", + "current": "Alerting", + "dashboardUID": "iYSJkozVk", + "fingerprint": "234db8cd1e160f6a", + "labels": { + "alertname": "CPU Usage", + "cpu": "1", + "grafana_folder": "Demonstrations", + "team": "operations", + "type": "cpu", + }, + "panelID": 5, + "previous": "Normal", + "ruleID": 7635, + "ruleTitle": "CPU Usage", + "ruleUID": "8nEmeE44k", + "schemaVersion": 1, + "values": { + "B": 0.07027800000000001, + "C": 1, + }, + }, + "timestamp": 1727189690000, + }, + { + "line": { + "condition": "C", + "current": "Alerting", + "dashboardUID": "iYSJkozVk", + "fingerprint": "4e1f85b7efc3ed75", + "labels": { + "alertname": "CPU Usage", + "cpu": "0", + "grafana_folder": "Demonstrations", + "team": "operations", + "type": "cpu", + }, + "panelID": 5, + "previous": "Normal", + "ruleID": 7635, + "ruleTitle": "CPU Usage", + "ruleUID": "8nEmeE44k", + "schemaVersion": 1, + "values": { + "B": 0.06727466666666668, + "C": 1, + }, + }, + "timestamp": 1727189690000, + }, + { + "line": { + "condition": "C", + "current": "Alerting", + "dashboardUID": "iYSJkozVk", + "fingerprint": "50bf9f17b9534593", + "labels": { + "alertname": "CPU Usage", + "cpu": "6", + "grafana_folder": "Demonstrations", + "team": "operations", + "type": "cpu", + }, + "panelID": 5, + "previous": "Normal", + "ruleID": 7635, + "ruleTitle": "CPU Usage", + "ruleUID": "8nEmeE44k", + "schemaVersion": 1, + "values": { + "B": 0.05315900000000001, + "C": 1, + }, + }, + "timestamp": 1727189690000, + }, + { + "line": { + "condition": "C", + "current": "Alerting", + "dashboardUID": "iYSJkozVk", + "fingerprint": "2fb0af95df9b2bfe", + "labels": { + "alertname": "CPU Usage", + "cpu": "5", + "grafana_folder": "Demonstrations", + "team": "operations", + "type": "cpu", + }, + "panelID": 5, + "previous": "Normal", + "ruleID": 7635, + "ruleTitle": "CPU Usage", + "ruleUID": "8nEmeE44k", + "schemaVersion": 1, + "values": { + "B": 0.046551666666666686, + "C": 1, + }, + }, + "timestamp": 1727189690000, + }, + { + "line": { + "condition": "C", + "current": "Alerting", + "dashboardUID": "iYSJkozVk", + "fingerprint": "d0303e26f6c376e9", + "labels": { + "alertname": "CPU Usage", + "cpu": "4", + "grafana_folder": "Demonstrations", + "team": "operations", + "type": "cpu", + }, + "panelID": 5, + "previous": "Normal", + "ruleID": 7635, + "ruleTitle": "CPU Usage", + "ruleUID": "8nEmeE44k", + "schemaVersion": 1, + "values": { + "B": 0.05466066666666668, + "C": 1, + }, + }, + "timestamp": 1727189690000, + }, + { + "line": { + "condition": "C", + "current": "Alerting", + "dashboardUID": "iYSJkozVk", + "fingerprint": "af82d1220e70c3b4", + "labels": { + "alertname": "CPU Usage", + "cpu": "3", + "grafana_folder": "Demonstrations", + "team": "operations", + "type": "cpu", + }, + "panelID": 5, + "previous": "Normal", + "ruleID": 7635, + "ruleTitle": "CPU Usage", + "ruleUID": "8nEmeE44k", + "schemaVersion": 1, + "values": { + "B": 0.059165666666666665, + "C": 1, + }, + }, + "timestamp": 1727189690000, + }, + { + "line": { + "condition": "C", + "current": "Normal (MissingSeries)", + "dashboardUID": "iYSJkozVk", + "fingerprint": "4a5519a42c382438", + "labels": { + "alertname": "CPU Usage", + "datasource_uid": "gdev-prometheus", + "grafana_folder": "Demonstrations", + "ref_id": "A", + "team": "operations", + "type": "cpu", + }, + "panelID": 5, + "previous": "Error", + "ruleID": 7635, + "ruleTitle": "CPU Usage", + "ruleUID": "8nEmeE44k", + "schemaVersion": 1, + "values": {}, + }, + "timestamp": 1727189680000, + }, + { + "line": { + "condition": "C", + "current": "NoData", + "dashboardUID": "iYSJkozVk", + "fingerprint": "4a5519a42c382438", + "labels": { + "alertname": "CPU Usage", + "datasource_uid": "gdev-prometheus", + "grafana_folder": "Demonstrations", + "ref_id": "A", + "team": "operations", + "type": "cpu", + }, + "panelID": 5, + "previous": "Normal", + "ruleID": 7635, + "ruleTitle": "CPU Usage", + "ruleUID": "8nEmeE44k", + "schemaVersion": 1, + "values": {}, + }, + "timestamp": 1727189670000, + }, + { + "line": { + "condition": "C", + "current": "Pending", + "dashboardUID": "", + "fingerprint": "abeb2444057d7f01", + "labels": { + "alertname": "test-read-only-threshold-indicators", + "grafana_folder": "Testing and reproducing", + }, + "panelID": 0, + "previous": "Normal", + "ruleID": 10606, + "ruleTitle": "test-read-only-threshold-indicators", + "ruleUID": "Wq5jJlc4k", + "schemaVersion": 1, + "values": { + "C0": 9.319781010143206, + }, + }, + "timestamp": 1727189700000, + }, + { + "line": { + "condition": "threshold", + "current": "Pending", + "dashboardUID": "", + "fingerprint": "2e7f5e6f5a5f2902", + "labels": { + "alertname": "CPU Usage 5", + "cpu": "6", + "grafana_folder": "Demonstrations", + "team": "operations", + }, + "panelID": 0, + "previous": "Normal", + "ruleID": 10583, + "ruleTitle": "CPU Usage 5", + "ruleUID": "LvMu_IHVz", + "schemaVersion": 1, + "values": { + "reducer": 0.07305751032920166, + "threshold": 1, + }, + }, + "timestamp": 1727189710000, + }, + { + "line": { + "condition": "threshold", + "current": "Pending", + "dashboardUID": "", + "fingerprint": "1c1b21f738468ea5", + "labels": { + "alertname": "CPU Usage 5", + "cpu": "5", + "grafana_folder": "Demonstrations", + "team": "operations", + }, + "panelID": 0, + "previous": "Normal", + "ruleID": 10583, + "ruleTitle": "CPU Usage 5", + "ruleUID": "LvMu_IHVz", + "schemaVersion": 1, + "values": { + "reducer": 0.06820108623217379, + "threshold": 1, + }, + }, + "timestamp": 1727189710000, + }, + { + "line": { + "condition": "threshold", + "current": "Pending", + "dashboardUID": "", + "fingerprint": "23ee5f02850e38f8", + "labels": { + "alertname": "CPU Usage 5", + "cpu": "4", + "grafana_folder": "Demonstrations", + "team": "operations", + }, + "panelID": 0, + "previous": "Normal", + "ruleID": 10583, + "ruleTitle": "CPU Usage 5", + "ruleUID": "LvMu_IHVz", + "schemaVersion": 1, + "values": { + "reducer": 0.07685819005731041, + "threshold": 1, + }, + }, + "timestamp": 1727189710000, + }, + { + "line": { + "condition": "threshold", + "current": "Pending", + "dashboardUID": "", + "fingerprint": "2a810fa152d6407b", + "labels": { + "alertname": "CPU Usage 5", + "cpu": "3", + "grafana_folder": "Demonstrations", + "team": "operations", + }, + "panelID": 0, + "previous": "Normal", + "ruleID": 10583, + "ruleTitle": "CPU Usage 5", + "ruleUID": "LvMu_IHVz", + "schemaVersion": 1, + "values": { + "reducer": 0.08614874050379848, + "threshold": 1, + }, + }, + "timestamp": 1727189710000, + }, + { + "line": { + "condition": "threshold", + "current": "Pending", + "dashboardUID": "", + "fingerprint": "48d127e8826c6fb6", + "labels": { + "alertname": "CPU Usage 5", + "cpu": "2", + "grafana_folder": "Demonstrations", + "team": "operations", + }, + "panelID": 0, + "previous": "Normal", + "ruleID": 10583, + "ruleTitle": "CPU Usage 5", + "ruleUID": "LvMu_IHVz", + "schemaVersion": 1, + "values": { + "reducer": 0.0926943555910969, + "threshold": 1, + }, + }, + "timestamp": 1727189710000, + }, + { + "line": { + "condition": "threshold", + "current": "Pending", + "dashboardUID": "", + "fingerprint": "c9582e3d28402fe9", + "labels": { + "alertname": "CPU Usage 5", + "cpu": "1", + "grafana_folder": "Demonstrations", + "team": "operations", + }, + "panelID": 0, + "previous": "Normal", + "ruleID": 10583, + "ruleTitle": "CPU Usage 5", + "ruleUID": "LvMu_IHVz", + "schemaVersion": 1, + "values": { + "reducer": 0.1097974143675863, + "threshold": 1, + }, + }, + "timestamp": 1727189710000, + }, + { + "line": { + "condition": "threshold", + "current": "Pending", + "dashboardUID": "", + "fingerprint": "fb3e32509acec7ec", + "labels": { + "alertname": "CPU Usage 5", + "cpu": "0", + "grafana_folder": "Demonstrations", + "team": "operations", + }, + "panelID": 0, + "previous": "Normal", + "ruleID": 10583, + "ruleTitle": "CPU Usage 5", + "ruleUID": "LvMu_IHVz", + "schemaVersion": 1, + "values": { + "reducer": 0.12985655737704918, + "threshold": 1, + }, + }, + "timestamp": 1727189710000, + }, + { + "line": { + "condition": "B", + "current": "Pending", + "dashboardUID": "", + "fingerprint": "91a8126f41689d98", + "labels": { + "alertname": "Random Values", + "grafana_folder": "Demonstrations", + }, + "panelID": 0, + "previous": "Normal", + "ruleID": 146, + "ruleTitle": "Random Values", + "ruleUID": "I1M_iCWVk", + "schemaVersion": 1, + "values": { + "B0": 8.406488484275167, + }, + }, + "timestamp": 1727189710000, + }, + { + "line": { + "condition": "threshold", + "current": "Pending", + "dashboardUID": "", + "fingerprint": "6cd2ab200de1cf77", + "labels": { + "alertname": "CPU Usage 5", + "cpu": "7", + "grafana_folder": "Demonstrations", + "team": "operations", + }, + "panelID": 0, + "previous": "Normal", + "ruleID": 10583, + "ruleTitle": "CPU Usage 5", + "ruleUID": "LvMu_IHVz", + "schemaVersion": 1, + "values": { + "reducer": 0.08973827135812343, + "threshold": 1, + }, + }, + "timestamp": 1727189710000, + }, + ], +} +`; + +exports[`ruleHistoryToRecords should convert rule history JSON response with filters 1`] = ` +{ + "historyRecords": [ + { + "line": { + "condition": "B", + "current": "Alerting", + "dashboardUID": "", + "fingerprint": "10716534bbf8086b", + "labels": { + "alertname": "XSS attack vector", + "grafana_folder": "gdev dashboards", + }, + "panelID": 0, + "previous": "Pending", + "ruleID": 37, + "ruleTitle": "XSS attack vector", + "ruleUID": "p8TBxnq7k", + "schemaVersion": 1, + "values": { + "B0": 36.956598435022215, + }, + }, + "timestamp": 1727189680000, + }, + ], +} +`; diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/__snapshots__/utils.test.ts.snap b/public/app/features/alerting/unified/components/rules/central-state-history/__snapshots__/utils.test.ts.snap new file mode 100644 index 00000000000..e1edf6a4842 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/central-state-history/__snapshots__/utils.test.ts.snap @@ -0,0 +1,71 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`historyResultToDataFrame should decode 1`] = ` +[ + { + "fields": [ + { + "config": { + "custom": { + "fillOpacity": 100, + }, + "displayName": "Time", + }, + "name": "time", + "type": "time", + "values": [ + 1727189670000, + 1727189680000, + 1727189700000, + 1727189690000, + 1727189710000, + ], + }, + { + "config": {}, + "name": "value", + "type": "number", + "values": [ + 11, + 2, + 2, + 8, + 9, + ], + }, + ], + "length": 5, + }, +] +`; + +exports[`historyResultToDataFrame should decode and filter 1`] = ` +[ + { + "fields": [ + { + "config": { + "custom": { + "fillOpacity": 100, + }, + "displayName": "Time", + }, + "name": "time", + "type": "time", + "values": [ + 1727189680000, + ], + }, + { + "config": {}, + "name": "value", + "type": "number", + "values": [ + 1, + ], + }, + ], + "length": 1, + }, +] +`; diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/useRuleHistoryRecords.test.ts b/public/app/features/alerting/unified/components/rules/central-state-history/useRuleHistoryRecords.test.ts new file mode 100644 index 00000000000..9acf26edd49 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/central-state-history/useRuleHistoryRecords.test.ts @@ -0,0 +1,20 @@ +import fixtureData from './__fixtures__/alert-state-history'; +import { ruleHistoryToRecords } from './useRuleHistoryRecords'; + +describe('ruleHistoryToRecords', () => { + it('should convert rule history JSON response to log records', () => { + expect(ruleHistoryToRecords(fixtureData)).toMatchSnapshot(); + }); + + it('should convert rule history JSON response with filters', () => { + expect( + ruleHistoryToRecords(fixtureData, { stateFrom: 'Pending', stateTo: 'Alerting', labels: '' }) + ).toMatchSnapshot(); + }); + + it('should be empty with no matches', () => { + expect( + ruleHistoryToRecords(fixtureData, { stateFrom: 'Pending', stateTo: 'Alerting', labels: 'doesNot=exist' }) + ).toMatchSnapshot(); + }); +}); diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/useRuleHistoryRecords.ts b/public/app/features/alerting/unified/components/rules/central-state-history/useRuleHistoryRecords.ts new file mode 100644 index 00000000000..ee8c739d3e5 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/central-state-history/useRuleHistoryRecords.ts @@ -0,0 +1,65 @@ +import { useMemo } from 'react'; + +import { DataFrameJSON } from '@grafana/data'; +import { mapStateWithReasonToBaseState } from 'app/types/unified-alerting-dto'; + +import { labelsMatchMatchers } from '../../../utils/alertmanager'; +import { parsePromQLStyleMatcherLooseSafe } from '../../../utils/matchers'; +import { LogRecord } from '../state-history/common'; +import { isLine, isNumbers } from '../state-history/useRuleHistoryRecords'; + +import { StateFilterValues } from './CentralAlertHistoryScene'; + +const emptyFilters = { + labels: '', + stateFrom: 'all', + stateTo: 'all', +}; + +/** + * This hook filters the history records based on the label, stateTo and stateFrom filters. + * @param filterInLabel + * @param filterInStateTo + * @param filterInStateFrom + * @param stateHistory the original history records + * @returns the filtered history records + */ +export function useRuleHistoryRecords(stateHistory?: DataFrameJSON, filters = emptyFilters) { + return useMemo(() => ruleHistoryToRecords(stateHistory, filters), [filters, stateHistory]); +} + +export function ruleHistoryToRecords(stateHistory?: DataFrameJSON, filters = emptyFilters) { + const { labels, stateFrom, stateTo } = filters; + + if (!stateHistory?.data) { + return { historyRecords: [] }; + } + + const filterMatchers = labels ? parsePromQLStyleMatcherLooseSafe(labels) : []; + + const [tsValues, lines] = stateHistory.data.values; + const timestamps = isNumbers(tsValues) ? tsValues : []; + + // merge timestamp with "line" + const logRecords = timestamps.reduce((acc: LogRecord[], timestamp: number, index: number) => { + const line = lines[index]; + if (!isLine(line)) { + return acc; + } + // values property can be undefined for some instance states (e.g. NoData) + const filterMatch = line.labels && labelsMatchMatchers(line.labels, filterMatchers); + const baseStateTo = mapStateWithReasonToBaseState(line.current); + const baseStateFrom = mapStateWithReasonToBaseState(line.previous); + const stateToMatch = stateTo !== StateFilterValues.all ? stateTo === baseStateTo : true; + const stateFromMatch = stateFrom !== StateFilterValues.all ? stateFrom === baseStateFrom : true; + if (filterMatch && stateToMatch && stateFromMatch) { + acc.push({ timestamp, line }); + } + + return acc; + }, []); + + return { + historyRecords: logRecords, + }; +} diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/utils.test.ts b/public/app/features/alerting/unified/components/rules/central-state-history/utils.test.ts new file mode 100644 index 00000000000..547db61db8f --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/central-state-history/utils.test.ts @@ -0,0 +1,12 @@ +import fixtureData from './__fixtures__/alert-state-history'; +import { historyResultToDataFrame } from './utils'; + +describe('historyResultToDataFrame', () => { + it('should decode', () => { + expect(historyResultToDataFrame(fixtureData)).toMatchSnapshot(); + }); + + it('should decode and filter', () => { + expect(historyResultToDataFrame(fixtureData, { stateFrom: 'Pending', stateTo: 'Alerting' })).toMatchSnapshot(); + }); +}); diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/utils.ts b/public/app/features/alerting/unified/components/rules/central-state-history/utils.ts index e032858c69d..28c620b4605 100644 --- a/public/app/features/alerting/unified/components/rules/central-state-history/utils.ts +++ b/public/app/features/alerting/unified/components/rules/central-state-history/utils.ts @@ -12,7 +12,7 @@ import { getDisplayProcessor, } from '@grafana/data'; import { fieldIndexComparer } from '@grafana/data/src/field/fieldComparers'; -import { isGrafanaAlertState, mapStateWithReasonToBaseState } from 'app/types/unified-alerting-dto'; +import { mapStateWithReasonToBaseState } from 'app/types/unified-alerting-dto'; import { labelsMatchMatchers } from '../../../utils/alertmanager'; import { parsePromQLStyleMatcherLooseSafe } from '../../../utils/matchers'; @@ -23,43 +23,43 @@ import { LABELS_FILTER, STATE_FILTER_FROM, STATE_FILTER_TO, StateFilterValues } const GROUPING_INTERVAL = 10 * 1000; // 10 seconds const QUERY_PARAM_PREFIX = 'var-'; // Prefix used by Grafana to sync variables in the URL + +const emptyFilters = { + stateTo: 'all', + stateFrom: 'all', +}; + /* * This function is used to convert the history response to a DataFrame list and filter the data by labels and states * The response is a list of log records, each log record has a timestamp and a line. * We group all records by alert instance (unique set of labels) and create a DataFrame for each group (instance). * This allows us to be able to filter by labels and states in the groupDataFramesByTime function. */ -export function historyResultToDataFrame(data: DataFrameJSON): DataFrame[] { - // Get the labels and states filters from the URL - const stateToInQueryParams = getStateFilterToInQueryParams(); - const stateFromInQueryParams = getStateFilterFromInQueryParams(); - const stateToFilterValue = stateToInQueryParams === '' ? StateFilterValues.all : stateToInQueryParams; - const stateFromFilterValue = stateFromInQueryParams === '' ? StateFilterValues.all : stateFromInQueryParams; +export function historyResultToDataFrame({ data }: DataFrameJSON, filters = emptyFilters): DataFrame[] { + const { stateTo, stateFrom } = filters; // Extract timestamps and lines from the response - const tsValues = data?.data?.values[0] ?? []; - const timestamps: number[] = isNumbers(tsValues) ? tsValues : []; - const lines = data?.data?.values[1] ?? []; + const [tsValues = [], lines = []] = data?.values ?? []; + const timestamps = isNumbers(tsValues) ? tsValues : []; // Filter log records by state and create a list of log records with the timestamp and line - const logRecords = timestamps.reduce((acc: LogRecord[], timestamp: number, index: number) => { + const logRecords = timestamps.reduce((acc, timestamp: number, index: number) => { const line = lines[index]; - // values property can be undefined for some instance states (e.g. NoData) - if (isLine(line)) { - if (!isGrafanaAlertState(line.current)) { - return acc; - } - // we have to filter out by state at that point , because we are going to group by timestamp and these states are going to be lost - const baseStateTo = mapStateWithReasonToBaseState(line.current); - const baseStateFrom = mapStateWithReasonToBaseState(line.previous); - const stateToMatch = stateToFilterValue !== StateFilterValues.all ? stateToFilterValue === baseStateTo : true; - const stateFromMatch = - stateFromFilterValue !== StateFilterValues.all ? stateFromFilterValue === baseStateFrom : true; - // filter by state - if (stateToMatch && stateFromMatch) { - acc.push({ timestamp, line }); - } + if (!isLine(line)) { + return acc; } + + // we have to filter out by state at that point , because we are going to group by timestamp and these states are going to be lost + const baseStateTo = mapStateWithReasonToBaseState(line.current); + const baseStateFrom = mapStateWithReasonToBaseState(line.previous); + const stateToMatch = stateTo !== StateFilterValues.all ? stateTo === baseStateTo : true; + const stateFromMatch = stateFrom !== StateFilterValues.all ? stateFrom === baseStateFrom : true; + + // filter by state + if (stateToMatch && stateFromMatch) { + acc.push({ timestamp, line }); + } + return acc; }, []); @@ -79,19 +79,21 @@ export function historyResultToDataFrame(data: DataFrameJSON): DataFrame[] { } // Scenes sync variables in the URL adding a prefix to the variable name. -function getLabelsFilterInQueryParams() { +export function getLabelsFilterInQueryParams() { const queryParams = new URLSearchParams(window.location.search); return queryParams.get(`${QUERY_PARAM_PREFIX}${LABELS_FILTER}`) ?? ''; } -function getStateFilterToInQueryParams() { + +export function getStateFilterToInQueryParams() { const queryParams = new URLSearchParams(window.location.search); - return queryParams.get(`${QUERY_PARAM_PREFIX}${STATE_FILTER_TO}`) ?? ''; + return queryParams.get(`${QUERY_PARAM_PREFIX}${STATE_FILTER_TO}`) ?? StateFilterValues.all; } -function getStateFilterFromInQueryParams() { +export function getStateFilterFromInQueryParams() { const queryParams = new URLSearchParams(window.location.search); - return queryParams.get(`${QUERY_PARAM_PREFIX}${STATE_FILTER_FROM}`) ?? ''; + return queryParams.get(`${QUERY_PARAM_PREFIX}${STATE_FILTER_FROM}`) ?? StateFilterValues.all; } + /* * This function groups the data frames by time and filters them by labels. * The interval is set to 10 seconds. diff --git a/public/app/features/alerting/unified/types/rule-form.ts b/public/app/features/alerting/unified/types/rule-form.ts index 3e65e2ca556..e99d56ee7e0 100644 --- a/public/app/features/alerting/unified/types/rule-form.ts +++ b/public/app/features/alerting/unified/types/rule-form.ts @@ -25,6 +25,10 @@ export interface AlertManagerManualRouting { [key: string]: ContactPoint; } +export interface SimplifiedEditor { + simplifiedQueryEditor: boolean; +} + export interface RuleFormValues { // common name: string; @@ -46,6 +50,7 @@ export interface RuleFormValues { isPaused?: boolean; manualRouting: boolean; // if true contactPoints are used. This field will not be used for saving the rule contactPoints?: AlertManagerManualRouting; + editorSettings?: SimplifiedEditor; metric?: string; // cortex / loki rules diff --git a/public/app/features/alerting/unified/utils/__snapshots__/rule-form.test.ts.snap b/public/app/features/alerting/unified/utils/__snapshots__/rule-form.test.ts.snap index 70899d7e938..71e08950c88 100644 --- a/public/app/features/alerting/unified/utils/__snapshots__/rule-form.test.ts.snap +++ b/public/app/features/alerting/unified/utils/__snapshots__/rule-form.test.ts.snap @@ -9,6 +9,7 @@ exports[`formValuesToRulerGrafanaRuleDTO should correctly convert rule form valu "data": [], "exec_err_state": "Error", "is_paused": false, + "metadata": undefined, "no_data_state": "NoData", "notification_settings": undefined, "title": "", @@ -59,6 +60,7 @@ exports[`formValuesToRulerGrafanaRuleDTO should not save both instant and range ], "exec_err_state": "Error", "is_paused": false, + "metadata": undefined, "no_data_state": "NoData", "notification_settings": undefined, "title": "", diff --git a/public/app/features/alerting/unified/utils/rule-form.ts b/public/app/features/alerting/unified/utils/rule-form.ts index 547e3031aea..231a3c51acf 100644 --- a/public/app/features/alerting/unified/utils/rule-form.ts +++ b/public/app/features/alerting/unified/utils/rule-form.ts @@ -43,7 +43,13 @@ import { type KVObject = { key: string; value: string }; import { EvalFunction } from '../../state/alertDef'; -import { AlertManagerManualRouting, ContactPoint, RuleFormType, RuleFormValues } from '../types/rule-form'; +import { + AlertManagerManualRouting, + ContactPoint, + RuleFormType, + RuleFormValues, + SimplifiedEditor, +} from '../types/rule-form'; import { getRulesAccess } from './access-control'; import { Annotation, defaultAnnotations } from './constants'; @@ -62,6 +68,7 @@ import { formatPrometheusDuration, parseInterval, safeParsePrometheusDuration } export type PromOrLokiQuery = PromQuery | LokiQuery; export const MANUAL_ROUTING_KEY = 'grafana.alerting.manualRouting'; +export const SIMPLIFIED_QUERY_EDITOR_KEY = 'grafana.alerting.simplifiedQueryEditor'; // even if the min interval is < 1m we should default to 1m, but allow arbitrary values for minInterval > 1m const GROUP_EVALUATION_MIN_INTERVAL_MS = safeParsePrometheusDuration(config.unifiedAlerting?.minInterval ?? '10s'); @@ -98,6 +105,7 @@ export const getDefaultFormValues = (): RuleFormValues => { overrideGrouping: false, overrideTimings: false, muteTimeIntervals: [], + editorSettings: getDefaultEditorSettings(), // cortex / loki namespace: '', @@ -119,6 +127,18 @@ export const getDefautManualRouting = () => { return manualRouting !== 'false'; }; +function getDefaultEditorSettings() { + const editorSettingsEnabled = config.featureToggles.alertingQueryAndExpressionsStepMode ?? false; + if (!editorSettingsEnabled) { + return undefined; + } + //then, check in local storage if the user has saved last rule with simplified query editor + const queryEditorSettings = localStorage.getItem(SIMPLIFIED_QUERY_EDITOR_KEY); + return { + simplifiedQueryEditor: queryEditorSettings !== 'false', + }; +} + export function formValuesToRulerRuleDTO(values: RuleFormValues): RulerRuleDTO { const { name, expression, forTime, forTimeUnit, keepFiringForTime, keepFiringForTimeUnit, type } = values; @@ -202,7 +222,11 @@ export function getNotificationSettingsForDTO( } return undefined; } - +function getEditorSettingsForDTO(simplifiedEditor: SimplifiedEditor) { + return { + simplified_query_and_expressions_section: simplifiedEditor.simplifiedQueryEditor, + }; +} export function formValuesToRulerGrafanaRuleDTO(values: RuleFormValues): PostableRuleGrafanaRuleDTO { const { name, @@ -222,6 +246,9 @@ export function formValuesToRulerGrafanaRuleDTO(values: RuleFormValues): Postabl } const notificationSettings = getNotificationSettingsForDTO(manualRouting, contactPoints); + const metadata = values.editorSettings + ? { editor_settings: getEditorSettingsForDTO(values.editorSettings) } + : undefined; const annotations = arrayToRecord(cleanAnnotations(values.annotations)); const labels = arrayToRecord(cleanLabels(values.labels)); @@ -241,6 +268,7 @@ export function formValuesToRulerGrafanaRuleDTO(values: RuleFormValues): Postabl no_data_state: noDataState, exec_err_state: execErrState, notification_settings: notificationSettings, + metadata, }, annotations, labels, @@ -307,6 +335,23 @@ export function getContactPointsFromDTO(ga: GrafanaRuleDefinition): AlertManager return routingSettings; } +function getEditorSettingsFromDTO(ga: GrafanaRuleDefinition) { + // we need to check if the feature toggle is enabled as it might be disabled after the rule was created with the feature enabled + if (!config.featureToggles.alertingQueryAndExpressionsStepMode) { + return undefined; + } + + if (ga.metadata?.editor_settings) { + return { + simplifiedQueryEditor: ga.metadata.editor_settings.simplified_query_and_expressions_section, + }; + } + + return { + simplifiedQueryEditor: false, + }; +} + export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleFormValues { const { ruleSourceName, namespace, group, rule } = ruleWithLocation; @@ -353,6 +398,8 @@ export function rulerRuleToFormValues(ruleWithLocation: RuleWithLocation): RuleF contactPoints: routingSettings, manualRouting: Boolean(routingSettings), + + editorSettings: getEditorSettingsFromDTO(ga), }; } else { throw new Error('Unexpected type of rule for grafana rules source'); diff --git a/public/app/features/auth-config/AuthProvidersListPage.tsx b/public/app/features/auth-config/AuthProvidersListPage.tsx index 560c1a3a79a..717906f0f3a 100644 --- a/public/app/features/auth-config/AuthProvidersListPage.tsx +++ b/public/app/features/auth-config/AuthProvidersListPage.tsx @@ -57,7 +57,13 @@ export const AuthConfigPageUnconnected = ({ providers = providers.map((p) => { if (p.provider === 'ldap') { - p.settings.type = p.provider; + return { + ...p, + settings: { + ...p.settings, + type: 'LDAP', + }, + }; } return p; }); diff --git a/public/app/features/correlations/CorrelationsPage.test.tsx b/public/app/features/correlations/CorrelationsPage.test.tsx index bd5788ef25f..6c046e76d06 100644 --- a/public/app/features/correlations/CorrelationsPage.test.tsx +++ b/public/app/features/correlations/CorrelationsPage.test.tsx @@ -23,7 +23,7 @@ import { createRemoveCorrelationResponse, createUpdateCorrelationResponse, } from './__mocks__/useCorrelations.mocks'; -import { Correlation, CreateCorrelationParams } from './types'; +import { Correlation, CreateCorrelationParams, OmitUnion } from './types'; const renderWithContext = async ( datasources: ConstructorParameters[0] = {}, @@ -43,7 +43,7 @@ const renderWithContext = async ( throw createFetchCorrelationsError(); }, - post: async (url: string, data: Omit) => { + post: async (url: string, data: OmitUnion) => { const matches = url.match(/^\/api\/datasources\/uid\/(?[a-zA-Z0-9]+)\/correlations$/); if (matches?.groups) { const { sourceUID } = matches.groups; @@ -54,7 +54,7 @@ const renderWithContext = async ( throw createFetchCorrelationsError(); }, - patch: async (url: string, data: Omit) => { + patch: async (url: string, data: OmitUnion) => { const matches = url.match( /^\/api\/datasources\/uid\/(?[a-zA-Z0-9]+)\/correlations\/(?[a-zA-Z0-9]+)$/ ); diff --git a/public/app/features/correlations/CorrelationsPage.tsx b/public/app/features/correlations/CorrelationsPage.tsx index ea85765eae5..d653f30a353 100644 --- a/public/app/features/correlations/CorrelationsPage.tsx +++ b/public/app/features/correlations/CorrelationsPage.tsx @@ -2,7 +2,7 @@ import { css } from '@emotion/css'; import { negate } from 'lodash'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { GrafanaTheme2 } from '@grafana/data'; +import { DataSourceInstanceSettings, GrafanaTheme2 } from '@grafana/data'; import { isFetchError, reportInteraction } from '@grafana/runtime'; import { Badge, @@ -27,7 +27,7 @@ import { AccessControlAction } from 'app/types'; import { AddCorrelationForm } from './Forms/AddCorrelationForm'; import { EditCorrelationForm } from './Forms/EditCorrelationForm'; import { EmptyCorrelationsCTA } from './components/EmptyCorrelationsCTA'; -import type { RemoveCorrelationParams } from './types'; +import type { Correlation, RemoveCorrelationParams } from './types'; import { CorrelationData, useCorrelations } from './useCorrelations'; const sortDatasource: SortByFn = (a, b, column) => @@ -238,7 +238,7 @@ interface ExpandedRowProps { readOnly: boolean; onUpdated: () => void; } -function ExpendedRow({ correlation: { source, target, ...correlation }, readOnly, onUpdated }: ExpandedRowProps) { +function ExpendedRow({ correlation: { source, ...correlation }, readOnly, onUpdated }: ExpandedRowProps) { useEffect( () => reportInteraction('grafana_correlations_details_expanded'), // we only want to fire this on first render @@ -246,13 +246,12 @@ function ExpendedRow({ correlation: { source, target, ...correlation }, readOnly [] ); - return ( - - ); + let corr: Correlation = + correlation.type === 'query' + ? { ...correlation, type: 'query', sourceUID: source.uid, targetUID: correlation.target.uid } + : { ...correlation, type: 'external', sourceUID: source.uid }; + + return ; } const getDatasourceCellStyles = (theme: GrafanaTheme2) => ({ @@ -268,20 +267,22 @@ const getDatasourceCellStyles = (theme: GrafanaTheme2) => ({ }); const DataSourceCell = memo( - function DataSourceCell({ - cell: { value }, - }: CellProps) { + function DataSourceCell({ cell: { value } }: CellProps) { const styles = useStyles2(getDatasourceCellStyles); return ( - - {value.name} + {value?.name !== undefined && ( + <> + + {value.name} + + )} ); }, ({ cell: { value } }, { cell: { value: prevValue } }) => { - return value.type === prevValue.type && value.name === prevValue.name; + return value?.type === prevValue?.type && value?.name === prevValue?.name; } ); diff --git a/public/app/features/correlations/Forms/ConfigureCorrelationSourceForm.tsx b/public/app/features/correlations/Forms/ConfigureCorrelationSourceForm.tsx index 4c9b32067b1..b079b85d995 100644 --- a/public/app/features/correlations/Forms/ConfigureCorrelationSourceForm.tsx +++ b/public/app/features/correlations/Forms/ConfigureCorrelationSourceForm.tsx @@ -24,6 +24,35 @@ const getStyles = (theme: GrafanaTheme2) => ({ `, }); +const getFormText = (queryType: string, dataSourceName?: string) => { + if (queryType === 'query') { + return { + title: t( + 'correlations.source-form.query-title', + 'Configure the data source that will link to {{dataSourceName}} (Step 3 of 3)', + { dataSourceName } + ), + descriptionPre: t( + 'correlations.source-form.description-query-pre', + 'You have used following variables in the target query:' + ), + heading: t('correlations.source-form.heading-query', 'Variables used in the target query'), + }; + } else { + return { + title: t( + 'correlations.source-form.external-title', + 'Configure the data source that will use the URL (Step 3 of 3)' + ), + descriptionPre: t( + 'correlations.source-form.description-external-pre', + 'You have used following variables in the target URL:' + ), + heading: t('correlations.source-form.heading-external', 'Variables used in the target URL'), + }; + } +}; + export const ConfigureCorrelationSourceForm = () => { const { control, formState, register, getValues } = useFormContext(); const styles = useStyles2(getStyles); @@ -32,9 +61,13 @@ export const ConfigureCorrelationSourceForm = () => { const { correlation, readOnly } = useCorrelationsFormContext(); const currentTargetQuery = getValues('config.target'); + const currentType = getValues('type'); const variables = getVariableUsageInfo(currentTargetQuery, {}).variables.map( (variable) => variable.variableName + (variable.fieldPath ? `.${variable.fieldPath}` : '') ); + const dataSourceName = getDatasourceSrv().getInstanceSettings(getValues('targetUID'))?.name; + + const formText = getFormText(currentType, dataSourceName); function VariableList() { return ( @@ -49,16 +82,9 @@ export const ConfigureCorrelationSourceForm = () => { ); } - const dataSourceName = getDatasourceSrv().getInstanceSettings(getValues('targetUID'))?.name; return ( <> -
    +

    Define what data source will display the correlation, and what data will replace previously defined @@ -117,14 +143,14 @@ export const ConfigureCorrelationSourceForm = () => { {variables.length > 0 && ( - - Variables used in the target query - + {formText.heading} + {formText.descriptionPre} + +
    - You have used following variables in the target query: -
    A data point needs to provide values to all variables as fields or as transformations output to - make the correlation button appear in the visualization. + A data point needs to provide values to all variables as fields or as transformations output to make the + correlation button appear in the visualization.
    Note: Not every variable needs to be explicitly defined below. A transformation such as{' '} logfmt will create variables for every key/value pair. diff --git a/public/app/features/correlations/Forms/ConfigureCorrelationTargetForm.tsx b/public/app/features/correlations/Forms/ConfigureCorrelationTargetForm.tsx index f0f36c4dcfd..84e22a5c36d 100644 --- a/public/app/features/correlations/Forms/ConfigureCorrelationTargetForm.tsx +++ b/public/app/features/correlations/Forms/ConfigureCorrelationTargetForm.tsx @@ -1,64 +1,173 @@ -import { Controller, useFormContext, useWatch } from 'react-hook-form'; +import { css } from '@emotion/css'; +import { Controller, FieldError, useFormContext, useWatch } from 'react-hook-form'; -import { DataSourceInstanceSettings } from '@grafana/data'; -import { Field, FieldSet } from '@grafana/ui'; +import { DataSourceInstanceSettings, GrafanaTheme2 } from '@grafana/data'; +import { Field, FieldSet, Input, Select, useStyles2 } from '@grafana/ui'; import { Trans, t } from 'app/core/internationalization'; import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker'; +import { CorrelationType, ExternalTypeTarget } from '../types'; + import { QueryEditorField } from './QueryEditorField'; import { useCorrelationsFormContext } from './correlationsFormContext'; -import { FormDTO } from './types'; +import { assertIsQueryTypeError, FormDTO } from './types'; + +type CorrelationTypeOptions = { + value: CorrelationType; + label: string; + description: string; +}; + +export const CORR_TYPES_SELECT: Record = { + query: { + value: 'query', + label: 'Query', + description: 'Open a query', + }, + external: { + value: 'external', + label: 'External', + description: 'Open an external URL', + }, +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + typeSelect: css` + max-width: ${theme.spacing(40)}; + `, +}); export const ConfigureCorrelationTargetForm = () => { - const { control, formState } = useFormContext(); + const { + control, + formState: { errors }, + } = useFormContext(); const withDsUID = (fn: Function) => (ds: DataSourceInstanceSettings) => fn(ds.uid); const { correlation } = useCorrelationsFormContext(); - const targetUID: string | undefined = useWatch({ name: 'targetUID' }) || correlation?.targetUID; + const targetUIDFromCorrelation = correlation && 'targetUID' in correlation ? correlation.targetUID : undefined; + const targetUID: string | undefined = useWatch({ name: 'targetUID' }) || targetUIDFromCorrelation; + const correlationType: CorrelationType | undefined = useWatch({ name: 'type' }) || correlation?.type; + const styles = useStyles2(getStyles); return ( <>

    - Define what data source the correlation will link to, and what query will run when the correlation is - clicked. + Define what the correlation will link to. With the query type, a query will run when the correlation is + clicked. With the external type, clicking the correlation will open a URL.

    ( + render={({ field: { onChange, value, ...field } }) => ( - onChange(value.value)} + options={Object.values(CORR_TYPES_SELECT)} + aria-label="correlation type" /> )} /> - + {correlationType === 'query' && + (() => { + assertIsQueryTypeError(errors); + // the assert above will make sure the form dto, which can be either external or query, is for query + // however, the query type has config.target, an object, which doesn't get converted, so we must explicity type it below + return ( + <> + ( + + + + )} + /> + + + + ); + })()} + {correlationType === 'external' && ( + <> + { + const castVal = value as ExternalTypeTarget; // the target under "query" type can contain anything a datasource query contains + return ( + + { + onChange({ url: e.currentTarget.value }); + }} + /> + + ); + }} + /> + + )}
    ); diff --git a/public/app/features/correlations/Forms/QueryEditorField.tsx b/public/app/features/correlations/Forms/QueryEditorField.tsx index b2e9a55127c..34c99f892d8 100644 --- a/public/app/features/correlations/Forms/QueryEditorField.tsx +++ b/public/app/features/correlations/Forms/QueryEditorField.tsx @@ -52,12 +52,14 @@ export const QueryEditorField = ({ dsUid, invalid, error, name }: Props) => { name={name} rules={{ validate: { - hasQueryEditor: () => - QueryEditor !== undefined || - t( - 'correlations.query-editor.control-rules', - 'The selected target data source must export a query editor.' - ), + hasQueryEditor: (_, formVals) => { + return formVals.type === 'query' && QueryEditor === undefined + ? t( + 'correlations.query-editor.control-rules', + 'The selected target data source must export a query editor.' + ) + : true; + }, }, }} render={({ field: { value, onChange } }) => { diff --git a/public/app/features/correlations/Forms/types.ts b/public/app/features/correlations/Forms/types.ts index d95667fa38e..53cfc8b6e54 100644 --- a/public/app/features/correlations/Forms/types.ts +++ b/public/app/features/correlations/Forms/types.ts @@ -1,18 +1,36 @@ +import { DeepMap, FieldError, FieldErrors } from 'react-hook-form'; + import { SupportedTransformationType } from '@grafana/data'; import { t } from 'app/core/internationalization'; -import { CorrelationConfig, CorrelationType } from '../types'; +import { CorrelationConfigExternal, CorrelationConfigQuery, OmitUnion } from '../types'; -export interface FormDTO { +export interface FormExternalDTO { + sourceUID: string; + label: string; + description: string; + type: 'external'; + config: CorrelationConfigExternal; +} + +export interface FormQueryDTO { sourceUID: string; targetUID: string; label: string; description: string; - type: CorrelationType; - config: CorrelationConfig; + type: 'query'; + config: CorrelationConfigQuery; } -export type EditFormDTO = Omit; +export type FormDTO = FormExternalDTO | FormQueryDTO; + +export function assertIsQueryTypeError( + errors: FieldErrors +): asserts errors is DeepMap { + // explicitly assert the type so that TS can narrow down FormDTO to FormQueryDTO +} + +export type EditFormDTO = OmitUnion; export type TransformationDTO = { type: SupportedTransformationType; diff --git a/public/app/features/correlations/Forms/utils.ts b/public/app/features/correlations/Forms/utils.ts index be77c4f6b8b..0ca9b1ae549 100644 --- a/public/app/features/correlations/Forms/utils.ts +++ b/public/app/features/correlations/Forms/utils.ts @@ -1,6 +1,6 @@ import { Correlation } from '../types'; -type CorrelationBaseData = Pick; +type CorrelationBaseData = Pick; export const getInputId = (inputName: string, correlation?: CorrelationBaseData) => { if (!correlation) { diff --git a/public/app/features/correlations/types.ts b/public/app/features/correlations/types.ts index 4365930ce9b..ce1ff2c2213 100644 --- a/public/app/features/correlations/types.ts +++ b/public/app/features/correlations/types.ts @@ -26,30 +26,50 @@ export interface RemoveCorrelationResponse { message: string; } -export type CorrelationType = 'query'; +export type CorrelationType = 'query' | 'external'; -export interface CorrelationConfig { +export type ExternalTypeTarget = { url: string }; + +export type CorrelationConfigQuery = { field: string; - target: object; // this contains anything that would go in the query editor, so any extension off DataQuery a datasource would have, and needs to be generic + target: object; // for queries, this contains anything that would go in the query editor, so any extension off DataQuery a datasource would have, and needs to be generic. transformations?: DataLinkTransformationConfig[]; -} +}; -export interface Correlation { +export type CorrelationConfigExternal = { + field: string; + target: ExternalTypeTarget; // For external, this simply contains a URL + transformations?: DataLinkTransformationConfig[]; +}; + +type CorrelationBase = { uid: string; sourceUID: string; - targetUID: string; label?: string; description?: string; provisioned: boolean; orgId?: number; - config: CorrelationConfig; - type: CorrelationType; -} +}; + +export type CorrelationExternal = CorrelationBase & { + type: 'external'; + config: CorrelationConfigExternal; +}; + +export type CorrelationQuery = CorrelationBase & { + type: 'query'; + config: CorrelationConfigQuery; + targetUID: string; +}; + +export type Correlation = CorrelationExternal | CorrelationQuery; export type GetCorrelationsParams = { page: number; }; +export type OmitUnion = T extends any ? Omit : never; + export type RemoveCorrelationParams = Pick; -export type CreateCorrelationParams = Omit; -export type UpdateCorrelationParams = Omit; +export type CreateCorrelationParams = OmitUnion; +export type UpdateCorrelationParams = OmitUnion; diff --git a/public/app/features/correlations/useCorrelations.ts b/public/app/features/correlations/useCorrelations.ts index 7b1a9c83c8a..e187b7c7d97 100644 --- a/public/app/features/correlations/useCorrelations.ts +++ b/public/app/features/correlations/useCorrelations.ts @@ -7,6 +7,8 @@ import { useGrafana } from 'app/core/context/GrafanaContext'; import { Correlation, + CorrelationExternal, + CorrelationQuery, CreateCorrelationParams, CreateCorrelationResponse, GetCorrelationsParams, @@ -24,10 +26,14 @@ export interface CorrelationsResponse { totalCount: number; } -export interface CorrelationData extends Omit { - source: DataSourceInstanceSettings; - target: DataSourceInstanceSettings; -} +export type CorrelationData = + | (Omit & { + source: DataSourceInstanceSettings; + }) + | (Omit & { + source: DataSourceInstanceSettings; + target: DataSourceInstanceSettings; + }); export interface CorrelationsData { correlations: CorrelationData[]; @@ -36,13 +42,10 @@ export interface CorrelationsData { totalCount: number; } -const toEnrichedCorrelationData = ({ - sourceUID, - targetUID, - ...correlation -}: Correlation): CorrelationData | undefined => { +const toEnrichedCorrelationData = ({ sourceUID, ...correlation }: Correlation): CorrelationData | undefined => { const sourceDatasource = getDataSourceSrv().getInstanceSettings(sourceUID); - const targetDatasource = getDataSourceSrv().getInstanceSettings(targetUID); + const targetDatasource = + correlation.type === 'query' ? getDataSourceSrv().getInstanceSettings(correlation.targetUID) : undefined; // According to #72258 we will remove logic to handle orgId=0/null as global correlations. // This logging is to check if there are any customers who did not migrate existing correlations. @@ -54,21 +57,33 @@ const toEnrichedCorrelationData = ({ if ( sourceDatasource && sourceDatasource?.uid !== undefined && - targetDatasource && - targetDatasource.uid !== undefined + targetDatasource?.uid !== undefined && + correlation.type === 'query' ) { return { ...correlation, source: sourceDatasource, target: targetDatasource, }; - } else { - correlationsLogger.logWarning(`Invalid correlation config: Missing source or target.`, { - source: JSON.stringify(sourceDatasource), - target: JSON.stringify(targetDatasource), - }); - return undefined; } + + if ( + sourceDatasource && + sourceDatasource?.uid !== undefined && + targetDatasource?.uid === undefined && + correlation.type === 'external' + ) { + return { + ...correlation, + source: sourceDatasource, + }; + } + + correlationsLogger.logWarning(`Invalid correlation config: Missing source or target.`, { + source: JSON.stringify(sourceDatasource), + target: JSON.stringify(targetDatasource), + }); + return undefined; }; const validSourceFilter = (correlation: CorrelationData | undefined): correlation is CorrelationData => !!correlation; diff --git a/public/app/features/correlations/utils.ts b/public/app/features/correlations/utils.ts index 64d4cf386b9..d9719c77e64 100644 --- a/public/app/features/correlations/utils.ts +++ b/public/app/features/correlations/utils.ts @@ -53,19 +53,31 @@ const decorateDataFrameWithInternalDataLinks = (dataFrame: DataFrame, correlatio dataFrame.fields.forEach((field) => { field.config.links = field.config.links?.filter((link) => link.origin !== DataLinkConfigOrigin.Correlations) || []; correlations.map((correlation) => { - if (correlation.config?.field === field.name) { - const targetQuery = correlation.config?.target || {}; - field.config.links!.push({ - internal: { - query: { ...targetQuery, datasource: { uid: correlation.target.uid } }, - datasourceUid: correlation.target.uid, - datasourceName: correlation.target.name, - transformations: correlation.config?.transformations, - }, - url: '', - title: correlation.label || correlation.target.name, - origin: DataLinkConfigOrigin.Correlations, - }); + if (correlation.config.field === field.name) { + if (correlation.type === 'query') { + const targetQuery = correlation.config.target || {}; + field.config.links!.push({ + internal: { + query: { ...targetQuery, datasource: { uid: correlation.target.uid } }, + datasourceUid: correlation.target.uid, + datasourceName: correlation.target.name, + }, + url: '', + title: correlation.label || correlation.target.name, + origin: DataLinkConfigOrigin.Correlations, + meta: { + transformations: correlation.config.transformations, + }, + }); + } else if (correlation.type === 'external') { + const externalTarget = correlation.config.target; + field.config.links!.push({ + url: externalTarget.url, + title: correlation.label || 'External URL', + origin: DataLinkConfigOrigin.Correlations, + meta: { transformations: correlation.config?.transformations }, + }); + } } }); }); diff --git a/public/app/features/dashboard-scene/inspect/HelpWizard/utils.ts b/public/app/features/dashboard-scene/inspect/HelpWizard/utils.ts index d3cab702aba..0808233eaad 100644 --- a/public/app/features/dashboard-scene/inspect/HelpWizard/utils.ts +++ b/public/app/features/dashboard-scene/inspect/HelpWizard/utils.ts @@ -15,7 +15,6 @@ import { VizPanel } from '@grafana/scenes'; import { GrafanaQueryType } from 'app/plugins/datasource/grafana/types'; import { DashboardGridItem } from '../../scene/DashboardGridItem'; -import { DashboardScene } from '../../scene/DashboardScene'; import { gridItemToPanel, vizPanelToPanel } from '../../serialization/transformSceneToSaveModel'; import { getQueryRunnerFor, isLibraryPanel } from '../../utils/utils'; @@ -64,25 +63,12 @@ export function getGithubMarkdown(panel: VizPanel, snapshot: string): string { export async function getDebugDashboard(panel: VizPanel, rand: Randomize, timeRange: TimeRange) { let saveModel: ReturnType = { type: '' }; const gridItem = panel.parent as DashboardGridItem; - const scene = panel.getRoot() as DashboardScene; if (isLibraryPanel(panel)) { saveModel = { ...gridItemToPanel(gridItem), ...vizPanelToPanel(panel), }; - } else if (scene.state.editPanel) { - // If panel edit mode is open when the user chooses the "get help" panel menu option - // we want the debug dashboard to include the panel with any changes that were made while - // in panel edit mode. - const sourcePanel = scene.state.editPanel.state.vizManager.state.sourcePanel.resolve(); - const dashGridItem = sourcePanel.parent; - if (dashGridItem instanceof DashboardGridItem) { - saveModel = { - ...gridItemToPanel(dashGridItem), - ...vizPanelToPanel(scene.state.editPanel.state.vizManager.state.panel.clone()), - }; - } } else { saveModel = gridItemToPanel(gridItem); } diff --git a/public/app/features/dashboard-scene/inspect/InspectJsonTab.tsx b/public/app/features/dashboard-scene/inspect/InspectJsonTab.tsx index c1424bfeeb3..e0de482f519 100644 --- a/public/app/features/dashboard-scene/inspect/InspectJsonTab.tsx +++ b/public/app/features/dashboard-scene/inspect/InspectJsonTab.tsx @@ -25,7 +25,6 @@ import { InspectTab } from 'app/features/inspector/types'; import { getPrettyJSON } from 'app/features/inspector/utils/utils'; import { reportPanelInspectInteraction } from 'app/features/search/page/reporting'; -import { VizPanelManager } from '../panel-edit/VizPanelManager'; import { DashboardGridItem } from '../scene/DashboardGridItem'; import { buildGridItemForPanel } from '../serialization/transformSaveModelToScene'; import { gridItemToPanel, vizPanelToPanel } from '../serialization/transformSceneToSaveModel'; @@ -219,11 +218,6 @@ function getJsonText(show: ShowContent, panel: VizPanel): string { break; } - if (panel.parent instanceof VizPanelManager) { - objToStringify = panel.parent.getPanelSaveModel(); - break; - } - if (gridItem instanceof DashboardGridItem) { objToStringify = gridItemToPanel(gridItem); } diff --git a/public/app/features/dashboard-scene/inspect/PanelInspectDrawer.tsx b/public/app/features/dashboard-scene/inspect/PanelInspectDrawer.tsx index 919c8e32cc3..59b38232d74 100644 --- a/public/app/features/dashboard-scene/inspect/PanelInspectDrawer.tsx +++ b/public/app/features/dashboard-scene/inspect/PanelInspectDrawer.tsx @@ -28,7 +28,7 @@ import { SceneInspectTab } from './types'; interface PanelInspectDrawerState extends SceneObjectState { tabs?: SceneInspectTab[]; - panelRef?: SceneObjectRef; + panelRef: SceneObjectRef; pluginNotLoaded?: boolean; canEdit?: boolean; } @@ -51,7 +51,7 @@ export class PanelInspectDrawer extends SceneObjectBase */ async buildTabs(retry: number) { const panelRef = this.state.panelRef; - const plugin = panelRef?.resolve()?.getPlugin(); + const plugin = panelRef.resolve()?.getPlugin(); const tabs: SceneInspectTab[] = []; if (!plugin) { diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx b/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx index 11146de7f4d..2dd6ebcba26 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx +++ b/public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx @@ -6,7 +6,13 @@ import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; import { PanelProps } from '@grafana/data'; import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; -import { config, getPluginLinkExtensions, locationService, setPluginImportUtils } from '@grafana/runtime'; +import { + LocationServiceProvider, + config, + getPluginLinkExtensions, + locationService, + setPluginImportUtils, +} from '@grafana/runtime'; import { VizPanel } from '@grafana/scenes'; import { Dashboard } from '@grafana/schema'; import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps'; @@ -60,14 +66,18 @@ function setup({ routeProps }: { routeProps?: Partial - + + + ); const rerender = (newProps: Props) => { renderResult.rerender( - + + + ); }; diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx index 9d004d346f1..f74e53c0d89 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx +++ b/public/app/features/dashboard-scene/pages/DashboardScenePage.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo } from 'react'; import { PageLayoutType } from '@grafana/data'; import { UrlSyncContextProvider } from '@grafana/scenes'; +import { Alert, Box } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; import PageLoader from 'app/core/components/PageLoader/PageLoader'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; @@ -68,9 +69,15 @@ export function DashboardScenePage({ match, route, queryParams, history }: Props if (!dashboard) { return ( - - {isLoading && } - {loadError &&

    {loadError}

    } + + + {isLoading && } + {loadError && ( + + {loadError} + + )} + ); } diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts index b67870e1b90..3c0c7b5a2ed 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.test.ts @@ -35,7 +35,7 @@ describe('DashboardScenePageStateManager', () => { const loader = new DashboardScenePageStateManager({}); await loader.loadDashboard({ uid: 'fake-dash', route: DashboardRoutes.Normal }); - expect(loader.state.dashboard).toBeDefined(); + expect(loader.state.dashboard).toBeUndefined(); expect(loader.state.isLoading).toBe(false); expect(loader.state.loadError).toBe('Dashboard not found'); }); @@ -107,8 +107,7 @@ describe('DashboardScenePageStateManager', () => { const loader = new DashboardScenePageStateManager({}); await loader.loadDashboard({ uid: '', route: DashboardRoutes.Home }); - expect(loader.state.dashboard).toBeDefined(); - expect(loader.state.dashboard?.state.title).toEqual('Failed to load home dashboard'); + expect(loader.state.dashboard).toBeUndefined(); expect(loader.state.loadError).toEqual('Failed to load home dashboard'); }); }); diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts index 40cd0b07fc1..089cfa0f417 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts @@ -1,13 +1,11 @@ import { locationUtil } from '@grafana/data'; import { config, getBackendSrv, isFetchError, locationService } from '@grafana/runtime'; -import { defaultDashboard } from '@grafana/schema'; import { StateManagerBase } from 'app/core/services/StateManagerBase'; import { default as localStorageStore } from 'app/core/store'; import { getMessageFromError } from 'app/core/utils/errors'; import { startMeasure, stopMeasure } from 'app/core/utils/metrics'; import { dashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; -import { DashboardModel } from 'app/features/dashboard/state'; import { emitDashboardViewEvent } from 'app/features/dashboard/state/analyticsProcessor'; import { DASHBOARD_FROM_LS_KEY, @@ -20,10 +18,7 @@ import { DashboardDTO, DashboardRoutes } from 'app/types'; import { PanelEditor } from '../panel-edit/PanelEditor'; import { DashboardScene } from '../scene/DashboardScene'; import { buildNewDashboardSaveModel } from '../serialization/buildNewDashboardSaveModel'; -import { - createDashboardSceneFromDashboardModel, - transformSaveModelToScene, -} from '../serialization/transformSaveModelToScene'; +import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; import { restoreDashboardStateFromLocalStorage } from '../utils/dashboardSessionState'; import { updateNavModel } from './utils'; @@ -203,11 +198,7 @@ export class DashboardScenePageStateManager extends StateManagerBase

    ${msg}

    `, - mode: 'html', - }, - title: '', - transparent: true, - type: 'text', - }, - ], - }, - meta: { canSave: false, canEdit: false }, - }; - return createDashboardSceneFromDashboardModel(new DashboardModel(dto.dashboard, dto.meta), dto.dashboard); -} diff --git a/public/app/features/dashboard-scene/pages/PublicDashboardScenePage.test.tsx b/public/app/features/dashboard-scene/pages/PublicDashboardScenePage.test.tsx index 2824025d1e6..bd7e7b4fc64 100644 --- a/public/app/features/dashboard-scene/pages/PublicDashboardScenePage.test.tsx +++ b/public/app/features/dashboard-scene/pages/PublicDashboardScenePage.test.tsx @@ -6,7 +6,14 @@ import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; import { getDefaultTimeRange, LoadingState, PanelData, PanelProps } from '@grafana/data'; import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; -import { config, getPluginLinkExtensions, setPluginImportUtils, setRunRequest } from '@grafana/runtime'; +import { + config, + getPluginLinkExtensions, + locationService, + LocationServiceProvider, + setPluginImportUtils, + setRunRequest, +} from '@grafana/runtime'; import { Dashboard } from '@grafana/schema'; import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps'; import { DashboardRoutes } from 'app/types/dashboard'; @@ -47,7 +54,9 @@ function setup(props?: Partial) { return render( - + + + ); } diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/DataProviderSharer.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/DataProviderSharer.tsx new file mode 100644 index 00000000000..20bf2c7d267 --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/DataProviderSharer.tsx @@ -0,0 +1,48 @@ +import { Observable } from 'rxjs'; + +import { + SceneDataProvider, + SceneDataProviderResult, + SceneDataState, + SceneObjectBase, + SceneObjectRef, +} from '@grafana/scenes'; + +export interface DataProviderSharerState extends SceneDataState { + source: SceneObjectRef; +} + +export class DataProviderSharer extends SceneObjectBase implements SceneDataProvider { + public constructor(state: DataProviderSharerState) { + super({ + source: state.source, + data: state.source.resolve().state.data, + }); + + this.addActivationHandler(() => { + this._subs.add( + this.state.source.resolve().subscribeToState((newState, oldState) => { + if (newState.data !== oldState.data) { + this.setState({ data: newState.data }); + } + }) + ); + }); + } + + public setContainerWidth(width: number) { + this.state.source.resolve().setContainerWidth?.(width); + } + + public isDataReadyToDisplay() { + return this.state.source.resolve().isDataReadyToDisplay?.() ?? true; + } + + public cancelQuery() { + this.state.source.resolve().cancelQuery?.(); + } + + public getResultsStream(): Observable { + return this.state.source.resolve().getResultsStream(); + } +} diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx index c315a73bcc9..c1dab28f8ed 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx @@ -34,7 +34,6 @@ import { AlertQuery, PromRulesResponse } from 'app/types/unified-alerting-dto'; import { createDashboardSceneFromDashboardModel } from '../../serialization/transformSaveModelToScene'; import * as utils from '../../utils/utils'; import { findVizPanelByKey, getVizPanelKeyForPanelId } from '../../utils/utils'; -import { VizPanelManager } from '../VizPanelManager'; import { PanelDataAlertingTab, PanelDataAlertingTabRendered } from './PanelDataAlertingTab'; @@ -361,7 +360,7 @@ async function clickNewButton() { function createModel(dashboard: DashboardModel) { const scene = createDashboardSceneFromDashboardModel(dashboard, {} as DashboardDataDTO); const vizPanel = findVizPanelByKey(scene, getVizPanelKeyForPanelId(34))!; - const model = new PanelDataAlertingTab(VizPanelManager.createFor(vizPanel)); + const model = new PanelDataAlertingTab({ panelRef: vizPanel.getRef() }); jest.spyOn(utils, 'getDashboardSceneFor').mockReturnValue(scene); return model; } diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.tsx index 3ebc9bb51a7..a4fbbc4471d 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.tsx @@ -1,8 +1,7 @@ import { css } from '@emotion/css'; -import * as React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { SceneComponentProps, SceneObjectBase } from '@grafana/scenes'; +import { SceneComponentProps, SceneObjectBase, SceneObjectRef, SceneObjectState, VizPanel } from '@grafana/scenes'; import { Alert, LoadingPlaceholder, Tab, useStyles2 } from '@grafana/ui'; import { contextSrv } from 'app/core/core'; import { RulesTable } from 'app/features/alerting/unified/components/rules/RulesTable'; @@ -11,58 +10,46 @@ import { getRulesPermissions } from 'app/features/alerting/unified/utils/access- import { stringifyErrorLike } from 'app/features/alerting/unified/utils/misc'; import { getDashboardSceneFor, getPanelIdForVizPanel } from '../../utils/utils'; -import { VizPanelManager } from '../VizPanelManager'; import { ScenesNewRuleFromPanelButton } from './NewAlertRuleButton'; -import { PanelDataPaneTab, PanelDataPaneTabState, PanelDataTabHeaderProps, TabId } from './types'; +import { PanelDataPaneTab, PanelDataTabHeaderProps, TabId } from './types'; -export class PanelDataAlertingTab extends SceneObjectBase implements PanelDataPaneTab { +export interface PanelDataAlertingTabState extends SceneObjectState { + panelRef: SceneObjectRef; +} + +export class PanelDataAlertingTab extends SceneObjectBase implements PanelDataPaneTab { static Component = PanelDataAlertingTabRendered; - TabComponent: (props: PanelDataTabHeaderProps) => React.JSX.Element; + public tabId = TabId.Alert; - tabId = TabId.Alert; - private _panelManager: VizPanelManager; - - constructor(panelManager: VizPanelManager) { - super({}); - this.TabComponent = (props: PanelDataTabHeaderProps) => AlertingTab({ ...props, model: this }); - this._panelManager = panelManager; + public renderTab(props: PanelDataTabHeaderProps) { + return ; } - getTabLabel() { + public getTabLabel() { return 'Alert'; } - getDashboardUID() { + public getDashboardUID() { const dashboard = this.getDashboard(); return dashboard.state.uid!; } - getDashboard() { - return getDashboardSceneFor(this._panelManager); + public getDashboard() { + return getDashboardSceneFor(this); } - getLegacyPanelId() { - return getPanelIdForVizPanel(this._panelManager.state.panel); + public getLegacyPanelId() { + return getPanelIdForVizPanel(this.state.panelRef.resolve()); } - getCanCreateRules() { + public getCanCreateRules() { const rulesPermissions = getRulesPermissions('grafana'); return this.getDashboard().state.meta.canSave && contextSrv.hasPermission(rulesPermissions.create); } - - get panelManager() { - return this._panelManager; - } - - get panel() { - return this._panelManager.state.panel; - } } -export function PanelDataAlertingTabRendered(props: SceneComponentProps) { - const { model } = props; - +export function PanelDataAlertingTabRendered({ model }: SceneComponentProps) { const styles = useStyles2(getStyles); const { errors, loading, rules } = usePanelCombinedRules({ @@ -87,7 +74,7 @@ export function PanelDataAlertingTabRendered(props: SceneComponentProps; } export class PanelDataPane extends SceneObjectBase { static Component = PanelDataPaneRendered; protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['tab'] }); - private panelSubscription: Unsubscribable | undefined; - public panelManager: VizPanelManager; - getUrlState() { - return { - tab: this.state.tab, - }; + public static createFor(panel: VizPanel) { + const panelRef = panel.getRef(); + const tabs: PanelDataPaneTab[] = [ + new PanelDataQueriesTab({ panelRef }), + new PanelDataTransformationsTab({ panelRef }), + ]; + + if (shouldShowAlertingTab(panel.state.pluginId)) { + tabs.push(new PanelDataAlertingTab({ panelRef })); + } + + return new PanelDataPane({ + panelRef, + tabs, + tab: TabId.Queries, + }); } - updateFromUrl(values: SceneObjectUrlValues) { + public onChangeTab = (tab: PanelDataPaneTab) => { + this.setState({ tab: tab.tabId }); + }; + + public getUrlState() { + return { tab: this.state.tab }; + } + + public updateFromUrl(values: SceneObjectUrlValues) { if (!values.tab) { return; } @@ -48,68 +66,6 @@ export class PanelDataPane extends SceneObjectBase { this.setState({ tab: values.tab as TabId }); } } - - constructor(panelMgr: VizPanelManager) { - super({ - tab: TabId.Queries, - tabs: [], - }); - - this.panelManager = panelMgr; - this.addActivationHandler(() => this.onActivate()); - } - - private onActivate() { - this.buildTabs(); - - this._subs.add( - // Setup subscription for the case when panel type changed - this.panelManager.subscribeToState((n, p) => { - if (n.pluginId !== p.pluginId) { - this.buildTabs(); - } - }) - ); - - return () => { - if (this.panelSubscription) { - this.panelSubscription.unsubscribe(); - this.panelSubscription = undefined; - } - }; - } - - private buildTabs() { - const panelManager = this.panelManager; - const panel = panelManager.state.panel; - const pluginId = panelManager.state.pluginId; - - const runner = this.panelManager.queryRunner; - const tabs: PanelDataPaneTab[] = []; - - if (panel) { - if (config.panels[pluginId]?.skipDataQuery) { - this.setState({ tabs }); - return; - } else { - if (runner) { - tabs.push(new PanelDataQueriesTab(this.panelManager)); - } - - tabs.push(new PanelDataTransformationsTab(this.panelManager)); - - if (shouldShowAlertingTab(panelManager.state.pluginId)) { - tabs.push(new PanelDataAlertingTab(this.panelManager)); - } - } - } - - this.setState({ tabs }); - } - - onChangeTab = (tab: PanelDataPaneTab) => { - this.setState({ tab: tab.tabId }); - }; } function PanelDataPaneRendered({ model }: SceneComponentProps) { @@ -125,15 +81,7 @@ function PanelDataPaneRendered({ model }: SceneComponentProps) { return (
    - {tabs.map((t, index) => { - return ( - model.onChangeTab(t)} - > - ); - })} + {tabs.map((t) => t.renderTab({ active: t.tabId === tab, onChangeTab: () => model.onChangeTab(t) }))} diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.test.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.test.tsx index 9264e85d079..5a406406282 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.test.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.test.tsx @@ -6,6 +6,7 @@ import { DataQuery, DataQueryRequest, DataSourceApi, + DataSourceInstanceSettings, DataSourceJsonData, DataSourceRef, FieldType, @@ -14,29 +15,28 @@ import { TimeRange, toDataFrame, } from '@grafana/data'; +import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; import { selectors } from '@grafana/e2e-selectors'; -import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; +import { config, locationService, setPluginExtensionsHook } from '@grafana/runtime'; +import { InspectTab } from 'app/features/inspector/types'; import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard'; import { DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plugins/datasource/dashboard/types'; import { DashboardDataDTO } from 'app/types'; +import { PanelTimeRange, PanelTimeRangeState } from '../../scene/PanelTimeRange'; import { transformSaveModelToScene } from '../../serialization/transformSaveModelToScene'; -import { DashboardModelCompatibilityWrapper } from '../../utils/DashboardModelCompatibilityWrapper'; import { findVizPanelByKey } from '../../utils/utils'; -import { VizPanelManager } from '../VizPanelManager'; -import { testDashboard } from '../testfiles/testDashboard'; +import { buildPanelEditScene } from '../PanelEditor'; +import { testDashboard, panelWithTransformations, panelWithQueriesOnly } from '../testfiles/testDashboard'; import { PanelDataQueriesTab, PanelDataQueriesTabRendered } from './PanelDataQueriesTab'; async function createModelMock() { - const panelManager = setupVizPanelManger('panel-1'); - panelManager.activate(); - await Promise.resolve(); - const queryTabModel = new PanelDataQueriesTab(panelManager); + const { queriesTab } = await setupScene('panel-1'); // mock queryRunner data state - jest.spyOn(queryTabModel.queryRunner, 'state', 'get').mockReturnValue({ - ...queryTabModel.queryRunner.state, + jest.spyOn(queriesTab.queryRunner, 'state', 'get').mockReturnValue({ + ...queriesTab.queryRunner.state, data: { state: LoadingState.Done, series: [ @@ -52,8 +52,14 @@ async function createModelMock() { }, }); - return queryTabModel; + return queriesTab; } + +setPluginExtensionsHook(() => ({ + extensions: [], + isLoading: false, +})); + const runRequestMock = jest.fn().mockImplementation((ds: DataSourceApi, request: DataQueryRequest) => { const result: PanelData = { state: LoadingState.Loading, @@ -186,11 +192,17 @@ const MixedDsSettingsMock = { }, }; +const panelPlugin = getPanelPlugin({ id: 'timeseries', skipDataQuery: false }); + jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), getRunRequest: () => (ds: DataSourceApi, request: DataQueryRequest) => { return runRequestMock(ds, request); }, + getPluginImportUtils: () => ({ + getPanelPluginFromCache: jest.fn(() => panelPlugin), + }), + getPluginLinkExtensions: jest.fn(), getDataSourceSrv: () => ({ get: async (ref: DataSourceRef) => { // Mocking the build in Grafana data source to avoid annotations data layer errors. @@ -234,112 +246,454 @@ jest.mock('@grafana/runtime', () => ({ return instance1SettingsMock; }, }), - locationService: { - partial: jest.fn(), - getSearchObject: jest.fn().mockReturnValue({ - firstPanel: false, - }), - }, config: { ...jest.requireActual('@grafana/runtime').config, defaultDatasource: 'gdev-testdata', }, })); -describe('PanelDataQueriesModel', () => { - it('can add a new query', async () => { - const vizPanelManager = setupVizPanelManger('panel-1'); - vizPanelManager.activate(); - await Promise.resolve(); - const model = new PanelDataQueriesTab(vizPanelManager); - model.addQueryClick(); - expect(model.queryRunner.state.queries).toHaveLength(2); - expect(model.queryRunner.state.queries[1].refId).toBe('B'); - expect(model.queryRunner.state.queries[1].hide).toBe(false); - expect(model.queryRunner.state.queries[1].datasource).toEqual({ - type: 'grafana-testdata-datasource', - uid: 'gdev-testdata', +jest.mock('app/core/store', () => ({ + exists: jest.fn(), + get: jest.fn(), + getObject: jest.fn((_a, b) => b), + setObject: jest.fn(), +})); + +const store = jest.requireMock('app/core/store'); +let deactivators = [] as Array<() => void>; + +describe('PanelDataQueriesTab', () => { + beforeEach(() => { + store.setObject.mockClear(); + }); + + afterEach(() => { + deactivators.forEach((deactivate) => deactivate()); + deactivators = []; + }); + + describe('Adding queries', () => { + it('can add a new query', async () => { + const { queriesTab } = await setupScene('panel-1'); + + queriesTab.addQueryClick(); + + expect(queriesTab.queryRunner.state.queries).toHaveLength(2); + expect(queriesTab.queryRunner.state.queries[1].refId).toBe('B'); + expect(queriesTab.queryRunner.state.queries[1].hide).toBe(false); + expect(queriesTab.queryRunner.state.queries[1].datasource).toEqual({ + type: 'grafana-testdata-datasource', + uid: 'gdev-testdata', + }); + }); + + it('Can add a new query when datasource is mixed', async () => { + const { queriesTab } = await setupScene('panel-7'); + + expect(queriesTab.state.datasource?.uid).toBe('-- Mixed --'); + expect(queriesTab.queryRunner.state.datasource?.uid).toBe('-- Mixed --'); + + queriesTab.addQueryClick(); + + expect(queriesTab.queryRunner.state.queries).toHaveLength(2); + expect(queriesTab.queryRunner.state.queries[1].refId).toBe('B'); + expect(queriesTab.queryRunner.state.queries[1].hide).toBe(false); + expect(queriesTab.queryRunner.state.queries[1].datasource?.uid).toBe('gdev-testdata'); }); }); - it('can add a new query when datasource is mixed', async () => { - const vizPanelManager = setupVizPanelManger('panel-7'); - vizPanelManager.activate(); - await Promise.resolve(); + describe('PanelDataQueriesTab', () => { + it('renders query group top section', async () => { + const modelMock = await createModelMock(); - const model = new PanelDataQueriesTab(vizPanelManager); - expect(vizPanelManager.state.datasource?.uid).toBe('-- Mixed --'); - expect(model.queryRunner.state.datasource?.uid).toBe('-- Mixed --'); - model.addQueryClick(); + render(); + await screen.findByTestId(selectors.components.QueryTab.queryGroupTopSection); + }); - expect(model.queryRunner.state.queries).toHaveLength(2); - expect(model.queryRunner.state.queries[1].refId).toBe('B'); - expect(model.queryRunner.state.queries[1].hide).toBe(false); - expect(model.queryRunner.state.queries[1].datasource?.uid).toBe('gdev-testdata'); + it('renders queries rows when queries are set', async () => { + const modelMock = await createModelMock(); + render(); + + await screen.findByTestId('query-editor-rows'); + expect(screen.getAllByTestId('query-editor-row')).toHaveLength(1); + }); + + it('allow to add a new query when user clicks on add new', async () => { + const modelMock = await createModelMock(); + jest.spyOn(modelMock, 'addQueryClick'); + jest.spyOn(modelMock, 'onQueriesChange'); + render(); + + await screen.findByTestId(selectors.components.QueryTab.addQuery); + await userEvent.click(screen.getByTestId(selectors.components.QueryTab.addQuery)); + + const expectedQueries = [ + { + datasource: { type: 'grafana-testdata-datasource', uid: 'gdev-testdata' }, + refId: 'A', + scenarioId: 'random_walk', + seriesCount: 1, + }, + { datasource: { type: 'grafana-testdata-datasource', uid: 'gdev-testdata' }, hide: false, refId: 'B' }, + ]; + + expect(modelMock.addQueryClick).toHaveBeenCalled(); + expect(modelMock.onQueriesChange).toHaveBeenCalledWith(expectedQueries); + }); + + it('allow to remove a query when user clicks on remove', async () => { + const modelMock = await createModelMock(); + jest.spyOn(modelMock, 'addQueryClick'); + jest.spyOn(modelMock, 'onQueriesChange'); + render(); + + await screen.findByTestId('data-testid Remove query'); + await userEvent.click(screen.getByTestId('data-testid Remove query')); + + expect(modelMock.onQueriesChange).toHaveBeenCalledWith([]); + }); + }); + + describe('query options', () => { + describe('activation', () => { + it('should load data source', async () => { + const { queriesTab } = await setupScene('panel-1'); + + expect(queriesTab.state.datasource).toEqual(ds1Mock); + expect(queriesTab.state.dsSettings).toEqual(instance1SettingsMock); + }); + + it('should store loaded data source in local storage', async () => { + await setupScene('panel-1'); + + expect(store.setObject).toHaveBeenCalledWith('grafana.dashboards.panelEdit.lastUsedDatasource', { + dashboardUid: 'ffbe00e2-803c-4d49-adb7-41aad336234f', + datasourceUid: 'gdev-testdata', + }); + }); + + it('should load default datasource if the datasource passed is not found', async () => { + const { queriesTab } = await setupScene('panel-6'); + + expect(queriesTab.queryRunner.state.datasource).toEqual({ + uid: 'abc', + type: 'datasource', + }); + + expect(config.defaultDatasource).toBe('gdev-testdata'); + expect(queriesTab.state.datasource).toEqual(defaultDsMock); + expect(queriesTab.state.dsSettings).toEqual(instance1SettingsMock); + }); + }); + + describe('data source change', () => { + it('should load new data source', async () => { + const { queriesTab, panel } = await setupScene('panel-1'); + panel.state.$data?.activate(); + + await queriesTab.onChangeDataSource( + { type: 'grafana-prometheus-datasource', uid: 'gdev-prometheus' } as DataSourceInstanceSettings, + [] + ); + + expect(store.setObject).toHaveBeenCalledTimes(2); + expect(store.setObject).toHaveBeenLastCalledWith('grafana.dashboards.panelEdit.lastUsedDatasource', { + dashboardUid: 'ffbe00e2-803c-4d49-adb7-41aad336234f', + datasourceUid: 'gdev-prometheus', + }); + + expect(queriesTab.state.datasource).toEqual(ds2Mock); + expect(queriesTab.state.dsSettings).toEqual(instance2SettingsMock); + }); + }); + + describe('query options change', () => { + describe('time overrides', () => { + it('should create PanelTimeRange object', async () => { + const { queriesTab, panel } = await setupScene('panel-1'); + + panel.state.$data?.activate(); + + expect(panel.state.$timeRange).toBeUndefined(); + + queriesTab.onQueryOptionsChange({ + dataSource: { name: 'grafana-testdata', type: 'grafana-testdata-datasource', default: true }, + queries: [], + timeRange: { from: '1h' }, + }); + + expect(panel.state.$timeRange).toBeInstanceOf(PanelTimeRange); + }); + + it('should update PanelTimeRange object on time options update', async () => { + const { queriesTab, panel } = await setupScene('panel-1'); + + expect(panel.state.$timeRange).toBeUndefined(); + + queriesTab.onQueryOptionsChange({ + dataSource: { name: 'grafana-testdata', type: 'grafana-testdata-datasource', default: true }, + queries: [], + timeRange: { from: '1h' }, + }); + + expect(panel.state.$timeRange).toBeInstanceOf(PanelTimeRange); + expect((panel.state.$timeRange?.state as PanelTimeRangeState).timeFrom).toBe('1h'); + + queriesTab.onQueryOptionsChange({ + dataSource: { name: 'grafana-testdata', type: 'grafana-testdata-datasource', default: true }, + queries: [], + timeRange: { from: '2h' }, + }); + + expect((panel.state.$timeRange?.state as PanelTimeRangeState).timeFrom).toBe('2h'); + }); + + it('should remove PanelTimeRange object on time options cleared', async () => { + const { queriesTab, panel } = await setupScene('panel-1'); + + expect(panel.state.$timeRange).toBeUndefined(); + + queriesTab.onQueryOptionsChange({ + dataSource: { name: 'grafana-testdata', type: 'grafana-testdata-datasource', default: true }, + queries: [], + timeRange: { from: '1h' }, + }); + + queriesTab.onQueryOptionsChange({ + dataSource: { + name: 'grafana-testdata', + type: 'grafana-testdata-datasource', + default: true, + }, + queries: [], + timeRange: { from: null }, + }); + + expect(panel.state.$timeRange).toBeUndefined(); + }); + }); + + describe('max data points and interval', () => { + it('should update max data points', async () => { + const { queriesTab } = await setupScene('panel-1'); + const dataObj = queriesTab.queryRunner; + + expect(dataObj.state.maxDataPoints).toBeUndefined(); + + queriesTab.onQueryOptionsChange({ + dataSource: { name: 'grafana-testdata', type: 'grafana-testdata-datasource', default: true }, + queries: [], + maxDataPoints: 100, + }); + + expect(dataObj.state.maxDataPoints).toBe(100); + }); + + it('should update min interval', async () => { + const { queriesTab } = await setupScene('panel-1'); + const dataObj = queriesTab.queryRunner; + + expect(dataObj.state.maxDataPoints).toBeUndefined(); + + queriesTab.onQueryOptionsChange({ + dataSource: { name: 'grafana-testdata', type: 'grafana-testdata-datasource', default: true }, + queries: [], + minInterval: '1s', + }); + expect(dataObj.state.minInterval).toBe('1s'); + }); + }); + + describe('query caching', () => { + it('updates cacheTimeout and queryCachingTTL', async () => { + const { queriesTab } = await setupScene('panel-1'); + const dataObj = queriesTab.queryRunner; + + queriesTab.onQueryOptionsChange({ + cacheTimeout: '60', + queryCachingTTL: 200000, + dataSource: { name: 'grafana-testdata', type: 'grafana-testdata-datasource', default: true }, + queries: [], + }); + + expect(dataObj.state.cacheTimeout).toBe('60'); + expect(dataObj.state.queryCachingTTL).toBe(200000); + }); + }); + }); + + describe('query inspection', () => { + it('allows query inspection from the tab', async () => { + const { queriesTab } = await setupScene('panel-1'); + queriesTab.onOpenInspector(); + + const params = locationService.getSearchObject(); + expect(params.inspect).toBe('1'); + expect(params.inspectTab).toBe(InspectTab.Query); + }); + }); + + describe('data source change', () => { + it('changing from one plugin to another', async () => { + const { queriesTab } = await setupScene('panel-1'); + + expect(queriesTab.queryRunner.state.datasource).toEqual({ + uid: 'gdev-testdata', + type: 'grafana-testdata-datasource', + }); + + await queriesTab.onChangeDataSource({ + name: 'grafana-prometheus', + type: 'grafana-prometheus-datasource', + uid: 'gdev-prometheus', + meta: { + name: 'Prometheus', + module: 'prometheus', + id: 'grafana-prometheus-datasource', + }, + } as DataSourceInstanceSettings); + + expect(queriesTab.queryRunner.state.datasource).toEqual({ + uid: 'gdev-prometheus', + type: 'grafana-prometheus-datasource', + }); + }); + + it('changing from a plugin to a dashboard data source', async () => { + const { queriesTab } = await setupScene('panel-1'); + + expect(queriesTab.queryRunner.state.datasource).toEqual({ + uid: 'gdev-testdata', + type: 'grafana-testdata-datasource', + }); + + await queriesTab.onChangeDataSource({ + name: SHARED_DASHBOARD_QUERY, + type: 'datasource', + uid: SHARED_DASHBOARD_QUERY, + meta: { + name: 'Prometheus', + module: 'prometheus', + id: DASHBOARD_DATASOURCE_PLUGIN_ID, + }, + } as DataSourceInstanceSettings); + + expect(queriesTab.queryRunner.state.datasource).toEqual({ + uid: SHARED_DASHBOARD_QUERY, + type: 'datasource', + }); + }); + + it('changing from dashboard data source to a plugin', async () => { + const { queriesTab } = await setupScene('panel-3'); + + expect(queriesTab.queryRunner.state.datasource).toEqual({ uid: SHARED_DASHBOARD_QUERY, type: 'datasource' }); + + await queriesTab.onChangeDataSource({ + name: 'grafana-prometheus', + type: 'grafana-prometheus-datasource', + uid: 'gdev-prometheus', + meta: { + name: 'Prometheus', + module: 'prometheus', + id: 'grafana-prometheus-datasource', + }, + } as DataSourceInstanceSettings); + + expect(queriesTab.queryRunner.state.datasource).toEqual({ + uid: 'gdev-prometheus', + type: 'grafana-prometheus-datasource', + }); + }); + }); + + describe('change queries', () => { + describe('plugin queries', () => { + it('should update queries', async () => { + const { queriesTab, panel } = await setupScene('panel-1'); + + panel.state.$data?.activate(); + + queriesTab.onQueriesChange([ + { + datasource: { type: 'grafana-testdata-datasource', uid: 'gdev-testdata' }, + refId: 'A', + scenarioId: 'random_walk', + seriesCount: 5, + }, + ]); + + expect(queriesTab.queryRunner.state.queries).toEqual([ + { + datasource: { type: 'grafana-testdata-datasource', uid: 'gdev-testdata' }, + refId: 'A', + scenarioId: 'random_walk', + seriesCount: 5, + }, + ]); + }); + }); + + describe('dashboard queries', () => { + it('should update queries', async () => { + const { queriesTab, panel } = await setupScene('panel-3'); + + panel.state.$data?.activate(); + + // Changing dashboard query to a panel with transformations + queriesTab.onQueriesChange([ + { + refId: 'A', + datasource: { type: DASHBOARD_DATASOURCE_PLUGIN_ID }, + panelId: panelWithTransformations.id, + }, + ]); + + expect(queriesTab.queryRunner.state.queries[0].panelId).toEqual(panelWithTransformations.id); + + // Changing dashboard query to a panel with queries only + queriesTab.onQueriesChange([ + { + refId: 'A', + datasource: { type: DASHBOARD_DATASOURCE_PLUGIN_ID }, + panelId: panelWithQueriesOnly.id, + }, + ]); + + expect(queriesTab.queryRunner.state.queries[0].panelId).toBe(panelWithQueriesOnly.id); + }); + + it('should load last used data source if no data source specified for a panel', async () => { + store.exists.mockReturnValue(true); + store.getObject.mockReturnValue({ + dashboardUid: 'ffbe00e2-803c-4d49-adb7-41aad336234f', + datasourceUid: 'gdev-testdata', + }); + + const { queriesTab } = await setupScene('panel-5'); + + expect(queriesTab.state.datasource).toBe(ds1Mock); + expect(queriesTab.state.dsSettings).toBe(instance1SettingsMock); + }); + }); + }); }); }); -describe('PanelDataQueriesTab', () => { - it('renders query group top section', async () => { - const modelMock = await createModelMock(); +async function setupScene(panelId: string) { + const dashboard = transformSaveModelToScene({ dashboard: testDashboard as unknown as DashboardDataDTO, meta: {} }); + const panel = findVizPanelByKey(dashboard, panelId)!; - render(); - await screen.findByTestId(selectors.components.QueryTab.queryGroupTopSection); - }); + const panelEditor = buildPanelEditScene(panel); + dashboard.setState({ editPanel: panelEditor }); - it('renders queries rows when queries are set', async () => { - const modelMock = await createModelMock(); - render(); + deactivators.push(dashboard.activate()); + deactivators.push(panelEditor.activate()); - await screen.findByTestId('query-editor-rows'); - expect(screen.getAllByTestId('query-editor-row')).toHaveLength(1); - }); + const queriesTab = panelEditor.state.dataPane!.state.tabs[0] as PanelDataQueriesTab; + deactivators.push(queriesTab.activate()); - it('allow to add a new query when user clicks on add new', async () => { - const modelMock = await createModelMock(); - jest.spyOn(modelMock, 'addQueryClick'); - jest.spyOn(modelMock, 'onQueriesChange'); - render(); + await Promise.resolve(); - await screen.findByTestId(selectors.components.QueryTab.addQuery); - await userEvent.click(screen.getByTestId(selectors.components.QueryTab.addQuery)); - - const expectedQueries = [ - { - datasource: { type: 'grafana-testdata-datasource', uid: 'gdev-testdata' }, - refId: 'A', - scenarioId: 'random_walk', - seriesCount: 1, - }, - { datasource: { type: 'grafana-testdata-datasource', uid: 'gdev-testdata' }, hide: false, refId: 'B' }, - ]; - - expect(modelMock.addQueryClick).toHaveBeenCalled(); - expect(modelMock.onQueriesChange).toHaveBeenCalledWith(expectedQueries); - }); - - it('allow to remove a query when user clicks on remove', async () => { - const modelMock = await createModelMock(); - jest.spyOn(modelMock, 'addQueryClick'); - jest.spyOn(modelMock, 'onQueriesChange'); - render(); - - await screen.findByTestId('data-testid Remove query'); - await userEvent.click(screen.getByTestId('data-testid Remove query')); - - expect(modelMock.onQueriesChange).toHaveBeenCalledWith([]); - }); -}); - -const setupVizPanelManger = (panelId: string) => { - const scene = transformSaveModelToScene({ dashboard: testDashboard as unknown as DashboardDataDTO, meta: {} }); - const panel = findVizPanelByKey(scene, panelId)!; - - const vizPanelManager = VizPanelManager.createFor(panel); - - // The following happens on DahsboardScene activation. For the needs of this test this activation aint needed hence we hand-call it - // @ts-expect-error - getDashboardSrv().setCurrent(new DashboardModelCompatibilityWrapper(scene)); - - return vizPanelManager; -}; + return { panel, scene: dashboard, queriesTab }; +} diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx index 94d59e73eb4..23f663ca9f5 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx @@ -1,60 +1,134 @@ -import * as React from 'react'; - -import { CoreApp, DataSourceApi, DataSourceInstanceSettings, IconName, getDataSourceRef } from '@grafana/data'; +import { CoreApp, DataSourceApi, DataSourceInstanceSettings, getDataSourceRef } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { config, getDataSourceSrv } from '@grafana/runtime'; -import { SceneObjectBase, SceneComponentProps, sceneGraph, SceneQueryRunner } from '@grafana/scenes'; +import { config, getDataSourceSrv, locationService } from '@grafana/runtime'; +import { + SceneObjectBase, + SceneComponentProps, + sceneGraph, + SceneQueryRunner, + SceneObjectRef, + VizPanel, + SceneObjectState, + SceneDataQuery, +} from '@grafana/scenes'; import { DataQuery } from '@grafana/schema'; import { Button, Stack, Tab } from '@grafana/ui'; import { addQuery } from 'app/core/utils/query'; +import { getLastUsedDatasourceFromStorage } from 'app/features/dashboard/utils/dashboard'; +import { storeLastUsedDataSourceInLocalStorage } from 'app/features/datasources/components/picker/utils'; import { dataSource as expressionDatasource } from 'app/features/expressions/ExpressionDatasource'; import { GroupActionComponents } from 'app/features/query/components/QueryActionComponent'; import { QueryEditorRows } from 'app/features/query/components/QueryEditorRows'; import { QueryGroupTopSection } from 'app/features/query/components/QueryGroup'; +import { updateQueries } from 'app/features/query/state/updateQueries'; import { isSharedDashboardQuery } from 'app/plugins/datasource/dashboard'; -import { GrafanaQuery } from 'app/plugins/datasource/grafana/types'; import { QueryGroupOptions } from 'app/types'; -import { PanelTimeRange } from '../../scene/PanelTimeRange'; -import { VizPanelManager } from '../VizPanelManager'; +import { PanelTimeRange, PanelTimeRangeState } from '../../scene/PanelTimeRange'; +import { getDashboardSceneFor, getPanelIdForVizPanel, getQueryRunnerFor } from '../../utils/utils'; -import { PanelDataPaneTabState, PanelDataPaneTab, TabId, PanelDataTabHeaderProps } from './types'; +import { PanelDataPaneTab, TabId, PanelDataTabHeaderProps } from './types'; -interface PanelDataQueriesTabState extends PanelDataPaneTabState { +interface PanelDataQueriesTabState extends SceneObjectState { datasource?: DataSourceApi; dsSettings?: DataSourceInstanceSettings; + panelRef: SceneObjectRef; } export class PanelDataQueriesTab extends SceneObjectBase implements PanelDataPaneTab { static Component = PanelDataQueriesTabRendered; - TabComponent: (props: PanelDataTabHeaderProps) => React.JSX.Element; - tabId = TabId.Queries; - icon: IconName = 'database'; - private _panelManager: VizPanelManager; - getTabLabel() { + public constructor(state: PanelDataQueriesTabState) { + super(state); + this.addActivationHandler(() => this.onActivate()); + } + + public getTabLabel() { return 'Queries'; } - getItemsCount() { + public getItemsCount() { return this.getQueries().length; } - constructor(panelManager: VizPanelManager) { - super({}); - - this.TabComponent = (props: PanelDataTabHeaderProps) => { - return QueriesTab({ ...props, model: this }); - }; - - this._panelManager = panelManager; + public renderTab(props: PanelDataTabHeaderProps) { + return ; } - buildQueryOptions(): QueryGroupOptions { - const panelManager = this._panelManager; - const panelObj = this._panelManager.state.panel; - const queryRunner = this._panelManager.queryRunner; - const timeRangeObj = sceneGraph.getTimeRange(panelObj); + private onActivate() { + this.loadDataSource(); + } + + private async loadDataSource() { + const panel = this.state.panelRef.resolve(); + const dataObj = panel.state.$data; + + if (!dataObj) { + return; + } + + let datasourceToLoad = this.queryRunner.state.datasource; + + try { + let datasource: DataSourceApi | undefined; + let dsSettings: DataSourceInstanceSettings | undefined; + + if (!datasourceToLoad) { + const dashboardScene = getDashboardSceneFor(this); + const dashboardUid = dashboardScene.state.uid ?? ''; + const lastUsedDatasource = getLastUsedDatasourceFromStorage(dashboardUid!); + + // do we have a last used datasource for this dashboard + if (lastUsedDatasource?.datasourceUid !== null) { + // get datasource from dashbopard uid + dsSettings = getDataSourceSrv().getInstanceSettings({ uid: lastUsedDatasource?.datasourceUid }); + if (dsSettings) { + datasource = await getDataSourceSrv().get({ + uid: lastUsedDatasource?.datasourceUid, + type: dsSettings.type, + }); + + this.queryRunner.setState({ + datasource: { + ...getDataSourceRef(dsSettings), + uid: lastUsedDatasource?.datasourceUid, + }, + }); + } + } + } else { + datasource = await getDataSourceSrv().get(datasourceToLoad); + dsSettings = getDataSourceSrv().getInstanceSettings(datasourceToLoad); + } + + if (datasource && dsSettings) { + this.setState({ datasource, dsSettings }); + storeLastUsedDataSourceInLocalStorage(getDataSourceRef(dsSettings) || { default: true }); + } + } catch (err) { + //set default datasource if we fail to load the datasource + const datasource = await getDataSourceSrv().get(config.defaultDatasource); + const dsSettings = getDataSourceSrv().getInstanceSettings(config.defaultDatasource); + + if (datasource && dsSettings) { + this.setState({ + datasource, + dsSettings, + }); + + this.queryRunner.setState({ + datasource: getDataSourceRef(dsSettings), + }); + } + + console.error(err); + } + } + + public buildQueryOptions(): QueryGroupOptions { + const panel = this.state.panelRef.resolve(); + const queryRunner = getQueryRunnerFor(panel)!; + const timeRangeObj = sceneGraph.getTimeRange(panel); let timeRangeOpts: QueryGroupOptions['timeRange'] = { from: undefined, @@ -71,19 +145,14 @@ export class PanelDataQueriesTab extends SceneObjectBase { - this._panelManager.inspectPanel(); + public onOpenInspector = () => { + const panel = this.state.panelRef.resolve(); + const panelId = getPanelIdForVizPanel(panel); + + locationService.partial({ inspect: panelId, inspectTab: 'query' }); }; - onChangeDataSource = async ( - newSettings: DataSourceInstanceSettings, - defaultQueries?: DataQuery[] | GrafanaQuery[] - ) => { - this._panelManager.changePanelDataSource(newSettings, defaultQueries); + public onChangeDataSource = async (newSettings: DataSourceInstanceSettings, defaultQueries?: SceneDataQuery[]) => { + const { dsSettings } = this.state; + const queryRunner = this.queryRunner; + + const currentDS = dsSettings ? await getDataSourceSrv().get({ uid: dsSettings.uid }) : undefined; + const nextDS = await getDataSourceSrv().get({ uid: newSettings.uid }); + + const currentQueries = queryRunner.state.queries; + + // We need to pass in newSettings.uid as well here as that can be a variable expression and we want to store that in the query model not the current ds variable value + const queries = defaultQueries || (await updateQueries(nextDS, newSettings.uid, currentQueries, currentDS)); + + queryRunner.setState({ datasource: getDataSourceRef(newSettings), queries }); + + if (defaultQueries) { + queryRunner.runQueries(); + } + + this.loadDataSource(); }; - onQueryOptionsChange = (options: QueryGroupOptions) => { - this._panelManager.changeQueryOptions(options); + public onQueryOptionsChange = (options: QueryGroupOptions) => { + const panelObj = this.state.panelRef.resolve(); + const dataObj = this.queryRunner; + const timeRangeObj = panelObj.state.$timeRange; + + const dataObjStateUpdate: Partial = {}; + const timeRangeObjStateUpdate: Partial = {}; + + if (options.maxDataPoints !== dataObj.state.maxDataPoints) { + dataObjStateUpdate.maxDataPoints = options.maxDataPoints ?? undefined; + } + + if (options.minInterval !== dataObj.state.minInterval && options.minInterval !== null) { + dataObjStateUpdate.minInterval = options.minInterval; + } + + if (options.timeRange) { + timeRangeObjStateUpdate.timeFrom = options.timeRange.from ?? undefined; + timeRangeObjStateUpdate.timeShift = options.timeRange.shift ?? undefined; + timeRangeObjStateUpdate.hideTimeOverride = options.timeRange.hide; + } + + if (timeRangeObj instanceof PanelTimeRange) { + if (timeRangeObjStateUpdate.timeFrom !== undefined || timeRangeObjStateUpdate.timeShift !== undefined) { + // update time override + timeRangeObj.setState(timeRangeObjStateUpdate); + } else { + // remove time override + panelObj.setState({ $timeRange: undefined }); + } + } else { + // no time override present on the panel, let's create one first + panelObj.setState({ $timeRange: new PanelTimeRange(timeRangeObjStateUpdate) }); + } + + if (options.cacheTimeout !== dataObj?.state.cacheTimeout) { + dataObjStateUpdate.cacheTimeout = options.cacheTimeout; + } + + if (options.queryCachingTTL !== dataObj?.state.queryCachingTTL) { + dataObjStateUpdate.queryCachingTTL = options.queryCachingTTL; + } + + dataObj.setState(dataObjStateUpdate); + dataObj.runQueries(); }; - onQueriesChange = (queries: DataQuery[]) => { - this._panelManager.changeQueries(queries); + public onQueriesChange = (queries: SceneDataQuery[]) => { + const runner = this.queryRunner; + runner.setState({ queries }); }; - onRunQueries = () => { - this._panelManager.queryRunner.runQueries(); + public onRunQueries = () => { + this.queryRunner.runQueries(); }; - getQueries() { - return this._panelManager.queryRunner.state.queries; + public getQueries() { + return this.queryRunner.state.queries; } - newQuery(): Partial { - const { dsSettings, datasource } = this._panelManager.state; - + public newQuery(): Partial { + const { dsSettings, datasource } = this.state; let ds; + if (!dsSettings?.meta.mixed) { ds = dsSettings; // Use dsSettings if it is not mixed } else if (!datasource?.meta.mixed) { @@ -138,29 +268,30 @@ export class PanelDataQueriesTab extends SceneObjectBase { + public addQueryClick = () => { const queries = this.getQueries(); this.onQueriesChange(addQuery(queries, this.newQuery())); }; - onAddQuery = (query: Partial) => { + public onAddQuery = (query: Partial) => { const queries = this.getQueries(); - const dsSettings = this._panelManager.state.dsSettings; + const dsSettings = this.state.dsSettings; + this.onQueriesChange( addQuery(queries, query, dsSettings ? getDataSourceRef(dsSettings) : { type: undefined, uid: undefined }) ); }; - isExpressionsSupported(dsSettings: DataSourceInstanceSettings): boolean { + public isExpressionsSupported(dsSettings: DataSourceInstanceSettings): boolean { return (dsSettings.meta.alerting || dsSettings.meta.mixed) === true; } - onAddExpressionClick = () => { + public onAddExpressionClick = () => { const queries = this.getQueries(); this.onQueriesChange(addQuery(queries, expressionDatasource.newQuery())); }; - renderExtraActions() { + public renderExtraActions() { return GroupActionComponents.getAllExtraRenderAction() .map((action, index) => action({ @@ -172,18 +303,14 @@ export class PanelDataQueriesTab extends SceneObjectBase) { - const { datasource, dsSettings } = model.panelManager.useState(); - const { data, queries } = model.panelManager.queryRunner.useState(); + const { datasource, dsSettings } = model.useState(); + const { data, queries } = model.queryRunner.useState(); if (!datasource || !dsSettings || !data) { return null; @@ -250,7 +377,6 @@ function QueriesTab(props: QueriesTabProps) { return ( { it('can change transformations', () => { - const vizPanelManager = setupVizPanelManger('panel-1'); - const model = new PanelDataTransformationsTab(vizPanelManager); - model.onChangeTransformations([{ id: 'calculateField', options: {} }]); - expect(model.getDataTransformer().state.transformations).toEqual([{ id: 'calculateField', options: {} }]); + const { transformsTab } = setupTabScene('panel-1'); + transformsTab.onChangeTransformations([{ id: 'calculateField', options: {} }]); + expect(transformsTab.getDataTransformer().state.transformations).toEqual([{ id: 'calculateField', options: {} }]); }); }); @@ -169,15 +167,16 @@ describe('PanelDataTransformationsTab', () => { }); }); -const setupVizPanelManger = (panelId: string) => { +function setupTabScene(panelId: string) { const scene = transformSaveModelToScene({ dashboard: testDashboard as unknown as DashboardDataDTO, meta: {} }); const panel = findVizPanelByKey(scene, panelId)!; - const vizPanelManager = VizPanelManager.createFor(panel); + const transformsTab = new PanelDataTransformationsTab({ panelRef: panel.getRef() }); + transformsTab.activate(); // The following happens on DahsboardScene activation. For the needs of this test this activation aint needed hence we hand-call it // @ts-expect-error getDashboardSrv().setCurrent(new DashboardModelCompatibilityWrapper(scene)); - return vizPanelManager; -}; + return { transformsTab }; +} diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataTransformationsTab.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataTransformationsTab.tsx index a90022f66d2..5f0d7d4ad05 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataTransformationsTab.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataTransformationsTab.tsx @@ -2,56 +2,62 @@ import { css } from '@emotion/css'; import { DragDropContext, DropResult, Droppable } from '@hello-pangea/dnd'; import { useState } from 'react'; -import { DataTransformerConfig, GrafanaTheme2, IconName, PanelData } from '@grafana/data'; +import { DataTransformerConfig, GrafanaTheme2, PanelData } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { SceneObjectBase, SceneComponentProps, SceneDataTransformer, SceneQueryRunner } from '@grafana/scenes'; +import { + SceneObjectBase, + SceneComponentProps, + SceneDataTransformer, + SceneQueryRunner, + SceneObjectRef, + VizPanel, + SceneObjectState, +} from '@grafana/scenes'; import { Button, ButtonGroup, ConfirmModal, Tab, useStyles2 } from '@grafana/ui'; import { TransformationOperationRows } from 'app/features/dashboard/components/TransformationsEditor/TransformationOperationRows'; -import { VizPanelManager } from '../VizPanelManager'; +import { getQueryRunnerFor } from '../../utils/utils'; import { EmptyTransformationsMessage } from './EmptyTransformationsMessage'; import { TransformationsDrawer } from './TransformationsDrawer'; -import { PanelDataPaneTabState, PanelDataPaneTab, TabId, PanelDataTabHeaderProps } from './types'; +import { PanelDataPaneTab, TabId, PanelDataTabHeaderProps } from './types'; -interface PanelDataTransformationsTabState extends PanelDataPaneTabState {} +interface PanelDataTransformationsTabState extends SceneObjectState { + panelRef: SceneObjectRef; +} export class PanelDataTransformationsTab extends SceneObjectBase implements PanelDataPaneTab { static Component = PanelDataTransformationsTabRendered; - TabComponent: (props: PanelDataTabHeaderProps) => React.JSX.Element; - tabId = TabId.Transformations; - icon: IconName = 'process'; - private _panelManager: VizPanelManager; getTabLabel() { return 'Transformations'; } - constructor(panelManager: VizPanelManager) { - super({}); - this.TabComponent = (props: PanelDataTabHeaderProps) => TransformationsTab({ ...props, model: this }); - - this._panelManager = panelManager; + public renderTab(props: PanelDataTabHeaderProps) { + return ; } public getQueryRunner(): SceneQueryRunner { - return this._panelManager.queryRunner; + return getQueryRunnerFor(this.state.panelRef.resolve())!; } public getDataTransformer(): SceneDataTransformer { - return this._panelManager.dataTransformer; + const provider = this.state.panelRef.resolve().state.$data; + + if (!provider || !(provider instanceof SceneDataTransformer)) { + throw new Error('Could not find SceneDataTransformer for panel'); + } + return provider; } public onChangeTransformations(transformations: DataTransformerConfig[]) { - this._panelManager.changeTransformations(transformations); - } - - get panelManager() { - return this._panelManager; + const transformer = this.getDataTransformer(); + transformer.setState({ transformations }); + transformer.reprocessTransformations(); } } @@ -200,11 +206,10 @@ interface TransformationsTabProps extends PanelDataTabHeaderProps { function TransformationsTab(props: TransformationsTabProps) { const { model } = props; - const transformerState = model.getDataTransformer().useState(); + return ( ) => void; } export interface PanelDataPaneTab extends SceneObject { - TabComponent: (props: PanelDataTabHeaderProps) => React.JSX.Element; + renderTab: (props: PanelDataTabHeaderProps) => React.JSX.Element; getTabLabel(): string; tabId: TabId; } diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditControls.tsx b/public/app/features/dashboard-scene/panel-edit/PanelEditControls.tsx index cf37a7888ea..66c1263e302 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditControls.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditControls.tsx @@ -1,5 +1,4 @@ import { selectors } from '@grafana/e2e-selectors'; -import { config } from '@grafana/runtime'; import { InlineSwitch } from '@grafana/ui'; import { PanelEditor } from './PanelEditor'; @@ -9,19 +8,17 @@ export interface Props { } export function PanelEditControls({ panelEditor }: Props) { - const vizManager = panelEditor.state.vizManager; - const { panel, tableView } = vizManager.useState(); - const skipDataQuery = config.panels[panel.state.pluginId]?.skipDataQuery; + const { tableView, dataPane } = panelEditor.useState(); return ( <> - {!skipDataQuery && ( + {dataPane && ( vizManager.toggleTableView()} + onClick={panelEditor.onToggleTableView} aria-label="toggle-table-view" data-testid={selectors.components.PanelEditor.toggleTableView} /> diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts b/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts index 1c18e13fa0d..8117146a4fc 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts @@ -1,5 +1,21 @@ -import { PanelPlugin, PanelPluginMeta, PluginType } from '@grafana/data'; -import { SceneGridLayout, VizPanel } from '@grafana/scenes'; +import { of } from 'rxjs'; + +import { DataQueryRequest, DataSourceApi, LoadingState, PanelPlugin } from '@grafana/data'; +import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; +import { setDataSourceSrv } from '@grafana/runtime'; +import { + CancelActivationHandler, + CustomVariable, + SceneDataTransformer, + sceneGraph, + SceneGridLayout, + SceneQueryRunner, + SceneTimeRange, + SceneVariableSet, + VizPanel, +} from '@grafana/scenes'; +import { mockDataSource, MockDataSourceSrv } from 'app/features/alerting/unified/mocks'; +import { DataSourceType } from 'app/features/alerting/unified/utils/datasource'; import * as libAPI from 'app/features/library-panels/state/api'; import { DashboardGridItem } from '../scene/DashboardGridItem'; @@ -7,14 +23,28 @@ import { DashboardScene } from '../scene/DashboardScene'; import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior'; import { vizPanelToPanel } from '../serialization/transformSceneToSaveModel'; import { activateFullSceneTree } from '../utils/test-utils'; +import { findVizPanelByKey, getQueryRunnerFor } from '../utils/utils'; import { buildPanelEditScene } from './PanelEditor'; -let pluginToLoad: PanelPlugin | undefined; +const runRequestMock = jest.fn().mockImplementation((ds: DataSourceApi, request: DataQueryRequest) => { + return of({ + state: LoadingState.Loading, + series: [], + timeRange: request.range, + }); +}); + +let pluginPromise: Promise | undefined; + jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), + getRunRequest: () => (ds: DataSourceApi, request: DataQueryRequest) => { + return runRequestMock(ds, request); + }, getPluginImportUtils: () => ({ - getPanelPluginFromCache: jest.fn(() => pluginToLoad), + getPanelPluginFromCache: jest.fn(() => undefined), + importPanelPlugin: () => pluginPromise, }), config: { ...jest.requireActual('@grafana/runtime').config, @@ -29,103 +59,134 @@ jest.mock('@grafana/runtime', () => ({ }, })); +const dataSources = { + ds1: mockDataSource({ + uid: 'ds1', + type: DataSourceType.Prometheus, + }), +}; + +setDataSourceSrv(new MockDataSourceSrv(dataSources)); + +let deactivate: CancelActivationHandler | undefined; + describe('PanelEditor', () => { - describe('When closing editor', () => { - it('should apply changes automatically', () => { - pluginToLoad = getTestPanelPlugin({ id: 'text', skipDataQuery: true }); - - const panel = new VizPanel({ - key: 'panel-1', - pluginId: 'text', - }); - - const gridItem = new DashboardGridItem({ body: panel }); - - const editScene = buildPanelEditScene(panel); - const scene = new DashboardScene({ - editPanel: editScene, - isEditing: true, - body: new SceneGridLayout({ - children: [gridItem], - }), - }); - - const deactivate = activateFullSceneTree(scene); - - editScene.state.vizManager.state.panel.setState({ title: 'changed title' }); - + afterEach(() => { + if (deactivate) { deactivate(); + deactivate = undefined; + } + }); - const updatedPanel = gridItem.state.body as VizPanel; - expect(updatedPanel?.state.title).toBe('changed title'); + describe('When initializing', () => { + it('should wait for panel plugin to load', async () => { + const { panelEditor, panel, pluginResolve, dashboard } = await setup({ skipWait: true }); + + expect(panel.state.options).toEqual({}); + expect(panelEditor.state.isInitializing).toBe(true); + + const pluginToLoad = getPanelPlugin({ id: 'text' }).setPanelOptions((build) => { + build.addBooleanSwitch({ + path: 'showHeader', + name: 'Show header', + defaultValue: true, + }); + }); + + pluginResolve(pluginToLoad); + + await new Promise((r) => setTimeout(r, 1)); + + expect(panelEditor.state.isInitializing).toBe(false); + expect(panel.state.options).toEqual({ showHeader: true }); + + panel.onOptionsChange({ showHeader: false }); + panelEditor.onDiscard(); + + const discardedPanel = findVizPanelByKey(dashboard, panel.state.key!)!; + expect(discardedPanel.state.options).toEqual({ showHeader: true }); + }); + }); + + describe('When discarding', () => { + it('should discard changes revert all changes', async () => { + const { panelEditor, panel, dashboard } = await setup(); + + panel.setState({ title: 'changed title' }); + panelEditor.onDiscard(); + + const discardedPanel = findVizPanelByKey(dashboard, panel.state.key!)!; + + expect(discardedPanel.state.title).toBe('original title'); }); - it('should discard changes when unmounted and discard changes is marked as true', () => { - pluginToLoad = getTestPanelPlugin({ id: 'text', skipDataQuery: true }); + it('should discard a newly added panel', async () => { + const { panelEditor, dashboard } = await setup({ isNewPanel: true }); + panelEditor.onDiscard(); - const panel = new VizPanel({ - key: 'panel-1', - pluginId: 'text', - }); - - const gridItem = new DashboardGridItem({ body: panel }); - - const editScene = buildPanelEditScene(panel); - const scene = new DashboardScene({ - editPanel: editScene, - isEditing: true, - body: new SceneGridLayout({ - children: [gridItem], - }), - }); - - const deactivate = activateFullSceneTree(scene); - - editScene.state.vizManager.state.panel.setState({ title: 'changed title' }); - - editScene.onDiscard(); - deactivate(); - - const updatedPanel = gridItem.state.body as VizPanel; - expect(updatedPanel?.state.title).toBe(panel.state.title); + expect((dashboard.state.body as SceneGridLayout).state.children.length).toBe(0); }); - it('should discard a newly added panel', () => { - pluginToLoad = getTestPanelPlugin({ id: 'text', skipDataQuery: true }); + it('should discard query runner changes', async () => { + const { panelEditor, panel, dashboard } = await setup({}); - const panel = new VizPanel({ - key: 'panel-1', - pluginId: 'text', - }); + const queryRunner = getQueryRunnerFor(panel); + queryRunner?.setState({ maxDataPoints: 123, queries: [{ refId: 'A' }, { refId: 'B' }] }); - const gridItem = new DashboardGridItem({ body: panel }); + panelEditor.onDiscard(); - const editScene = buildPanelEditScene(panel, true); - const scene = new DashboardScene({ - editPanel: editScene, - isEditing: true, - body: new SceneGridLayout({ - children: [gridItem], - }), - }); + const discardedPanel = findVizPanelByKey(dashboard, panel.state.key!)!; + const restoredQueryRunner = getQueryRunnerFor(discardedPanel); + expect(restoredQueryRunner?.state.maxDataPoints).toBe(500); + expect(restoredQueryRunner?.state.queries.length).toBe(1); + }); + }); - editScene.onDiscard(); - const deactivate = activateFullSceneTree(scene); + describe('When changes are made', () => { + it('Should set state to dirty', async () => { + const { panelEditor, panel } = await setup({}); - deactivate(); + expect(panelEditor.state.isDirty).toBe(undefined); - expect((scene.state.body as SceneGridLayout).state.children.length).toBe(0); + panel.setState({ title: 'changed title' }); + + expect(panelEditor.state.isDirty).toBe(true); + }); + + it('Should reset dirty and orginal state when dashboard is saved', async () => { + const { panelEditor, panel } = await setup({}); + + expect(panelEditor.state.isDirty).toBe(undefined); + + panel.setState({ title: 'changed title' }); + + panelEditor.dashboardSaved(); + + expect(panelEditor.state.isDirty).toBe(false); + + panel.setState({ title: 'changed title 2' }); + + expect(panelEditor.state.isDirty).toBe(true); + + // Change back to already saved state + panel.setState({ title: 'changed title' }); + expect(panelEditor.state.isDirty).toBe(false); + }); + }); + + describe('When opening a repeated panel', () => { + it('Should default to the first variable value if panel is repeated', async () => { + const { panel } = await setup({ repeatByVariable: 'server' }); + const variable = sceneGraph.lookupVariable('server', panel); + expect(variable?.getValue()).toBe('A'); }); }); describe('Handling library panels', () => { it('should call the api with the updated panel', async () => { - pluginToLoad = getTestPanelPlugin({ id: 'text', skipDataQuery: true }); - const panel = new VizPanel({ - key: 'panel-1', - pluginId: 'text', - }); + pluginPromise = Promise.resolve(getPanelPlugin({ id: 'text', skipDataQuery: true })); + const panel = new VizPanel({ key: 'panel-1', pluginId: 'text' }); const libraryPanelModel = { title: 'title', uid: 'uid', @@ -143,15 +204,13 @@ describe('PanelEditor', () => { _loadedPanel: libraryPanelModel, }); - panel.setState({ - $behaviors: [libPanelBehavior], - }); + panel.setState({ $behaviors: [libPanelBehavior] }); const gridItem = new DashboardGridItem({ body: panel }); - const editScene = buildPanelEditScene(panel); const scene = new DashboardScene({ editPanel: editScene, + $timeRange: new SceneTimeRange({ from: 'now-6h', to: 'now' }), isEditing: true, body: new SceneGridLayout({ children: [gridItem], @@ -160,96 +219,133 @@ describe('PanelEditor', () => { activateFullSceneTree(scene); - editScene.state.vizManager.state.panel.setState({ title: 'changed title' }); - (editScene.state.vizManager.state.panel.state.$behaviors![0] as LibraryPanelBehavior).setState({ - name: 'changed name', - }); + await new Promise((r) => setTimeout(r, 1)); + + panel.setState({ title: 'changed title' }); + libPanelBehavior.setState({ name: 'changed name' }); jest.spyOn(libAPI, 'saveLibPanel').mockImplementation(async (panel) => { const updatedPanel = { ...libAPI.libraryVizPanelToSaveModel(panel), version: 2 }; - libPanelBehavior.setPanelFromLibPanel(updatedPanel); }); - editScene.state.vizManager.commitChanges(); + editScene.onConfirmSaveLibraryPanel(); + await new Promise(process.nextTick); - await new Promise(process.nextTick); // Wait for mock api to return and update the library panel + // Wait for mock api to return and update the library panel expect(libPanelBehavior.state._loadedPanel?.version).toBe(2); expect(libPanelBehavior.state.name).toBe('changed name'); expect(libPanelBehavior.state.title).toBe('changed title'); expect((gridItem.state.body as VizPanel).state.title).toBe('changed title'); }); + + it('unlinks library panel', () => { + const libraryPanelModel = { + title: 'title', + uid: 'uid', + name: 'libraryPanelName', + model: { + title: 'title', + type: 'text', + }, + type: 'panel', + version: 1, + }; + + const libPanelBehavior = new LibraryPanelBehavior({ + isLoaded: true, + title: libraryPanelModel.title, + uid: libraryPanelModel.uid, + name: libraryPanelModel.name, + _loadedPanel: libraryPanelModel, + }); + + // Just adding an extra stateless behavior to verify unlinking does not remvoe it + const otherBehavior = jest.fn(); + const panel = new VizPanel({ key: 'panel-1', pluginId: 'text', $behaviors: [libPanelBehavior, otherBehavior] }); + const editScene = buildPanelEditScene(panel); + editScene.onConfirmUnlinkLibraryPanel(); + + expect(panel.state.$behaviors?.length).toBe(1); + expect(panel.state.$behaviors![0]).toBe(otherBehavior); + }); }); describe('PanelDataPane', () => { - it('should not exist if panel is skipDataQuery', () => { - pluginToLoad = getTestPanelPlugin({ id: 'text', skipDataQuery: true }); - - const panel = new VizPanel({ - key: 'panel-1', - pluginId: 'text', - }); - new DashboardGridItem({ - body: panel, - }); - - const editScene = buildPanelEditScene(panel); - const scene = new DashboardScene({ - editPanel: editScene, - }); - - activateFullSceneTree(scene); - - expect(editScene.state.dataPane).toBeUndefined(); + it('should not exist if panel is skipDataQuery', async () => { + const { panelEditor } = await setup({ pluginSkipDataQuery: true }); + expect(panelEditor.state.dataPane).toBeUndefined(); }); - it('should exist if panel is supporting querying', () => { - pluginToLoad = getTestPanelPlugin({ id: 'timeseries' }); - - const panel = new VizPanel({ - key: 'panel-1', - pluginId: 'timeseries', - }); - - new DashboardGridItem({ - body: panel, - }); - const editScene = buildPanelEditScene(panel); - const scene = new DashboardScene({ - editPanel: editScene, - }); - - activateFullSceneTree(scene); - expect(editScene.state.dataPane).toBeDefined(); + it('should exist if panel is supporting querying', async () => { + const { panelEditor } = await setup({ pluginSkipDataQuery: false }); + expect(panelEditor.state.dataPane).toBeDefined(); }); }); }); -export function getTestPanelPlugin(options: Partial): PanelPlugin { - const plugin = new PanelPlugin(() => null); - plugin.meta = { - id: options.id!, - type: PluginType.panel, - name: options.id!, - sort: options.sort || 1, - info: { - author: { - name: options.id + 'name', - }, - description: '', - links: [], - logos: { - large: '', - small: '', - }, - screenshots: [], - updated: '', - version: '1.0.', - }, - hideFromList: options.hideFromList === true, - module: options.module ?? '', - baseUrl: '', - skipDataQuery: options.skipDataQuery ?? false, - }; - return plugin; +interface SetupOptions { + isNewPanel?: boolean; + pluginSkipDataQuery?: boolean; + repeatByVariable?: string; + skipWait?: boolean; + pluginLoadTime?: number; +} + +async function setup(options: SetupOptions = {}) { + const pluginToLoad = getPanelPlugin({ id: 'text', skipDataQuery: options.pluginSkipDataQuery }); + let pluginResolve = (plugin: PanelPlugin) => {}; + + pluginPromise = new Promise((resolve) => { + pluginResolve = resolve; + }); + + const panel = new VizPanel({ + key: 'panel-1', + pluginId: 'text', + title: 'original title', + $data: new SceneDataTransformer({ + transformations: [], + $data: new SceneQueryRunner({ + queries: [{ refId: 'A' }], + maxDataPoints: 500, + datasource: { uid: 'ds1' }, + }), + }), + }); + + const gridItem = new DashboardGridItem({ body: panel, variableName: options.repeatByVariable }); + + const panelEditor = buildPanelEditScene(panel, options.isNewPanel); + const dashboard = new DashboardScene({ + editPanel: panelEditor, + isEditing: true, + $timeRange: new SceneTimeRange({ from: 'now-6h', to: 'now' }), + $variables: new SceneVariableSet({ + variables: [ + new CustomVariable({ + name: 'server', + query: 'A,B,C', + isMulti: true, + value: ['A', 'B', 'C'], + text: ['A', 'B', 'C'], + }), + ], + }), + body: new SceneGridLayout({ + children: [gridItem], + }), + }); + + panelEditor.debounceSaveModelDiff = false; + + deactivate = activateFullSceneTree(dashboard); + + if (!options.skipWait) { + //console.log('pluginResolve(pluginToLoad)'); + pluginResolve(pluginToLoad); + await new Promise((r) => setTimeout(r, 1)); + } + + return { dashboard, panel, gridItem, panelEditor, pluginResolve }; } diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx index 40546061ae5..ed0b3f624ae 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx @@ -1,72 +1,187 @@ import * as H from 'history'; +import { debounce } from 'lodash'; -import { NavIndex } from '@grafana/data'; -import { config, locationService } from '@grafana/runtime'; -import { SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes'; +import { NavIndex, PanelPlugin } from '@grafana/data'; +import { locationService } from '@grafana/runtime'; +import { + PanelBuilders, + SceneObjectBase, + SceneObjectRef, + SceneObjectState, + SceneObjectStateChangedEvent, + sceneUtils, + VizPanel, +} from '@grafana/scenes'; +import { Panel } from '@grafana/schema/dist/esm/index.gen'; +import { OptionFilter } from 'app/features/dashboard/components/PanelEditor/OptionsPaneOptions'; +import { saveLibPanel } from 'app/features/library-panels/state/api'; -import { DashboardGridItem } from '../scene/DashboardGridItem'; -import { getDashboardSceneFor, getPanelIdForVizPanel } from '../utils/utils'; +import { DashboardSceneChangeTracker } from '../saving/DashboardSceneChangeTracker'; +import { getPanelChanges } from '../saving/getDashboardChanges'; +import { DashboardGridItem, DashboardGridItemState } from '../scene/DashboardGridItem'; +import { vizPanelToPanel } from '../serialization/transformSceneToSaveModel'; +import { + activateInActiveParents, + getDashboardSceneFor, + getLibraryPanelBehavior, + getPanelIdForVizPanel, +} from '../utils/utils'; +import { DataProviderSharer } from './PanelDataPane/DataProviderSharer'; import { PanelDataPane } from './PanelDataPane/PanelDataPane'; import { PanelEditorRenderer } from './PanelEditorRenderer'; import { PanelOptionsPane } from './PanelOptionsPane'; -import { VizPanelManager, VizPanelManagerState } from './VizPanelManager'; export interface PanelEditorState extends SceneObjectState { isNewPanel: boolean; isDirty?: boolean; - panelId: number; - optionsPane: PanelOptionsPane; + optionsPane?: PanelOptionsPane; dataPane?: PanelDataPane; - vizManager: VizPanelManager; + panelRef: SceneObjectRef; showLibraryPanelSaveModal?: boolean; showLibraryPanelUnlinkModal?: boolean; + tableView?: VizPanel; + pluginLoadErrror?: string; + /** + * Waiting for library panel or panel plugin to load + */ + isInitializing?: boolean; } export class PanelEditor extends SceneObjectBase { - private _initialRepeatOptions: Pick = {}; static Component = PanelEditorRenderer; - private _discardChanges = false; + private _originalLayoutElementState!: DashboardGridItemState; + private _layoutElement!: DashboardGridItem; + private _originalSaveModel!: Panel; + private _changesHaveBeenMade = false; public constructor(state: PanelEditorState) { super(state); - const { repeat, repeatDirection, maxPerRow } = state.vizManager.state; - this._initialRepeatOptions = { - repeat, - repeatDirection, - maxPerRow, - }; - + this.setOriginalState(this.state.panelRef); this.addActivationHandler(this._activationHandler.bind(this)); } private _activationHandler() { - const panelManager = this.state.vizManager; - const panel = panelManager.state.panel; + const panel = this.state.panelRef.resolve(); + const deactivateParents = activateInActiveParents(panel); + const layoutElement = panel.parent; - this._subs.add( - panelManager.subscribeToState((n, p) => { - if (n.pluginId !== p.pluginId) { - this._initDataPane(n.pluginId); - } - }) - ); - - this._initDataPane(panel.state.pluginId); + this.waitForPlugin(); return () => { - if (!this._discardChanges) { - this.commitChanges(); - } else if (this.state.isNewPanel) { - getDashboardSceneFor(this).removePanel(panelManager.state.sourcePanel.resolve()!); + if (layoutElement instanceof DashboardGridItem) { + layoutElement.editingCompleted(this.state.isDirty || this._changesHaveBeenMade); + } + if (deactivateParents) { + deactivateParents(); } }; } + private waitForPlugin(retry = 0) { + const panel = this.getPanel(); + const plugin = panel.getPlugin(); - private _initDataPane(pluginId: string) { - const skipDataQuery = config.panels[pluginId]?.skipDataQuery; + if (!plugin || plugin.meta.id !== panel.state.pluginId) { + if (retry < 100) { + setTimeout(() => this.waitForPlugin(retry + 1), retry * 10); + } else { + this.setState({ pluginLoadErrror: 'Failed to load panel plugin' }); + } + return; + } + + this.gotPanelPlugin(plugin); + } + + private setOriginalState(panelRef: SceneObjectRef) { + const panel = panelRef.resolve(); + + this._originalSaveModel = vizPanelToPanel(panel); + + if (panel.parent instanceof DashboardGridItem) { + this._originalLayoutElementState = sceneUtils.cloneSceneObjectState(panel.parent.state); + this._layoutElement = panel.parent; + } + } + + /** + * Useful for testing to turn on debounce + */ + public debounceSaveModelDiff = true; + + /** + * Subscribe to state changes and check if the save model has changed + */ + private _setupChangeDetection() { + const panel = this.state.panelRef.resolve(); + const performSaveModelDiff = () => { + const { hasChanges } = getPanelChanges(this._originalSaveModel, vizPanelToPanel(panel)); + this.setState({ isDirty: hasChanges }); + }; + + const performSaveModelDiffDebounced = this.debounceSaveModelDiff + ? debounce(performSaveModelDiff, 250) + : performSaveModelDiff; + + const handleStateChange = (event: SceneObjectStateChangedEvent) => { + if (DashboardSceneChangeTracker.isUpdatingPersistedState(event)) { + performSaveModelDiffDebounced(); + } + }; + + this._subs.add(panel.subscribeToEvent(SceneObjectStateChangedEvent, handleStateChange)); + // Repeat options live on the layout element (DashboardGridItem) + this._subs.add(this._layoutElement.subscribeToEvent(SceneObjectStateChangedEvent, handleStateChange)); + } + + public getPanel(): VizPanel { + return this.state.panelRef?.resolve(); + } + + private gotPanelPlugin(plugin: PanelPlugin) { + const panel = this.getPanel(); + const layoutElement = panel.parent; + + // First time initialization + if (this.state.isInitializing) { + this.setOriginalState(this.state.panelRef); + + if (layoutElement instanceof DashboardGridItem) { + layoutElement.editingStarted(); + } + + this._setupChangeDetection(); + this._updateDataPane(plugin); + + // Listen for panel plugin changes + this._subs.add( + panel.subscribeToState((n, p) => { + if (n.pluginId !== p.pluginId) { + this.waitForPlugin(); + } + }) + ); + + // Setup options pane + this.setState({ + optionsPane: new PanelOptionsPane({ + panelRef: this.state.panelRef, + searchQuery: '', + listMode: OptionFilter.All, + }), + isInitializing: false, + }); + } else { + // plugin changed after first time initialization + // Just update data pane + this._updateDataPane(plugin); + } + } + + private _updateDataPane(plugin: PanelPlugin) { + const skipDataQuery = plugin.meta.skipDataQuery; if (skipDataQuery && this.state.dataPane) { locationService.partial({ tab: null }, true); @@ -74,12 +189,16 @@ export class PanelEditor extends SceneObjectBase { } if (!skipDataQuery && !this.state.dataPane) { - this.setState({ dataPane: new PanelDataPane(this.state.vizManager) }); + this.setState({ dataPane: PanelDataPane.createFor(this.getPanel()) }); } } public getUrlKey() { - return this.state.panelId.toString(); + return this.getPanelId().toString(); + } + + public getPanelId() { + return getPanelIdForVizPanel(this.state.panelRef.resolve()); } public getPageNav(location: H.Location, navIndex: NavIndex) { @@ -92,53 +211,26 @@ export class PanelEditor extends SceneObjectBase { } public onDiscard = () => { - this.state.vizManager.setState({ isDirty: false }); - this._discardChanges = true; + this.setState({ isDirty: false }); + + const panel = this.state.panelRef.resolve(); + + if (this.state.isNewPanel) { + getDashboardSceneFor(this).removePanel(panel); + } else { + // Revert any layout element changes + this._layoutElement.setState(this._originalLayoutElementState!); + } + locationService.partial({ editPanel: null }); }; - public commitChanges() { - const dashboard = getDashboardSceneFor(this); + public dashboardSaved() { + this.setOriginalState(this.state.panelRef); + this.setState({ isDirty: false }); - if (!dashboard.state.isEditing) { - dashboard.onEnterEditMode(); - } - - const panelManager = this.state.vizManager; - const sourcePanel = panelManager.state.sourcePanel.resolve(); - const gridItem = sourcePanel!.parent; - - if (!(gridItem instanceof DashboardGridItem)) { - console.error('Unsupported scene object type'); - return; - } - - this.commitChangesToSource(gridItem); - } - - private commitChangesToSource(gridItem: DashboardGridItem) { - let width = gridItem.state.width ?? 1; - let height = gridItem.state.height; - - const panelManager = this.state.vizManager; - const horizontalToVertical = - this._initialRepeatOptions.repeatDirection === 'h' && panelManager.state.repeatDirection === 'v'; - const verticalToHorizontal = - this._initialRepeatOptions.repeatDirection === 'v' && panelManager.state.repeatDirection === 'h'; - if (horizontalToVertical) { - width = Math.floor(width / (gridItem.state.maxPerRow ?? 1)); - } else if (verticalToHorizontal) { - width = 24; - } - - gridItem.setState({ - body: panelManager.state.panel.clone(), - repeatDirection: panelManager.state.repeatDirection, - variableName: panelManager.state.repeat, - maxPerRow: panelManager.state.maxPerRow, - width, - height, - }); + // Remember that we have done changes + this._changesHaveBeenMade = false; } public onSaveLibraryPanel = () => { @@ -146,8 +238,8 @@ export class PanelEditor extends SceneObjectBase { }; public onConfirmSaveLibraryPanel = () => { - this.state.vizManager.commitChanges(); - this.state.vizManager.setState({ isDirty: false }); + saveLibPanel(this.state.panelRef.resolve()); + this.setState({ isDirty: false }); locationService.partial({ editPanel: null }); }; @@ -164,16 +256,43 @@ export class PanelEditor extends SceneObjectBase { }; public onConfirmUnlinkLibraryPanel = () => { - this.state.vizManager.unlinkLibraryPanel(); + const libPanelBehavior = getLibraryPanelBehavior(this.getPanel()); + if (!libPanelBehavior) { + return; + } + + libPanelBehavior.unlink(); + this.setState({ showLibraryPanelUnlinkModal: false }); }; + + public onToggleTableView = () => { + if (this.state.tableView) { + this.setState({ tableView: undefined }); + return; + } + + const panel = this.state.panelRef.resolve(); + const dataProvider = panel.state.$data; + if (!dataProvider) { + return; + } + + this.setState({ + tableView: PanelBuilders.table() + .setTitle('') + .setOption('showTypeIcons', true) + .setOption('showHeader', true) + .setData(new DataProviderSharer({ source: dataProvider.getRef() })) + .build(), + }); + }; } export function buildPanelEditScene(panel: VizPanel, isNewPanel = false): PanelEditor { return new PanelEditor({ - panelId: getPanelIdForVizPanel(panel), - optionsPane: new PanelOptionsPane({}), - vizManager: VizPanelManager.createFor(panel), + isInitializing: true, + panelRef: panel.getRef(), isNewPanel, }); } diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx b/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx index 50f259d12ff..ae136dd08b7 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx @@ -2,8 +2,8 @@ import { css, cx } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { SceneComponentProps } from '@grafana/scenes'; -import { Button, ToolbarButton, useStyles2 } from '@grafana/ui'; +import { SceneComponentProps, VizPanel } from '@grafana/scenes'; +import { Button, Spinner, ToolbarButton, useStyles2 } from '@grafana/ui'; import { NavToolbarActions } from '../scene/NavToolbarActions'; import { UnlinkModal } from '../scene/UnlinkModal'; @@ -54,7 +54,8 @@ export function PanelEditorRenderer({ model }: SceneComponentProps) />
    )} - {!splitterState.collapsed && } + {!splitterState.collapsed && optionsPane && } + {!splitterState.collapsed && !optionsPane && }
    @@ -63,9 +64,9 @@ export function PanelEditorRenderer({ model }: SceneComponentProps) function VizAndDataPane({ model }: SceneComponentProps) { const dashboard = getDashboardSceneFor(model); - const { vizManager, dataPane, showLibraryPanelSaveModal, showLibraryPanelUnlinkModal } = model.useState(); - const { sourcePanel } = vizManager.useState(); - const libraryPanel = getLibraryPanelBehavior(sourcePanel.resolve()); + const { dataPane, showLibraryPanelSaveModal, showLibraryPanelUnlinkModal, tableView } = model.useState(); + const panel = model.getPanel(); + const libraryPanel = getLibraryPanelBehavior(panel); const { controls } = dashboard.useState(); const styles = useStyles2(getStyles); @@ -94,7 +95,7 @@ function VizAndDataPane({ model }: SceneComponentProps) { )}
    - +
    {showLibraryPanelSaveModal && libraryPanel && ( ) { ); } +interface VizWrapperProps { + panel: VizPanel; + tableView?: VizPanel; +} + +function VizWrapper({ panel, tableView }: VizWrapperProps) { + const styles = useStyles2(getStyles); + const panelToShow = tableView ?? panel; + + return ( +
    + +
    + ); +} + function getStyles(theme: GrafanaTheme2) { return { pageContainer: css({ @@ -215,5 +232,10 @@ function getStyles(theme: GrafanaTheme2) { rotate: '-90deg', }, }), + vizWrapper: css({ + height: '100%', + width: '100%', + paddingLeft: theme.spacing(2), + }), }; } diff --git a/public/app/features/dashboard-scene/panel-edit/PanelOptions.test.tsx b/public/app/features/dashboard-scene/panel-edit/PanelOptions.test.tsx index 619183a9dd2..cfba5a9c57f 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelOptions.test.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelOptions.test.tsx @@ -18,7 +18,7 @@ import { activateFullSceneTree } from '../utils/test-utils'; import * as utils from '../utils/utils'; import { PanelOptions } from './PanelOptions'; -import { VizPanelManager } from './VizPanelManager'; +import { PanelOptionsPane } from './PanelOptionsPane'; const OptionsPaneSelector = selectors.components.PanelEditor.OptionsPane; @@ -92,43 +92,47 @@ function setup(options: SetupOptions = {}) { } // need to wait for plugin to load - const vizManager = VizPanelManager.createFor(panel); + const panelOptionsScene = new PanelOptionsPane({ + panelRef: panel.getRef(), + searchQuery: '', + listMode: OptionFilter.All, + }); - activateFullSceneTree(vizManager); - - const panelOptions = ; + activateFullSceneTree(panelOptionsScene); + panel.activate(); + const panelOptions = ; const renderResult = render(panelOptions); - return { renderResult, vizManager }; + return { renderResult, panelOptionsScene, panel }; } describe('PanelOptions', () => { describe('Can render and edit panel frame options', () => { it('Can edit title', async () => { - const { vizManager } = setup(); + const { panel } = setup(); expect(screen.getByLabelText(OptionsPaneSelector.fieldLabel('Panel options Title'))).toBeInTheDocument(); const input = screen.getByTestId(selectors.components.PanelEditor.OptionsPane.fieldInput('Title')); fireEvent.change(input, { target: { value: 'New title' } }); - expect(vizManager.state.panel.state.title).toBe('New title'); + expect(panel.state.title).toBe('New title'); }); it('Clearing title should set hoverHeader to true', async () => { - const { vizManager } = setup(); + const { panel } = setup(); expect(screen.getByLabelText(OptionsPaneSelector.fieldLabel('Panel options Title'))).toBeInTheDocument(); const input = screen.getByTestId(selectors.components.PanelEditor.OptionsPane.fieldInput('Title')); fireEvent.change(input, { target: { value: '' } }); - expect(vizManager.state.panel.state.title).toBe(''); - expect(vizManager.state.panel.state.hoverHeader).toBe(true); + expect(panel.state.title).toBe(''); + expect(panel.state.hoverHeader).toBe(true); fireEvent.change(input, { target: { value: 'Muu' } }); - expect(vizManager.state.panel.state.hoverHeader).toBe(false); + expect(panel.state.hoverHeader).toBe(false); }); }); @@ -179,13 +183,11 @@ describe('PanelOptions', () => { _loadedPanel: libraryPanelModel, }); - panel.setState({ - $behaviors: [libraryPanel], - }); + panel.setState({ $behaviors: [libraryPanel] }); new DashboardGridItem({ body: panel }); - const { renderResult, vizManager } = setup({ panel: panel }); + const { renderResult } = setup({ panel: panel }); const input = await renderResult.findByTestId('library panel name input'); @@ -193,8 +195,6 @@ describe('PanelOptions', () => { fireEvent.blur(input, { target: { value: 'new library panel name' } }); }); - expect((vizManager.state.panel.state.$behaviors![0] as LibraryPanelBehavior).state.name).toBe( - 'new library panel name' - ); + expect((panel.state.$behaviors![0] as LibraryPanelBehavior).state.name).toBe('new library panel name'); }); }); diff --git a/public/app/features/dashboard-scene/panel-edit/PanelOptions.tsx b/public/app/features/dashboard-scene/panel-edit/PanelOptions.tsx index 01e4655d832..3428dbe707b 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelOptions.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelOptions.tsx @@ -13,24 +13,23 @@ import { import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior'; import { getLibraryPanelBehavior, isLibraryPanel } from '../utils/utils'; -import { VizPanelManager } from './VizPanelManager'; import { getPanelFrameCategory2 } from './getPanelFrameOptions'; interface Props { - vizManager: VizPanelManager; + panel: VizPanel; searchQuery: string; listMode: OptionFilter; data?: PanelData; } -export const PanelOptions = React.memo(({ vizManager, searchQuery, listMode, data }) => { - const { panel, repeat } = vizManager.useState(); +export const PanelOptions = React.memo(({ panel, searchQuery, listMode, data }) => { const { options, fieldConfig, _pluginInstanceState } = panel.useState(); + const layoutElement = panel.parent!; + const layoutElementState = layoutElement.useState(); - // eslint-disable-next-line react-hooks/exhaustive-deps const panelFrameOptions = useMemo( - () => getPanelFrameCategory2(vizManager, panel, repeat), - [vizManager, panel, repeat] + () => getPanelFrameCategory2(panel, layoutElementState), + [panel, layoutElementState] ); const visualizationOptions = useMemo(() => { diff --git a/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.test.tsx b/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.test.tsx new file mode 100644 index 00000000000..a63717aa9f7 --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.test.tsx @@ -0,0 +1,101 @@ +import { OptionFilter } from 'app/features/dashboard/components/PanelEditor/OptionsPaneOptions'; +import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; + +import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; +import { DashboardModelCompatibilityWrapper } from '../utils/DashboardModelCompatibilityWrapper'; +import { findVizPanelByKey } from '../utils/utils'; + +import { PanelOptionsPane } from './PanelOptionsPane'; +import { testDashboard } from './testfiles/testDashboard'; + +describe('PanelOptionsPane', () => { + describe('When changing plugin', () => { + it('Should set the cache', () => { + const { optionsPane, panel } = setupTest('panel-1'); + panel.changePluginType = jest.fn(); + + expect(panel.state.pluginId).toBe('timeseries'); + + optionsPane.onChangePanelPlugin({ pluginId: 'table' }); + + expect(optionsPane['_cachedPluginOptions']['timeseries']?.options).toBe(panel.state.options); + expect(optionsPane['_cachedPluginOptions']['timeseries']?.fieldConfig).toBe(panel.state.fieldConfig); + }); + + it('Should preserve correct field config', () => { + const { optionsPane, panel } = setupTest('panel-1'); + + const mockFn = jest.fn(); + panel.changePluginType = mockFn; + + const fieldConfig = panel.state.fieldConfig; + + fieldConfig.defaults = { + ...fieldConfig.defaults, + unit: 'flop', + decimals: 2, + }; + + fieldConfig.overrides = [ + { + matcher: { + id: 'byName', + options: 'A-series', + }, + properties: [ + { + id: 'displayName', + value: 'test', + }, + ], + }, + { + matcher: { id: 'byName', options: 'D-series' }, + //should be removed because it's custom + properties: [ + { + id: 'custom.customPropNoExist', + value: 'google', + }, + ], + }, + ]; + + panel.setState({ fieldConfig: fieldConfig }); + + expect(panel.state.fieldConfig.defaults.color?.mode).toBe('palette-classic'); + expect(panel.state.fieldConfig.defaults.thresholds?.mode).toBe('absolute'); + expect(panel.state.fieldConfig.defaults.unit).toBe('flop'); + expect(panel.state.fieldConfig.defaults.decimals).toBe(2); + expect(panel.state.fieldConfig.overrides).toHaveLength(2); + expect(panel.state.fieldConfig.overrides[1].properties).toHaveLength(1); + expect(panel.state.fieldConfig.defaults.custom).toHaveProperty('axisBorderShow'); + + optionsPane.onChangePanelPlugin({ pluginId: 'table' }); + + expect(mockFn).toHaveBeenCalled(); + expect(mockFn.mock.calls[0][2].defaults.color?.mode).toBe('palette-classic'); + expect(mockFn.mock.calls[0][2].defaults.thresholds?.mode).toBe('absolute'); + expect(mockFn.mock.calls[0][2].defaults.unit).toBe('flop'); + expect(mockFn.mock.calls[0][2].defaults.decimals).toBe(2); + expect(mockFn.mock.calls[0][2].overrides).toHaveLength(2); + //removed custom property + expect(mockFn.mock.calls[0][2].overrides[1].properties).toHaveLength(0); + //removed fieldConfig custom values as well + expect(mockFn.mock.calls[0][2].defaults.custom).toStrictEqual({}); + }); + }); +}); + +function setupTest(panelId: string) { + const scene = transformSaveModelToScene({ dashboard: testDashboard, meta: {} }); + const panel = findVizPanelByKey(scene, panelId)!; + + const optionsPane = new PanelOptionsPane({ panelRef: panel.getRef(), listMode: OptionFilter.All, searchQuery: '' }); + + // The following happens on DahsboardScene activation. For the needs of this test this activation aint needed hence we hand-call it + // @ts-expect-error + getDashboardSrv().setCurrent(new DashboardModelCompatibilityWrapper(scene)); + + return { optionsPane, scene, panel }; +} diff --git a/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx b/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx index 5effa56b8f8..3b100b15b83 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.tsx @@ -1,15 +1,30 @@ import { css } from '@emotion/css'; import { useMemo } from 'react'; -import { GrafanaTheme2, PanelPluginMeta } from '@grafana/data'; +import { + FieldConfigSource, + filterFieldConfigOverrides, + GrafanaTheme2, + isStandardFieldProp, + PanelPluginMeta, + restoreCustomOverrideRules, +} from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { SceneComponentProps, SceneObjectBase, SceneObjectState, sceneGraph } from '@grafana/scenes'; +import { + DeepPartial, + SceneComponentProps, + SceneObjectBase, + SceneObjectRef, + SceneObjectState, + VizPanel, + sceneGraph, +} from '@grafana/scenes'; import { FilterInput, Stack, ToolbarButton, useStyles2 } from '@grafana/ui'; import { OptionFilter } from 'app/features/dashboard/components/PanelEditor/OptionsPaneOptions'; import { getPanelPluginNotFound } from 'app/features/panel/components/PanelPluginError'; +import { VizTypeChangeDetails } from 'app/features/panel/components/VizTypePicker/types'; import { getAllPanelPluginMeta } from 'app/features/panel/state/util'; -import { PanelEditor } from './PanelEditor'; import { PanelOptions } from './PanelOptions'; import { PanelVizTypePicker } from './PanelVizTypePicker'; @@ -17,21 +32,48 @@ export interface PanelOptionsPaneState extends SceneObjectState { isVizPickerOpen?: boolean; searchQuery: string; listMode: OptionFilter; + panelRef: SceneObjectRef; +} + +interface PluginOptionsCache { + options: DeepPartial<{}>; + fieldConfig: FieldConfigSource>; } export class PanelOptionsPane extends SceneObjectBase { - public constructor(state: Partial) { - super({ - searchQuery: '', - listMode: OptionFilter.All, - ...state, - }); - } + private _cachedPluginOptions: Record = {}; onToggleVizPicker = () => { this.setState({ isVizPickerOpen: !this.state.isVizPickerOpen }); }; + onChangePanelPlugin = (options: VizTypeChangeDetails) => { + const panel = this.state.panelRef.resolve(); + const { options: prevOptions, fieldConfig: prevFieldConfig, pluginId: prevPluginId } = panel.state; + const pluginId = options.pluginId; + + // clear custom options + let newFieldConfig: FieldConfigSource = { + defaults: { + ...prevFieldConfig.defaults, + custom: {}, + }, + overrides: filterFieldConfigOverrides(prevFieldConfig.overrides, isStandardFieldProp), + }; + + this._cachedPluginOptions[prevPluginId] = { options: prevOptions, fieldConfig: prevFieldConfig }; + + const cachedOptions = this._cachedPluginOptions[pluginId]?.options; + const cachedFieldConfig = this._cachedPluginOptions[pluginId]?.fieldConfig; + + if (cachedFieldConfig) { + newFieldConfig = restoreCustomOverrideRules(newFieldConfig, cachedFieldConfig); + } + + panel.changePluginType(pluginId, cachedOptions, newFieldConfig); + this.onToggleVizPicker(); + }; + onSetSearchQuery = (searchQuery: string) => { this.setState({ searchQuery }); }; @@ -41,10 +83,10 @@ export class PanelOptionsPane extends SceneObjectBase { }; static Component = ({ model }: SceneComponentProps) => { - const { isVizPickerOpen, searchQuery, listMode } = model.useState(); - const vizManager = sceneGraph.getAncestor(model, PanelEditor).state.vizManager; - const { pluginId } = vizManager.useState(); - const { data } = sceneGraph.getData(vizManager.state.panel).useState(); + const { isVizPickerOpen, searchQuery, listMode, panelRef } = model.useState(); + const panel = panelRef.resolve(); + const { pluginId } = panel.useState(); + const { data } = sceneGraph.getData(panel).useState(); const styles = useStyles2(getStyles); return ( @@ -61,12 +103,17 @@ export class PanelOptionsPane extends SceneObjectBase { />
    - +
    )} {isVizPickerOpen && ( - + )} ); diff --git a/public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx b/public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx index 0da983abab6..2e180642bc9 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx @@ -4,6 +4,7 @@ import { useLocalStorage } from 'react-use'; import { GrafanaTheme2, PanelData, SelectableValue } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; +import { VizPanel } from '@grafana/scenes'; import { Button, CustomScrollbar, Field, FilterInput, RadioButtonGroup, useStyles2 } from '@grafana/ui'; import { LS_VISUALIZATION_SELECT_TAB_KEY, LS_WIDGET_SELECT_TAB_KEY } from 'app/core/constants'; import { VisualizationSelectPaneTab } from 'app/features/dashboard/components/PanelEditor/types'; @@ -13,16 +14,14 @@ import { VizTypeChangeDetails } from 'app/features/panel/components/VizTypePicke import { PanelModelCompatibilityWrapper } from '../utils/PanelModelCompatibilityWrapper'; -import { VizPanelManager } from './VizPanelManager'; - export interface Props { data?: PanelData; - vizManager: VizPanelManager; - onChange: () => void; + panel: VizPanel; + onChange: (options: VizTypeChangeDetails) => void; + onClose: () => void; } -export function PanelVizTypePicker({ vizManager, data, onChange }: Props) { - const { panel } = vizManager.useState(); +export function PanelVizTypePicker({ panel, data, onChange, onClose }: Props) { const styles = useStyles2(getStyles); const [searchQuery, setSearchQuery] = useState(''); @@ -50,22 +49,8 @@ export function PanelVizTypePicker({ vizManager, data, onChange }: Props) { const radioOptions: Array> = [ { label: 'Visualizations', value: VisualizationSelectPaneTab.Visualizations }, { label: 'Suggestions', value: VisualizationSelectPaneTab.Suggestions }, - // { - // label: 'Library panels', - // value: VisualizationSelectPaneTab.LibraryPanels, - // description: 'Reusable panels you can share between multiple dashboards.', - // }, ]; - const onVizTypeChange = (options: VizTypeChangeDetails) => { - vizManager.changePluginType(options.pluginId); - onChange(); - }; - - const onCloseVizPicker = () => { - onChange(); - }; - return (
    @@ -82,7 +67,7 @@ export function PanelVizTypePicker({ vizManager, data, onChange }: Props) { icon="angle-up" className={styles.closeButton} data-testid={selectors.components.PanelEditor.toggleVizPicker} - onClick={onCloseVizPicker} + onClick={onClose} />
    @@ -90,18 +75,10 @@ export function PanelVizTypePicker({ vizManager, data, onChange }: Props) { {listMode === VisualizationSelectPaneTab.Visualizations && ( - + )} - {/* {listMode === VisualizationSelectPaneTab.Widgets && ( - - )} */} {listMode === VisualizationSelectPaneTab.Suggestions && ( - + )}
    diff --git a/public/app/features/dashboard-scene/panel-edit/VizPanelManager.test.tsx b/public/app/features/dashboard-scene/panel-edit/VizPanelManager.test.tsx deleted file mode 100644 index 887c876bf70..00000000000 --- a/public/app/features/dashboard-scene/panel-edit/VizPanelManager.test.tsx +++ /dev/null @@ -1,864 +0,0 @@ -import { map, of } from 'rxjs'; - -import { DataQueryRequest, DataSourceApi, DataSourceInstanceSettings, LoadingState, PanelData } from '@grafana/data'; -import { calculateFieldTransformer } from '@grafana/data/src/transformations/transformers/calculateField'; -import { mockTransformationsRegistry } from '@grafana/data/src/utils/tests/mockTransformationsRegistry'; -import { config, locationService } from '@grafana/runtime'; -import { - CustomVariable, - LocalValueVariable, - SceneGridRow, - SceneVariableSet, - VizPanel, - sceneGraph, -} from '@grafana/scenes'; -import { DataQuery, DataSourceJsonData, DataSourceRef } from '@grafana/schema'; -import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; -import { InspectTab } from 'app/features/inspector/types'; -import * as libAPI from 'app/features/library-panels/state/api'; -import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard'; -import { DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plugins/datasource/dashboard/types'; - -import { DashboardGridItem } from '../scene/DashboardGridItem'; -import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior'; -import { PanelTimeRange, PanelTimeRangeState } from '../scene/PanelTimeRange'; -import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; -import { vizPanelToPanel } from '../serialization/transformSceneToSaveModel'; -import { DashboardModelCompatibilityWrapper } from '../utils/DashboardModelCompatibilityWrapper'; -import { findVizPanelByKey } from '../utils/utils'; - -import { buildPanelEditScene } from './PanelEditor'; -import { VizPanelManager } from './VizPanelManager'; -import { panelWithQueriesOnly, panelWithTransformations, testDashboard } from './testfiles/testDashboard'; - -const runRequestMock = jest.fn().mockImplementation((ds: DataSourceApi, request: DataQueryRequest) => { - const result: PanelData = { - state: LoadingState.Loading, - series: [], - timeRange: request.range, - }; - - return of([]).pipe( - map(() => { - result.state = LoadingState.Done; - result.series = []; - - return result; - }) - ); -}); - -const ds1Mock: DataSourceApi = { - meta: { - id: 'grafana-testdata-datasource', - }, - name: 'grafana-testdata-datasource', - type: 'grafana-testdata-datasource', - uid: 'gdev-testdata', - getRef: () => { - return { type: 'grafana-testdata-datasource', uid: 'gdev-testdata' }; - }, -} as DataSourceApi; - -const ds2Mock: DataSourceApi = { - meta: { - id: 'grafana-prometheus-datasource', - }, - name: 'grafana-prometheus-datasource', - type: 'grafana-prometheus-datasource', - uid: 'gdev-prometheus', - getRef: () => { - return { type: 'grafana-prometheus-datasource', uid: 'gdev-prometheus' }; - }, -} as DataSourceApi; - -const ds3Mock: DataSourceApi = { - meta: { - id: DASHBOARD_DATASOURCE_PLUGIN_ID, - }, - name: SHARED_DASHBOARD_QUERY, - type: SHARED_DASHBOARD_QUERY, - uid: SHARED_DASHBOARD_QUERY, - getRef: () => { - return { type: SHARED_DASHBOARD_QUERY, uid: SHARED_DASHBOARD_QUERY }; - }, -} as DataSourceApi; - -const defaultDsMock: DataSourceApi = { - meta: { - id: 'grafana-testdata-datasource', - }, - name: 'grafana-testdata-datasource', - type: 'grafana-testdata-datasource', - uid: 'gdev-testdata', - getRef: () => { - return { type: 'grafana-testdata-datasource', uid: 'gdev-testdata' }; - }, -} as DataSourceApi; - -const instance1SettingsMock = { - id: 1, - uid: 'gdev-testdata', - name: 'testDs1', - type: 'grafana-testdata-datasource', - meta: { - id: 'grafana-testdata-datasource', - }, -}; - -const instance2SettingsMock = { - id: 1, - uid: 'gdev-prometheus', - name: 'testDs2', - type: 'grafana-prometheus-datasource', - meta: { - id: 'grafana-prometheus-datasource', - }, -}; - -// Mocking the build in Grafana data source to avoid annotations data layer errors. -const grafanaDs = { - id: 1, - uid: '-- Grafana --', - name: 'grafana', - type: 'grafana', - meta: { - id: 'grafana', - }, -}; - -// Mock the store module -jest.mock('app/core/store', () => ({ - exists: jest.fn(), - get: jest.fn(), - getObject: jest.fn((_a, b) => b), - setObject: jest.fn(), -})); - -const store = jest.requireMock('app/core/store'); - -jest.mock('@grafana/runtime', () => ({ - ...jest.requireActual('@grafana/runtime'), - getRunRequest: () => (ds: DataSourceApi, request: DataQueryRequest) => { - return runRequestMock(ds, request); - }, - getDataSourceSrv: () => ({ - get: async (ref: DataSourceRef) => { - // Mocking the build in Grafana data source to avoid annotations data layer errors. - - if (ref.uid === '-- Grafana --') { - return grafanaDs; - } - - if (ref.uid === 'gdev-testdata') { - return ds1Mock; - } - - if (ref.uid === 'gdev-prometheus') { - return ds2Mock; - } - - if (ref.uid === SHARED_DASHBOARD_QUERY) { - return ds3Mock; - } - - // if datasource is not found, return default datasource - return defaultDsMock; - }, - getInstanceSettings: (ref: DataSourceRef) => { - if (ref.uid === 'gdev-testdata') { - return instance1SettingsMock; - } - - if (ref.uid === 'gdev-prometheus') { - return instance2SettingsMock; - } - - // if datasource is not found, return default instance settings - return instance1SettingsMock; - }, - }), - locationService: { - partial: jest.fn(), - }, - config: { - ...jest.requireActual('@grafana/runtime').config, - defaultDatasource: 'gdev-testdata', - }, -})); - -mockTransformationsRegistry([calculateFieldTransformer]); - -jest.useFakeTimers(); - -describe('VizPanelManager', () => { - describe('When changing plugin', () => { - it('Should set the cache', () => { - const { vizPanelManager } = setupTest('panel-1'); - vizPanelManager.state.panel.changePluginType = jest.fn(); - - expect(vizPanelManager.state.panel.state.pluginId).toBe('timeseries'); - - vizPanelManager.changePluginType('table'); - - expect(vizPanelManager['_cachedPluginOptions']['timeseries']?.options).toBe( - vizPanelManager.state.panel.state.options - ); - expect(vizPanelManager['_cachedPluginOptions']['timeseries']?.fieldConfig).toBe( - vizPanelManager.state.panel.state.fieldConfig - ); - }); - - it('Should preserve correct field config', () => { - const { vizPanelManager } = setupTest('panel-1'); - const mockFn = jest.fn(); - vizPanelManager.state.panel.changePluginType = mockFn; - const fieldConfig = vizPanelManager.state.panel.state.fieldConfig; - fieldConfig.defaults = { - ...fieldConfig.defaults, - unit: 'flop', - decimals: 2, - }; - fieldConfig.overrides = [ - { - matcher: { - id: 'byName', - options: 'A-series', - }, - properties: [ - { - id: 'displayName', - value: 'test', - }, - ], - }, - { - matcher: { id: 'byName', options: 'D-series' }, - //should be removed because it's custom - properties: [ - { - id: 'custom.customPropNoExist', - value: 'google', - }, - ], - }, - ]; - vizPanelManager.state.panel.setState({ - fieldConfig: fieldConfig, - }); - - expect(vizPanelManager.state.panel.state.fieldConfig.defaults.color?.mode).toBe('palette-classic'); - expect(vizPanelManager.state.panel.state.fieldConfig.defaults.thresholds?.mode).toBe('absolute'); - expect(vizPanelManager.state.panel.state.fieldConfig.defaults.unit).toBe('flop'); - expect(vizPanelManager.state.panel.state.fieldConfig.defaults.decimals).toBe(2); - expect(vizPanelManager.state.panel.state.fieldConfig.overrides).toHaveLength(2); - expect(vizPanelManager.state.panel.state.fieldConfig.overrides[1].properties).toHaveLength(1); - expect(vizPanelManager.state.panel.state.fieldConfig.defaults.custom).toHaveProperty('axisBorderShow'); - - vizPanelManager.changePluginType('table'); - - expect(mockFn).toHaveBeenCalled(); - expect(mockFn.mock.calls[0][2].defaults.color?.mode).toBe('palette-classic'); - expect(mockFn.mock.calls[0][2].defaults.thresholds?.mode).toBe('absolute'); - expect(mockFn.mock.calls[0][2].defaults.unit).toBe('flop'); - expect(mockFn.mock.calls[0][2].defaults.decimals).toBe(2); - expect(mockFn.mock.calls[0][2].overrides).toHaveLength(2); - //removed custom property - expect(mockFn.mock.calls[0][2].overrides[1].properties).toHaveLength(0); - //removed fieldConfig custom values as well - expect(mockFn.mock.calls[0][2].defaults.custom).toStrictEqual({}); - }); - }); - - describe('library panels', () => { - it('saves library panels on commit', () => { - const panel = new VizPanel({ - key: 'panel-1', - pluginId: 'text', - }); - - const libraryPanelModel = { - title: 'title', - uid: 'uid', - name: 'libraryPanelName', - model: vizPanelToPanel(panel), - type: 'panel', - version: 1, - }; - - const libPanelBehavior = new LibraryPanelBehavior({ - isLoaded: true, - title: libraryPanelModel.title, - uid: libraryPanelModel.uid, - name: libraryPanelModel.name, - _loadedPanel: libraryPanelModel, - }); - - panel.setState({ - $behaviors: [libPanelBehavior], - }); - - new DashboardGridItem({ body: panel }); - - const panelManager = VizPanelManager.createFor(panel); - - const apiCall = jest.spyOn(libAPI, 'saveLibPanel'); - - panelManager.state.panel.setState({ title: 'new title' }); - panelManager.commitChanges(); - - expect(apiCall.mock.calls[0][0].state.title).toBe('new title'); - }); - - it('unlinks library panel', () => { - const panel = new VizPanel({ - key: 'panel-1', - pluginId: 'text', - }); - - const libraryPanelModel = { - title: 'title', - uid: 'uid', - name: 'libraryPanelName', - model: vizPanelToPanel(panel), - type: 'panel', - version: 1, - }; - - const libPanelBehavior = new LibraryPanelBehavior({ - isLoaded: true, - title: libraryPanelModel.title, - uid: libraryPanelModel.uid, - name: libraryPanelModel.name, - _loadedPanel: libraryPanelModel, - }); - - panel.setState({ - $behaviors: [libPanelBehavior], - }); - - new DashboardGridItem({ body: panel }); - - const panelManager = VizPanelManager.createFor(panel); - panelManager.unlinkLibraryPanel(); - - const sourcePanel = panelManager.state.sourcePanel.resolve(); - expect(sourcePanel.state.$behaviors).toBe(undefined); - }); - }); - - describe('query options', () => { - beforeEach(() => { - store.setObject.mockClear(); - }); - - describe('activation', () => { - it('should load data source', async () => { - const { vizPanelManager } = setupTest('panel-1'); - vizPanelManager.activate(); - await Promise.resolve(); - - expect(vizPanelManager.state.datasource).toEqual(ds1Mock); - expect(vizPanelManager.state.dsSettings).toEqual(instance1SettingsMock); - }); - - it('should store loaded data source in local storage', async () => { - const { vizPanelManager } = setupTest('panel-1'); - vizPanelManager.activate(); - await Promise.resolve(); - - expect(store.setObject).toHaveBeenCalledWith('grafana.dashboards.panelEdit.lastUsedDatasource', { - dashboardUid: 'ffbe00e2-803c-4d49-adb7-41aad336234f', - datasourceUid: 'gdev-testdata', - }); - }); - - it('should load default datasource if the datasource passed is not found', async () => { - const { vizPanelManager } = setupTest('panel-6'); - vizPanelManager.activate(); - await Promise.resolve(); - - expect(vizPanelManager.queryRunner.state.datasource).toEqual({ - uid: 'abc', - type: 'datasource', - }); - - expect(config.defaultDatasource).toBe('gdev-testdata'); - expect(vizPanelManager.state.datasource).toEqual(defaultDsMock); - expect(vizPanelManager.state.dsSettings).toEqual(instance1SettingsMock); - }); - }); - - describe('data source change', () => { - it('should load new data source', async () => { - const { vizPanelManager } = setupTest('panel-1'); - vizPanelManager.activate(); - vizPanelManager.state.panel.state.$data?.activate(); - - await Promise.resolve(); - - await vizPanelManager.changePanelDataSource( - { type: 'grafana-prometheus-datasource', uid: 'gdev-prometheus' } as DataSourceInstanceSettings, - [] - ); - - expect(store.setObject).toHaveBeenCalledTimes(2); - expect(store.setObject).toHaveBeenLastCalledWith('grafana.dashboards.panelEdit.lastUsedDatasource', { - dashboardUid: 'ffbe00e2-803c-4d49-adb7-41aad336234f', - datasourceUid: 'gdev-prometheus', - }); - - jest.runAllTimers(); // The detect panel changes is debounced - expect(vizPanelManager.state.isDirty).toBe(true); - expect(vizPanelManager.state.datasource).toEqual(ds2Mock); - expect(vizPanelManager.state.dsSettings).toEqual(instance2SettingsMock); - }); - }); - - describe('query options change', () => { - describe('time overrides', () => { - it('should create PanelTimeRange object', async () => { - const { vizPanelManager } = setupTest('panel-1'); - vizPanelManager.activate(); - vizPanelManager.state.panel.state.$data?.activate(); - await Promise.resolve(); - - const panel = vizPanelManager.state.panel; - - expect(panel.state.$timeRange).toBeUndefined(); - - vizPanelManager.changeQueryOptions({ - dataSource: { - name: 'grafana-testdata', - type: 'grafana-testdata-datasource', - default: true, - }, - queries: [], - timeRange: { - from: '1h', - }, - }); - - expect(panel.state.$timeRange).toBeInstanceOf(PanelTimeRange); - }); - it('should update PanelTimeRange object on time options update', async () => { - const { vizPanelManager } = setupTest('panel-1'); - vizPanelManager.activate(); - await Promise.resolve(); - - const panel = vizPanelManager.state.panel; - - expect(panel.state.$timeRange).toBeUndefined(); - - vizPanelManager.changeQueryOptions({ - dataSource: { - name: 'grafana-testdata', - type: 'grafana-testdata-datasource', - default: true, - }, - queries: [], - timeRange: { - from: '1h', - }, - }); - - expect(panel.state.$timeRange).toBeInstanceOf(PanelTimeRange); - expect((panel.state.$timeRange?.state as PanelTimeRangeState).timeFrom).toBe('1h'); - - vizPanelManager.changeQueryOptions({ - dataSource: { - name: 'grafana-testdata', - type: 'grafana-testdata-datasource', - default: true, - }, - queries: [], - timeRange: { - from: '2h', - }, - }); - - jest.runAllTimers(); // The detect panel changes is debounced - expect(vizPanelManager.state.isDirty).toBe(true); - expect((panel.state.$timeRange?.state as PanelTimeRangeState).timeFrom).toBe('2h'); - }); - - it('should remove PanelTimeRange object on time options cleared', async () => { - const { vizPanelManager } = setupTest('panel-1'); - vizPanelManager.activate(); - await Promise.resolve(); - - const panel = vizPanelManager.state.panel; - - expect(panel.state.$timeRange).toBeUndefined(); - - vizPanelManager.changeQueryOptions({ - dataSource: { - name: 'grafana-testdata', - type: 'grafana-testdata-datasource', - default: true, - }, - queries: [], - timeRange: { - from: '1h', - }, - }); - - expect(panel.state.$timeRange).toBeInstanceOf(PanelTimeRange); - - vizPanelManager.changeQueryOptions({ - dataSource: { - name: 'grafana-testdata', - type: 'grafana-testdata-datasource', - default: true, - }, - queries: [], - timeRange: { - from: null, - }, - }); - - expect(panel.state.$timeRange).toBeUndefined(); - }); - }); - - describe('max data points and interval', () => { - it('should update max data points', async () => { - const { vizPanelManager } = setupTest('panel-1'); - vizPanelManager.activate(); - await Promise.resolve(); - - const dataObj = vizPanelManager.queryRunner; - - expect(dataObj.state.maxDataPoints).toBeUndefined(); - - vizPanelManager.changeQueryOptions({ - dataSource: { - name: 'grafana-testdata', - type: 'grafana-testdata-datasource', - default: true, - }, - queries: [], - maxDataPoints: 100, - }); - - jest.runAllTimers(); // The detect panel changes is debounced - expect(vizPanelManager.state.isDirty).toBe(true); - expect(dataObj.state.maxDataPoints).toBe(100); - }); - - it('should update min interval', async () => { - const { vizPanelManager } = setupTest('panel-1'); - vizPanelManager.activate(); - await Promise.resolve(); - - const dataObj = vizPanelManager.queryRunner; - - expect(dataObj.state.maxDataPoints).toBeUndefined(); - - vizPanelManager.changeQueryOptions({ - dataSource: { - name: 'grafana-testdata', - type: 'grafana-testdata-datasource', - default: true, - }, - queries: [], - minInterval: '1s', - }); - - jest.runAllTimers(); // The detect panel changes is debounced - expect(vizPanelManager.state.isDirty).toBe(true); - expect(dataObj.state.minInterval).toBe('1s'); - }); - }); - - describe('query caching', () => { - it('updates cacheTimeout and queryCachingTTL', async () => { - const { vizPanelManager } = setupTest('panel-1'); - vizPanelManager.activate(); - await Promise.resolve(); - - const dataObj = vizPanelManager.queryRunner; - - vizPanelManager.changeQueryOptions({ - cacheTimeout: '60', - queryCachingTTL: 200000, - dataSource: { - name: 'grafana-testdata', - type: 'grafana-testdata-datasource', - default: true, - }, - queries: [], - }); - - jest.runAllTimers(); // The detect panel changes is debounced - expect(vizPanelManager.state.isDirty).toBe(true); - expect(dataObj.state.cacheTimeout).toBe('60'); - expect(dataObj.state.queryCachingTTL).toBe(200000); - }); - }); - }); - - describe('query inspection', () => { - it('allows query inspection from the tab', async () => { - const { vizPanelManager } = setupTest('panel-1'); - vizPanelManager.inspectPanel(); - - expect(locationService.partial).toHaveBeenCalledWith({ inspect: 1, inspectTab: InspectTab.Query }); - }); - }); - - describe('data source change', () => { - it('changing from one plugin to another', async () => { - const { vizPanelManager } = setupTest('panel-1'); - vizPanelManager.activate(); - await Promise.resolve(); - - expect(vizPanelManager.queryRunner.state.datasource).toEqual({ - uid: 'gdev-testdata', - type: 'grafana-testdata-datasource', - }); - - await vizPanelManager.changePanelDataSource({ - name: 'grafana-prometheus', - type: 'grafana-prometheus-datasource', - uid: 'gdev-prometheus', - meta: { - name: 'Prometheus', - module: 'prometheus', - id: 'grafana-prometheus-datasource', - }, - } as DataSourceInstanceSettings); - - jest.runAllTimers(); // The detect panel changes is debounced - expect(vizPanelManager.state.isDirty).toBe(true); - expect(vizPanelManager.queryRunner.state.datasource).toEqual({ - uid: 'gdev-prometheus', - type: 'grafana-prometheus-datasource', - }); - }); - - it('changing from a plugin to a dashboard data source', async () => { - const { vizPanelManager } = setupTest('panel-1'); - vizPanelManager.activate(); - await Promise.resolve(); - - expect(vizPanelManager.queryRunner.state.datasource).toEqual({ - uid: 'gdev-testdata', - type: 'grafana-testdata-datasource', - }); - - await vizPanelManager.changePanelDataSource({ - name: SHARED_DASHBOARD_QUERY, - type: 'datasource', - uid: SHARED_DASHBOARD_QUERY, - meta: { - name: 'Prometheus', - module: 'prometheus', - id: DASHBOARD_DATASOURCE_PLUGIN_ID, - }, - } as DataSourceInstanceSettings); - - jest.runAllTimers(); // The detect panel changes is debounced - expect(vizPanelManager.state.isDirty).toBe(true); - expect(vizPanelManager.queryRunner.state.datasource).toEqual({ - uid: SHARED_DASHBOARD_QUERY, - type: 'datasource', - }); - }); - - it('changing from dashboard data source to a plugin', async () => { - const { vizPanelManager } = setupTest('panel-3'); - vizPanelManager.activate(); - await Promise.resolve(); - - expect(vizPanelManager.queryRunner.state.datasource).toEqual({ - uid: SHARED_DASHBOARD_QUERY, - type: 'datasource', - }); - - await vizPanelManager.changePanelDataSource({ - name: 'grafana-prometheus', - type: 'grafana-prometheus-datasource', - uid: 'gdev-prometheus', - meta: { - name: 'Prometheus', - module: 'prometheus', - id: 'grafana-prometheus-datasource', - }, - } as DataSourceInstanceSettings); - - jest.runAllTimers(); // The detect panel changes is debounced - expect(vizPanelManager.state.isDirty).toBe(true); - expect(vizPanelManager.queryRunner.state.datasource).toEqual({ - uid: 'gdev-prometheus', - type: 'grafana-prometheus-datasource', - }); - }); - }); - }); - - describe('change transformations', () => { - it('should update and reprocess transformations', () => { - const { scene, panel } = setupTest('panel-3'); - scene.setState({ editPanel: buildPanelEditScene(panel) }); - - const vizPanelManager = scene.state.editPanel!.state.vizManager; - vizPanelManager.activate(); - vizPanelManager.state.panel.state.$data?.activate(); - - const reprocessMock = jest.fn(); - vizPanelManager.dataTransformer.reprocessTransformations = reprocessMock; - vizPanelManager.changeTransformations([{ id: 'calculateField', options: {} }]); - - jest.runAllTimers(); // The detect panel changes is debounced - expect(vizPanelManager.state.isDirty).toBe(true); - expect(reprocessMock).toHaveBeenCalledTimes(1); - expect(vizPanelManager.dataTransformer.state.transformations).toEqual([{ id: 'calculateField', options: {} }]); - }); - }); - - describe('change queries', () => { - describe('plugin queries', () => { - it('should update queries', () => { - const { vizPanelManager } = setupTest('panel-1'); - - vizPanelManager.activate(); - vizPanelManager.state.panel.state.$data?.activate(); - - vizPanelManager.changeQueries([ - { - datasource: { - type: 'grafana-testdata-datasource', - uid: 'gdev-testdata', - }, - refId: 'A', - scenarioId: 'random_walk', - seriesCount: 5, - }, - ]); - - jest.runAllTimers(); // The detect panel changes is debounced - expect(vizPanelManager.state.isDirty).toBe(true); - expect(vizPanelManager.queryRunner.state.queries).toEqual([ - { - datasource: { - type: 'grafana-testdata-datasource', - uid: 'gdev-testdata', - }, - refId: 'A', - scenarioId: 'random_walk', - seriesCount: 5, - }, - ]); - }); - }); - - describe('dashboard queries', () => { - it('should update queries', () => { - const { scene, panel } = setupTest('panel-3'); - scene.setState({ editPanel: buildPanelEditScene(panel) }); - - const vizPanelManager = scene.state.editPanel!.state.vizManager; - vizPanelManager.activate(); - vizPanelManager.state.panel.state.$data?.activate(); - - // Changing dashboard query to a panel with transformations - vizPanelManager.changeQueries([ - { - refId: 'A', - datasource: { - type: DASHBOARD_DATASOURCE_PLUGIN_ID, - }, - panelId: panelWithTransformations.id, - }, - ]); - - expect(vizPanelManager.queryRunner.state.queries[0].panelId).toEqual(panelWithTransformations.id); - - // Changing dashboard query to a panel with queries only - vizPanelManager.changeQueries([ - { - refId: 'A', - datasource: { - type: DASHBOARD_DATASOURCE_PLUGIN_ID, - }, - panelId: panelWithQueriesOnly.id, - }, - ]); - - jest.runAllTimers(); // The detect panel changes is debounced - expect(vizPanelManager.state.isDirty).toBe(true); - expect(vizPanelManager.queryRunner.state.queries[0].panelId).toBe(panelWithQueriesOnly.id); - }); - }); - }); - - it('should load last used data source if no data source specified for a panel', async () => { - store.exists.mockReturnValue(true); - store.getObject.mockReturnValue({ - dashboardUid: 'ffbe00e2-803c-4d49-adb7-41aad336234f', - datasourceUid: 'gdev-testdata', - }); - const { scene, panel } = setupTest('panel-5'); - scene.setState({ editPanel: buildPanelEditScene(panel) }); - - const vizPanelManager = scene.state.editPanel!.state.vizManager; - vizPanelManager.activate(); - await Promise.resolve(); - - expect(vizPanelManager.state.datasource).toEqual(ds1Mock); - expect(vizPanelManager.state.dsSettings).toEqual(instance1SettingsMock); - }); - - it('Should default to the first variable value if panel is repeated', async () => { - const { scene, panel } = setupTest('panel-10'); - - scene.setState({ - $variables: new SceneVariableSet({ - variables: [ - new CustomVariable({ name: 'custom', query: 'A,B,C', value: ['A', 'B', 'C'], text: ['A', 'B', 'C'] }), - ], - }), - }); - - scene.setState({ editPanel: buildPanelEditScene(panel) }); - - const vizPanelManager = scene.state.editPanel!.state.vizManager; - vizPanelManager.activate(); - - const variable = sceneGraph.lookupVariable('custom', vizPanelManager); - expect(variable?.getValue()).toBe('A'); - }); - - describe('Given a panel inside repeated row', () => { - it('Should include row variable scope', () => { - const { panel } = setupTest('panel-9'); - - const row = panel.parent?.parent; - if (!(row instanceof SceneGridRow)) { - throw new Error('Did not find parent row'); - } - - row.setState({ - $variables: new SceneVariableSet({ variables: [new LocalValueVariable({ name: 'hello', value: 'A' })] }), - }); - - const editor = buildPanelEditScene(panel); - const variable = sceneGraph.lookupVariable('hello', editor.state.vizManager); - expect(variable?.getValue()).toBe('A'); - }); - }); -}); - -const setupTest = (panelId: string) => { - const scene = transformSaveModelToScene({ dashboard: testDashboard, meta: {} }); - - const panel = findVizPanelByKey(scene, panelId)!; - - const vizPanelManager = VizPanelManager.createFor(panel); - // The following happens on DahsboardScene activation. For the needs of this test this activation aint needed hence we hand-call it - // @ts-expect-error - getDashboardSrv().setCurrent(new DashboardModelCompatibilityWrapper(scene)); - - return { vizPanelManager, scene, panel }; -}; diff --git a/public/app/features/dashboard-scene/panel-edit/VizPanelManager.tsx b/public/app/features/dashboard-scene/panel-edit/VizPanelManager.tsx deleted file mode 100644 index c27ed98146c..00000000000 --- a/public/app/features/dashboard-scene/panel-edit/VizPanelManager.tsx +++ /dev/null @@ -1,504 +0,0 @@ -import { css } from '@emotion/css'; -import { debounce } from 'lodash'; -import { useEffect } from 'react'; - -import { - DataSourceApi, - DataSourceInstanceSettings, - FieldConfigSource, - GrafanaTheme2, - filterFieldConfigOverrides, - getDataSourceRef, - isStandardFieldProp, - restoreCustomOverrideRules, -} from '@grafana/data'; -import { config, getDataSourceSrv, locationService } from '@grafana/runtime'; -import { - DeepPartial, - LocalValueVariable, - MultiValueVariable, - PanelBuilders, - SceneComponentProps, - SceneDataTransformer, - SceneObjectBase, - SceneObjectRef, - SceneObjectState, - SceneObjectStateChangedEvent, - SceneQueryRunner, - SceneVariableSet, - SceneVariables, - VizPanel, - sceneGraph, -} from '@grafana/scenes'; -import { DataQuery, DataTransformerConfig, Panel } from '@grafana/schema'; -import { useStyles2 } from '@grafana/ui'; -import { getLastUsedDatasourceFromStorage } from 'app/features/dashboard/utils/dashboard'; -import { storeLastUsedDataSourceInLocalStorage } from 'app/features/datasources/components/picker/utils'; -import { saveLibPanel } from 'app/features/library-panels/state/api'; -import { updateQueries } from 'app/features/query/state/updateQueries'; -import { GrafanaQuery } from 'app/plugins/datasource/grafana/types'; -import { QueryGroupOptions } from 'app/types'; - -import { DashboardSceneChangeTracker } from '../saving/DashboardSceneChangeTracker'; -import { getPanelChanges } from '../saving/getDashboardChanges'; -import { DashboardGridItem, RepeatDirection } from '../scene/DashboardGridItem'; -import { PanelTimeRange, PanelTimeRangeState } from '../scene/PanelTimeRange'; -import { gridItemToPanel, vizPanelToPanel } from '../serialization/transformSceneToSaveModel'; -import { - getDashboardSceneFor, - getMultiVariableValues, - getPanelIdForVizPanel, - getQueryRunnerFor, - isLibraryPanel, -} from '../utils/utils'; - -export interface VizPanelManagerState extends SceneObjectState { - panel: VizPanel; - sourcePanel: SceneObjectRef; - pluginId: string; - datasource?: DataSourceApi; - dsSettings?: DataSourceInstanceSettings; - tableView?: VizPanel; - repeat?: string; - repeatDirection?: RepeatDirection; - maxPerRow?: number; - isDirty?: boolean; -} - -export enum DisplayMode { - Fill = 0, - Fit = 1, - Exact = 2, -} - -// VizPanelManager serves as an API to manipulate VizPanel state from the outside. It allows panel type, options and data manipulation. -export class VizPanelManager extends SceneObjectBase { - private _cachedPluginOptions: Record< - string, - { options: DeepPartial<{}>; fieldConfig: FieldConfigSource> } | undefined - > = {}; - - public constructor(state: VizPanelManagerState) { - super(state); - this.addActivationHandler(() => this._onActivate()); - } - - /** - * Will clone the source panel and move the data provider to - * live on the VizPanelManager level instead of the VizPanel level - */ - public static createFor(sourcePanel: VizPanel) { - let repeatOptions: Pick = {}; - - const gridItem = sourcePanel.parent; - - if (!(gridItem instanceof DashboardGridItem)) { - console.error('VizPanel is not a child of a dashboard grid item'); - throw new Error('VizPanel is not a child of a dashboard grid item'); - } - - const { variableName: repeat, repeatDirection, maxPerRow } = gridItem.state; - repeatOptions = { repeat, repeatDirection, maxPerRow }; - - let variables: SceneVariables | undefined; - - if (gridItem.parent?.state.$variables) { - variables = gridItem.parent.state.$variables.clone(); - } - - if (repeatOptions.repeat) { - const variable = sceneGraph.lookupVariable(repeatOptions.repeat, gridItem); - - if (variable instanceof MultiValueVariable && variable.state.value.length) { - const { values, texts } = getMultiVariableValues(variable); - - const varWithDefaultValue = new LocalValueVariable({ - name: variable.state.name, - value: values[0], - text: String(texts[0]), - }); - - if (!variables) { - variables = new SceneVariableSet({ - variables: [varWithDefaultValue], - }); - } else { - variables.setState({ variables: [varWithDefaultValue] }); - } - } - } - - return new VizPanelManager({ - $variables: variables, - panel: sourcePanel.clone(), - sourcePanel: sourcePanel.getRef(), - pluginId: sourcePanel.state.pluginId, - ...repeatOptions, - }); - } - - private _onActivate() { - this.loadDataSource(); - const changesSub = this.subscribeToEvent(SceneObjectStateChangedEvent, this._handleStateChange); - - return () => { - changesSub.unsubscribe(); - }; - } - - private _detectPanelModelChanges = debounce(() => { - const { hasChanges } = getPanelChanges( - vizPanelToPanel(this.state.sourcePanel.resolve().clone({ $behaviors: undefined })), - vizPanelToPanel(this.state.panel.clone({ $behaviors: undefined })) - ); - this.setState({ isDirty: hasChanges }); - }, 250); - - private _handleStateChange = (event: SceneObjectStateChangedEvent) => { - if (DashboardSceneChangeTracker.isUpdatingPersistedState(event)) { - this._detectPanelModelChanges(); - } - }; - - private async loadDataSource() { - const dataObj = this.state.panel.state.$data; - - if (!dataObj) { - return; - } - - let datasourceToLoad = this.queryRunner.state.datasource; - - try { - let datasource: DataSourceApi | undefined; - let dsSettings: DataSourceInstanceSettings | undefined; - - if (!datasourceToLoad) { - const dashboardScene = getDashboardSceneFor(this); - const dashboardUid = dashboardScene.state.uid ?? ''; - const lastUsedDatasource = getLastUsedDatasourceFromStorage(dashboardUid!); - - // do we have a last used datasource for this dashboard - if (lastUsedDatasource?.datasourceUid !== null) { - // get datasource from dashbopard uid - dsSettings = getDataSourceSrv().getInstanceSettings({ uid: lastUsedDatasource?.datasourceUid }); - if (dsSettings) { - datasource = await getDataSourceSrv().get({ - uid: lastUsedDatasource?.datasourceUid, - type: dsSettings.type, - }); - - this.queryRunner.setState({ - datasource: { - ...getDataSourceRef(dsSettings), - uid: lastUsedDatasource?.datasourceUid, - }, - }); - } - } - } else { - datasource = await getDataSourceSrv().get(datasourceToLoad); - dsSettings = getDataSourceSrv().getInstanceSettings(datasourceToLoad); - } - - if (datasource && dsSettings) { - this.setState({ datasource, dsSettings }); - - storeLastUsedDataSourceInLocalStorage(getDataSourceRef(dsSettings) || { default: true }); - } - } catch (err) { - //set default datasource if we fail to load the datasource - const datasource = await getDataSourceSrv().get(config.defaultDatasource); - const dsSettings = getDataSourceSrv().getInstanceSettings(config.defaultDatasource); - - if (datasource && dsSettings) { - this.setState({ - datasource, - dsSettings, - }); - - this.queryRunner.setState({ - datasource: getDataSourceRef(dsSettings), - }); - } - - console.error(err); - } - } - - public changePluginType(pluginId: string) { - const { options: prevOptions, fieldConfig: prevFieldConfig, pluginId: prevPluginId } = this.state.panel.state; - - // clear custom options - let newFieldConfig: FieldConfigSource = { - defaults: { - ...prevFieldConfig.defaults, - custom: {}, - }, - overrides: filterFieldConfigOverrides(prevFieldConfig.overrides, isStandardFieldProp), - }; - - this._cachedPluginOptions[prevPluginId] = { options: prevOptions, fieldConfig: prevFieldConfig }; - - const cachedOptions = this._cachedPluginOptions[pluginId]?.options; - const cachedFieldConfig = this._cachedPluginOptions[pluginId]?.fieldConfig; - - if (cachedFieldConfig) { - newFieldConfig = restoreCustomOverrideRules(newFieldConfig, cachedFieldConfig); - } - - // When changing from non-data to data panel, we need to add a new data provider - if (!this.state.panel.state.$data && !config.panels[pluginId].skipDataQuery) { - let ds = getLastUsedDatasourceFromStorage(getDashboardSceneFor(this).state.uid!)?.datasourceUid; - - if (!ds) { - ds = config.defaultDatasource; - } - - this.state.panel.setState({ - $data: new SceneDataTransformer({ - $data: new SceneQueryRunner({ - datasource: { - uid: ds, - }, - queries: [{ refId: 'A' }], - }), - transformations: [], - }), - }); - } - - this.setState({ pluginId }); - this.state.panel.changePluginType(pluginId, cachedOptions, newFieldConfig); - - this.loadDataSource(); - } - - public async changePanelDataSource( - newSettings: DataSourceInstanceSettings, - defaultQueries?: DataQuery[] | GrafanaQuery[] - ) { - const { dsSettings } = this.state; - const queryRunner = this.queryRunner; - - const currentDS = dsSettings ? await getDataSourceSrv().get({ uid: dsSettings.uid }) : undefined; - const nextDS = await getDataSourceSrv().get({ uid: newSettings.uid }); - - const currentQueries = queryRunner.state.queries; - - // We need to pass in newSettings.uid as well here as that can be a variable expression and we want to store that in the query model not the current ds variable value - const queries = defaultQueries || (await updateQueries(nextDS, newSettings.uid, currentQueries, currentDS)); - - queryRunner.setState({ - datasource: getDataSourceRef(newSettings), - queries, - }); - if (defaultQueries) { - queryRunner.runQueries(); - } - - this.loadDataSource(); - } - - public changeQueryOptions(options: QueryGroupOptions) { - const panelObj = this.state.panel; - const dataObj = this.queryRunner; - const timeRangeObj = panelObj.state.$timeRange; - - const dataObjStateUpdate: Partial = {}; - const timeRangeObjStateUpdate: Partial = {}; - - if (options.maxDataPoints !== dataObj.state.maxDataPoints) { - dataObjStateUpdate.maxDataPoints = options.maxDataPoints ?? undefined; - } - - if (options.minInterval !== dataObj.state.minInterval && options.minInterval !== null) { - dataObjStateUpdate.minInterval = options.minInterval; - } - - if (options.timeRange) { - timeRangeObjStateUpdate.timeFrom = options.timeRange.from ?? undefined; - timeRangeObjStateUpdate.timeShift = options.timeRange.shift ?? undefined; - timeRangeObjStateUpdate.hideTimeOverride = options.timeRange.hide; - } - - if (timeRangeObj instanceof PanelTimeRange) { - if (timeRangeObjStateUpdate.timeFrom !== undefined || timeRangeObjStateUpdate.timeShift !== undefined) { - // update time override - timeRangeObj.setState(timeRangeObjStateUpdate); - } else { - // remove time override - panelObj.setState({ $timeRange: undefined }); - } - } else { - // no time override present on the panel, let's create one first - panelObj.setState({ $timeRange: new PanelTimeRange(timeRangeObjStateUpdate) }); - } - - if (options.cacheTimeout !== dataObj?.state.cacheTimeout) { - dataObjStateUpdate.cacheTimeout = options.cacheTimeout; - } - - if (options.queryCachingTTL !== dataObj?.state.queryCachingTTL) { - dataObjStateUpdate.queryCachingTTL = options.queryCachingTTL; - } - - dataObj.setState(dataObjStateUpdate); - dataObj.runQueries(); - } - - public changeQueries(queries: T[]) { - const runner = this.queryRunner; - runner.setState({ queries }); - } - - public changeTransformations(transformations: DataTransformerConfig[]) { - const dataprovider = this.dataTransformer; - dataprovider.setState({ transformations }); - dataprovider.reprocessTransformations(); - } - - public inspectPanel() { - const panel = this.state.panel; - const panelId = getPanelIdForVizPanel(panel); - - locationService.partial({ - inspect: panelId, - inspectTab: 'query', - }); - } - - get queryRunner(): SceneQueryRunner { - // Panel data object is always SceneQueryRunner wrapped in a SceneDataTransformer - const runner = getQueryRunnerFor(this.state.panel); - - if (!runner) { - throw new Error('Query runner not found'); - } - - return runner; - } - - get dataTransformer(): SceneDataTransformer { - const provider = this.state.panel.state.$data; - if (!provider || !(provider instanceof SceneDataTransformer)) { - throw new Error('Could not find SceneDataTransformer for panel'); - } - return provider; - } - - public toggleTableView() { - if (this.state.tableView) { - this.setState({ tableView: undefined }); - return; - } - - this.setState({ - tableView: PanelBuilders.table() - .setTitle('') - .setOption('showTypeIcons', true) - .setOption('showHeader', true) - // Here we are breaking a scene rule and changing the parent of the main panel data provider - // But we need to share this same instance as the queries tab is subscribing to it - .setData(this.dataTransformer) - .build(), - }); - } - - public unlinkLibraryPanel() { - const sourcePanel = this.state.sourcePanel.resolve(); - if (!isLibraryPanel(sourcePanel)) { - throw new Error('VizPanel is not a library panel'); - } - - const gridItem = sourcePanel.parent; - - if (!(gridItem instanceof DashboardGridItem)) { - throw new Error('Library panel not a child of a grid item'); - } - - const newSourcePanel = this.state.panel.clone({ $data: sourcePanel.state.$data?.clone(), $behaviors: undefined }); - gridItem.setState({ - body: newSourcePanel, - }); - - this.state.panel.setState({ $behaviors: undefined }); - this.setState({ sourcePanel: newSourcePanel.getRef() }); - } - - public commitChanges() { - const sourcePanel = this.state.sourcePanel.resolve(); - this.commitChangesTo(sourcePanel); - } - - public commitChangesTo(sourcePanel: VizPanel) { - const repeatUpdate = { - variableName: this.state.repeat, - repeatDirection: this.state.repeatDirection, - maxPerRow: this.state.maxPerRow, - }; - - const vizPanel = this.state.panel.clone(); - - if (sourcePanel.parent instanceof DashboardGridItem) { - sourcePanel.parent.setState({ - ...repeatUpdate, - body: vizPanel, - }); - } - - if (isLibraryPanel(vizPanel)) { - saveLibPanel(vizPanel); - } - } - - /** - * Used from inspect json tab to view the current persisted model - */ - public getPanelSaveModel(): Panel | object { - const sourcePanel = this.state.sourcePanel.resolve(); - const gridItem = sourcePanel.parent; - - if (!(gridItem instanceof DashboardGridItem)) { - return { error: 'Unsupported panel parent' }; - } - - const parentClone = gridItem.clone({ - body: this.state.panel.clone(), - }); - - return gridItemToPanel(parentClone); - } - - public setPanelTitle(newTitle: string) { - this.state.panel.setState({ title: newTitle, hoverHeader: newTitle === '' }); - } - - public static Component = ({ model }: SceneComponentProps) => { - const { panel, tableView } = model.useState(); - const styles = useStyles2(getStyles); - const panelToShow = tableView ?? panel; - const dataProvider = panelToShow.state.$data; - - // This is to preserve SceneQueryRunner stays alive when switching between visualizations and table view - useEffect(() => { - return dataProvider?.activate(); - }, [dataProvider]); - - return ( - <> -
    {}
    - - ); - }; -} - -function getStyles(theme: GrafanaTheme2) { - return { - wrapper: css({ - height: '100%', - width: '100%', - paddingLeft: theme.spacing(2), - }), - }; -} diff --git a/public/app/features/dashboard-scene/panel-edit/getPanelFrameOptions.tsx b/public/app/features/dashboard-scene/panel-edit/getPanelFrameOptions.tsx index 7762bdc2965..0f2a8961278 100644 --- a/public/app/features/dashboard-scene/panel-edit/getPanelFrameOptions.tsx +++ b/public/app/features/dashboard-scene/panel-edit/getPanelFrameOptions.tsx @@ -1,8 +1,8 @@ import { SelectableValue } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { config } from '@grafana/runtime'; -import { VizPanel } from '@grafana/scenes'; -import { RadioButtonGroup, Select, DataLinksInlineEditor, Input, TextArea, Switch } from '@grafana/ui'; +import { SceneObjectState, VizPanel } from '@grafana/scenes'; +import { DataLinksInlineEditor, Input, TextArea, Switch, RadioButtonGroup, Select } from '@grafana/ui'; import { GenAIPanelDescriptionButton } from 'app/features/dashboard/components/GenAI/GenAIPanelDescriptionButton'; import { GenAIPanelTitleButton } from 'app/features/dashboard/components/GenAI/GenAIPanelTitleButton'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; @@ -10,17 +10,15 @@ import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/Pan import { RepeatRowSelect2 } from 'app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect'; import { getPanelLinksVariableSuggestions } from 'app/features/panel/panellinks/link_srv'; +import { DashboardGridItem } from '../scene/DashboardGridItem'; import { VizPanelLinks } from '../scene/PanelLinks'; import { vizPanelToPanel, transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel'; import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; import { getDashboardSceneFor } from '../utils/utils'; -import { VizPanelManager, VizPanelManagerState } from './VizPanelManager'; - export function getPanelFrameCategory2( - vizManager: VizPanelManager, panel: VizPanel, - repeat?: string + layoutElementState: SceneObjectState ): OptionsPaneCategoryDescriptor { const descriptor = new OptionsPaneCategoryDescriptor({ title: 'Panel options', @@ -31,19 +29,20 @@ export function getPanelFrameCategory2( const panelLinksObject = dashboardSceneGraph.getPanelLinks(panel); const links = panelLinksObject?.state.rawLinks ?? []; const dashboard = getDashboardSceneFor(panel); + const layoutElement = panel.parent; - return descriptor + descriptor .addItem( new OptionsPaneItemDescriptor({ title: 'Title', value: panel.state.title, popularRank: 1, render: function renderTitle() { - return ; + return ; }, addon: config.featureToggles.dashgpt && ( vizManager.setPanelTitle(title)} + onGenerate={(title) => setPanelTitle(panel, title)} panel={vizPanelToPanel(panel)} dashboard={transformSceneToSaveModel(dashboard)} /> @@ -95,73 +94,77 @@ export function getPanelFrameCategory2( render: () => , }) ) - ) - .addCategory( - new OptionsPaneCategoryDescriptor({ - title: 'Repeat options', - id: 'Repeat options', - isOpenDefault: false, - }) - .addItem( - new OptionsPaneItemDescriptor({ - title: 'Repeat by variable', - description: - 'Repeat this panel for each value in the selected variable. This is not visible while in edit mode. You need to go back to dashboard and then update the variable or reload the dashboard.', - render: function renderRepeatOptions() { - return ( - { - const stateUpdate: Partial = { repeat: value }; - if (value && !vizManager.state.repeatDirection) { - stateUpdate.repeatDirection = 'h'; - } - vizManager.setState(stateUpdate); - }} - /> - ); - }, - }) - ) - .addItem( - new OptionsPaneItemDescriptor({ - title: 'Repeat direction', - showIf: () => !!vizManager.state.repeat, - render: function renderRepeatOptions() { - const directionOptions: Array> = [ - { label: 'Horizontal', value: 'h' }, - { label: 'Vertical', value: 'v' }, - ]; - - return ( - vizManager.setState({ repeatDirection: value })} - /> - ); - }, - }) - ) - .addItem( - new OptionsPaneItemDescriptor({ - title: 'Max per row', - showIf: () => Boolean(vizManager.state.repeat && vizManager.state.repeatDirection === 'h'), - render: function renderOption() { - const maxPerRowOptions = [2, 3, 4, 6, 8, 12].map((value) => ({ label: value.toString(), value })); - return ( - gridItem.setState({ maxPerRow: value.value })} + /> + ); + }, + }) + ); + + descriptor.addCategory(category); + } + + return descriptor; } interface ScenePanelLinksEditorProps { @@ -181,14 +184,14 @@ function ScenePanelLinksEditor({ panelLinks }: ScenePanelLinksEditorProps) { ); } -function PanelFrameTitle({ vizManager }: { vizManager: VizPanelManager }) { - const { title } = vizManager.state.panel.useState(); +function PanelFrameTitle({ panel }: { panel: VizPanel }) { + const { title } = panel.useState(); return ( vizManager.setPanelTitle(e.currentTarget.value)} + onChange={(e) => setPanelTitle(panel, e.currentTarget.value)} /> ); } @@ -204,3 +207,7 @@ function DescriptionTextArea({ panel }: { panel: VizPanel }) { /> ); } + +function setPanelTitle(panel: VizPanel, title: string) { + panel.setState({ title: title, hoverHeader: title === '' }); +} diff --git a/public/app/features/dashboard-scene/saving/DashboardPrompt.tsx b/public/app/features/dashboard-scene/saving/DashboardPrompt.tsx index e841f7a47ba..94d1655686c 100644 --- a/public/app/features/dashboard-scene/saving/DashboardPrompt.tsx +++ b/public/app/features/dashboard-scene/saving/DashboardPrompt.tsx @@ -40,19 +40,12 @@ export const DashboardPrompt = memo(({ dashboard }: DashboardPromptProps) => { }, [dashboard]); const onHistoryBlock = (location: H.Location) => { - const panelInEdit = dashboard.state.editPanel; - const vizPanelManager = panelInEdit?.state.vizManager; - const vizPanel = vizPanelManager?.state.panel; + const panelEditor = dashboard.state.editPanel; + const vizPanel = panelEditor?.getPanel(); const search = new URLSearchParams(location.search); // Are we leaving panel edit & library panel? - if ( - panelInEdit && - vizPanel && - isLibraryPanel(vizPanel) && - vizPanelManager.state.isDirty && - !search.has('editPanel') - ) { + if (panelEditor && vizPanel && isLibraryPanel(vizPanel) && panelEditor.state.isDirty && !search.has('editPanel')) { const libPanelBehavior = getLibraryPanelBehavior(vizPanel); showModal(SaveLibraryVizPanelModal, { @@ -60,12 +53,12 @@ export const DashboardPrompt = memo(({ dashboard }: DashboardPromptProps) => { isUnsavedPrompt: true, libraryPanel: libPanelBehavior!, onConfirm: () => { - panelInEdit.onConfirmSaveLibraryPanel(); + panelEditor.onConfirmSaveLibraryPanel(); hideModal(); moveToBlockedLocationAfterReactStateUpdate(location); }, onDiscard: () => { - panelInEdit.onDiscard(); + panelEditor.onDiscard(); hideModal(); moveToBlockedLocationAfterReactStateUpdate(location); }, diff --git a/public/app/features/dashboard-scene/saving/DashboardSceneChangeTracker.ts b/public/app/features/dashboard-scene/saving/DashboardSceneChangeTracker.ts index d99c4132687..d7b5cf74ff8 100644 --- a/public/app/features/dashboard-scene/saving/DashboardSceneChangeTracker.ts +++ b/public/app/features/dashboard-scene/saving/DashboardSceneChangeTracker.ts @@ -14,7 +14,6 @@ import { } from '@grafana/scenes'; import { createWorker } from 'app/features/dashboard-scene/saving/createDetectChangesWorker'; -import { VizPanelManager } from '../panel-edit/VizPanelManager'; import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer'; import { DashboardControls } from '../scene/DashboardControls'; import { DashboardGridItem } from '../scene/DashboardGridItem'; @@ -43,7 +42,6 @@ export class DashboardSceneChangeTracker { } // Any change in the panel should trigger a change detection - // The VizPanelManager includes configuration for the panel like repeat // The PanelTimeRange includes the overrides configuration if ( payload.changedObject instanceof VizPanel || @@ -52,16 +50,6 @@ export class DashboardSceneChangeTracker { ) { return true; } - // VizPanelManager includes the repeat configuration - if (payload.changedObject instanceof VizPanelManager) { - if ( - Object.prototype.hasOwnProperty.call(payload.partialUpdate, 'repeat') || - Object.prototype.hasOwnProperty.call(payload.partialUpdate, 'repeatDirection') || - Object.prototype.hasOwnProperty.call(payload.partialUpdate, 'maxPerRow') - ) { - return true; - } - } // SceneQueryRunner includes the DS configuration if (payload.changedObject instanceof SceneQueryRunner) { if (!Object.prototype.hasOwnProperty.call(payload.partialUpdate, 'data')) { diff --git a/public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx b/public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx index 9232b5e9c68..84012e2f694 100644 --- a/public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx +++ b/public/app/features/dashboard-scene/saving/SaveDashboardAsForm.tsx @@ -48,6 +48,7 @@ export function SaveDashboardAsForm({ dashboard, changeInfo }: Props) { const { state, onSaveDashboard } = useSaveDashboard(false); const [contentSent, setContentSent] = useState<{ title?: string; folderUid?: string }>({}); + const [hasFolderChanged, setHasFolderChanged] = useState(false); const onSave = async (overwrite: boolean) => { const data = getValues(); @@ -70,10 +71,10 @@ export function SaveDashboardAsForm({ dashboard, changeInfo }: Props) { ); - const saveButton = (overwrite: boolean) => ( - - ); - + const saveButton = (overwrite: boolean) => { + const showSaveButton = !isValid && hasFolderChanged ? true : isValid; + return ; + }; function renderFooter(error?: Error) { const formValuesMatchContentSent = formValues.title.trim() === contentSent.title && formValues.folder.uid === contentSent.folderUid; @@ -127,6 +128,8 @@ export function SaveDashboardAsForm({ dashboard, changeInfo }: Props) { { setValue('folder', { uid, title }); + const folderUid = dashboard.state.meta.folderUid; + setHasFolderChanged(uid !== folderUid); }} // Old folder picker fields value={formValues.folder?.uid} diff --git a/public/app/features/dashboard-scene/saving/getDashboardChangesFromScene.test.ts b/public/app/features/dashboard-scene/saving/getDashboardChangesFromScene.test.ts index d14ae73ed3a..3a62712b70d 100644 --- a/public/app/features/dashboard-scene/saving/getDashboardChangesFromScene.test.ts +++ b/public/app/features/dashboard-scene/saving/getDashboardChangesFromScene.test.ts @@ -237,8 +237,7 @@ describe('getDashboardChangesFromScene', () => { dashboard.onEnterEditMode(); dashboard.setState({ editPanel: editScene }); - editScene.state.vizManager.state.panel.setState({ title: 'changed title' }); - editScene.commitChanges(); + editScene.state.panelRef.resolve().setState({ title: 'changed title' }); const result = getDashboardChangesFromScene(dashboard, false, true); const panelSaveModel = result.changedSaveModel.panels![0]; diff --git a/public/app/features/dashboard-scene/scene/DashboardDatasourceBehaviour.test.tsx b/public/app/features/dashboard-scene/scene/DashboardDatasourceBehaviour.test.tsx index ce5d1f04606..0be50bd2ecc 100644 --- a/public/app/features/dashboard-scene/scene/DashboardDatasourceBehaviour.test.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardDatasourceBehaviour.test.tsx @@ -11,11 +11,10 @@ import { } from '@grafana/data'; import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks'; import { setPluginImportUtils } from '@grafana/runtime'; -import { SceneDataTransformer, SceneGridLayout, SceneQueryRunner, VizPanel } from '@grafana/scenes'; +import { SceneDataTransformer, SceneFlexLayout, SceneGridLayout, SceneQueryRunner, VizPanel } from '@grafana/scenes'; import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard'; import { DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plugins/datasource/dashboard/types'; -import { VizPanelManager } from '../panel-edit/VizPanelManager'; import { activateFullSceneTree } from '../utils/test-utils'; import { DashboardDatasourceBehaviour } from './DashboardDatasourceBehaviour'; @@ -275,14 +274,12 @@ describe('DashboardDatasourceBehaviour', () => { // spy on runQueries const spy = jest.spyOn(dashboardDSPanel.state.$data!.state.$data as SceneQueryRunner, 'runQueries'); - const vizPanelManager = new VizPanelManager({ - panel: dashboardDSPanel.clone(), + const scene = new SceneFlexLayout({ $data: dashboardDSPanel.state.$data?.clone(), - sourcePanel: dashboardDSPanel.getRef(), - pluginId: dashboardDSPanel.state.pluginId, + children: [], }); - vizPanelManager.activate(); + scene.activate(); expect(spy).not.toHaveBeenCalled(); }); diff --git a/public/app/features/dashboard-scene/scene/DashboardGridItem.test.tsx b/public/app/features/dashboard-scene/scene/DashboardGridItem.test.tsx index e6d202b0801..de74e9934a7 100644 --- a/public/app/features/dashboard-scene/scene/DashboardGridItem.test.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardGridItem.test.tsx @@ -143,8 +143,8 @@ describe('PanelRepeaterGridItem', () => { await new Promise((r) => setTimeout(r, 10)); vizPanel.setState({ title: 'Changed' }); - //mimic returning to dashboard from panel edit cloning panel - panel.setState({ body: vizPanel.clone() }); + + panel.editingCompleted(true); // mimic returning to dashboard activateFullSceneTree(scene); @@ -214,10 +214,11 @@ describe('PanelRepeaterGridItem', () => { await new Promise((r) => setTimeout(r, 10)); vizPanel.setState({ title: 'Changed' }); - //mimic returning to dashboard from panel edit cloning panel - panel.setState({ body: vizPanel.clone() }); + + panel.editingCompleted(true); const performRepeatMock = jest.spyOn(panel, 'performRepeat'); + // mimic returning to dashboard activateFullSceneTree(scene); diff --git a/public/app/features/dashboard-scene/scene/DashboardGridItem.tsx b/public/app/features/dashboard-scene/scene/DashboardGridItem.tsx index 97e44cef66b..5845f40e03d 100644 --- a/public/app/features/dashboard-scene/scene/DashboardGridItem.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardGridItem.tsx @@ -21,7 +21,7 @@ import { SceneVariable, SceneVariableDependencyConfigLike, } from '@grafana/scenes'; -import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN } from 'app/core/constants'; +import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants'; import { getMultiVariableValues, getQueryRunnerFor } from '../utils/utils'; @@ -41,7 +41,6 @@ export type RepeatDirection = 'v' | 'h'; export class DashboardGridItem extends SceneObjectBase implements SceneGridItemLike { private _prevRepeatValues?: VariableValueSingle[]; - private _oldBody?: VizPanel; protected _variableDependency = new DashboardGridItemVariableDependencyHandler(this); @@ -54,10 +53,6 @@ export class DashboardGridItem extends SceneObjectBase i private _activationHandler() { if (this.state.variableName) { this._subs.add(this.subscribeToState((newState, prevState) => this._handleGridResize(newState, prevState))); - if (this._oldBody !== this.state.body) { - this._prevRepeatValues = undefined; - } - this.performRepeat(); } } @@ -116,9 +111,6 @@ export class DashboardGridItem extends SceneObjectBase i return; } - this._oldBody = this.state.body; - this._prevRepeatValues = values; - const panelToRepeat = this.state.body; const repeatedPanels: VizPanel[] = []; @@ -178,10 +170,56 @@ export class DashboardGridItem extends SceneObjectBase i } } + this._prevRepeatValues = values; + // Used from dashboard url sync this.publishEvent(new DashboardRepeatsProcessedEvent({ source: this }), true); } + public setRepeatByVariable(variableName: string | undefined) { + const stateUpdate: Partial = { variableName }; + + if (variableName && !this.state.repeatDirection) { + stateUpdate.repeatDirection = 'h'; + } + + if (this.state.body.state.$variables) { + this.state.body.setState({ $variables: undefined }); + } + + this.setState(stateUpdate); + } + + /** + * Logic to prep panel for panel edit + */ + public editingStarted() { + if (!this.state.variableName) { + return; + } + + if (this.state.repeatedPanels?.length ?? 0 > 1) { + this.state.body.setState({ + $variables: this.state.repeatedPanels![0].state.$variables?.clone(), + $data: this.state.repeatedPanels![0].state.$data?.clone(), + }); + } + } + + /** + * Going back to dashboards logic + * withChanges true if there where changes made while in panel edit + */ + public editingCompleted(withChanges: boolean) { + if (withChanges) { + this._prevRepeatValues = undefined; + } + + if (this.state.variableName && this.state.repeatDirection === 'h' && this.state.width !== GRID_COLUMN_COUNT) { + this.setState({ width: GRID_COLUMN_COUNT }); + } + } + public notifyRepeatedPanelsWaitingForVariables(variable: SceneVariable) { for (const panel of this.state.repeatedPanels ?? []) { const queryRunner = getQueryRunnerFor(panel); diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx index c2a2ba61966..3df3a8f73e6 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.test.tsx @@ -18,7 +18,7 @@ import { LS_PANEL_COPY_KEY } from 'app/core/constants'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { VariablesChanged } from 'app/features/variables/types'; -import { PanelEditor, buildPanelEditScene } from '../panel-edit/PanelEditor'; +import { buildPanelEditScene } from '../panel-edit/PanelEditor'; import { createWorker } from '../saving/createDetectChangesWorker'; import { buildGridItemForPanel, transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; import { DecoratedRevisionModel } from '../settings/VersionsEditView'; @@ -197,18 +197,16 @@ describe('DashboardScene', () => { expect(resoredLayout.state.children.map((c) => c.state.key)).toEqual(originalPanelOrder); }); - it('Should exit edit mode and discard panel changes if leaving the dashboard while in panel edit', () => { - const panel = findVizPanelByKey(scene, 'panel-1'); + it('Should exit edit mode and discard panel changes if leaving the dashboard while in panel edit', async () => { + const panel = findVizPanelByKey(scene, 'panel-1')!; const editPanel = buildPanelEditScene(panel!); - scene.setState({ - editPanel, - }); - - expect(scene.state.editPanel!['_discardChanges']).toBe(false); + scene.setState({ editPanel }); + panel.setState({ title: 'new title' }); scene.exitEditMode({ skipConfirm: true }); - expect(scene.state.editPanel!['_discardChanges']).toBe(true); + const discardPanel = findVizPanelByKey(scene, panel.state.key)!; + expect(discardPanel.state.title).toBe('Panel A'); }); it.each` @@ -1023,14 +1021,14 @@ describe('DashboardScene', () => { panelPluginId: 'table', }); }); + test('when editing', () => { const panel = findVizPanelByKey(scene, 'panel-1'); const editPanel = buildPanelEditScene(panel!); - scene.setState({ - editPanel, - }); + scene.setState({ editPanel }); + + const queryRunner = editPanel.getPanel().state.$data!; - const queryRunner = (scene.state.editPanel as PanelEditor).state.vizManager.queryRunner; expect(scene.enrichDataRequest(queryRunner)).toEqual({ app: CoreApp.Dashboard, dashboardUID: 'dash-1', diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index 48424ea2b1a..6c1bffe482a 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -252,6 +252,7 @@ export class DashboardScene extends SceneObjectBase { }; this._changeTracker.stopTrackingChanges(); + this.setState({ version: result.version, isDirty: false, @@ -267,6 +268,7 @@ export class DashboardScene extends SceneObjectBase { }, }); + this.state.editPanel?.dashboardSaved(); this._changeTracker.startTrackingChanges(); } @@ -801,7 +803,7 @@ export class DashboardScene extends SceneObjectBase { let panel = getClosestVizPanel(sceneObject); if (dashboard.state.isEditing && dashboard.state.editPanel) { - panel = dashboard.state.editPanel.state.vizManager.state.panel; + panel = dashboard.state.editPanel.state.panelRef.resolve(); } let panelId = 0; diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts index 53067b92a34..21335a1904d 100644 --- a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts +++ b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts @@ -2,14 +2,7 @@ import { Unsubscribable } from 'rxjs'; import { AppEvents } from '@grafana/data'; import { config, locationService } from '@grafana/runtime'; -import { - SceneGridLayout, - SceneObjectBase, - SceneObjectState, - SceneObjectUrlSyncHandler, - SceneObjectUrlValues, - VizPanel, -} from '@grafana/scenes'; +import { SceneGridLayout, SceneObjectUrlSyncHandler, SceneObjectUrlValues, VizPanel } from '@grafana/scenes'; import appEvents from 'app/core/app_events'; import { KioskMode } from 'app/types'; @@ -18,7 +11,7 @@ import { buildPanelEditScene } from '../panel-edit/PanelEditor'; import { createDashboardEditViewFor } from '../settings/utils'; import { ShareDrawer } from '../sharing/ShareDrawer/ShareDrawer'; import { ShareModal } from '../sharing/ShareModal'; -import { findVizPanelByKey, getDashboardSceneFor, getLibraryPanelBehavior, isPanelClone } from '../utils/utils'; +import { findVizPanelByKey, getLibraryPanelBehavior, isPanelClone } from '../utils/utils'; import { DashboardScene, DashboardSceneState } from './DashboardScene'; import { LibraryPanelBehavior } from './LibraryPanelBehavior'; @@ -78,9 +71,7 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler { } update.inspectPanelKey = values.inspect; - update.overlay = new PanelInspectDrawer({ - $behaviors: [new ResolveInspectPanelByKey({ panelKey: values.inspect })], - }); + update.overlay = new PanelInspectDrawer({ panelRef: panel.getRef() }); } else if (inspectPanelKey) { update.inspectPanelKey = undefined; update.overlay = undefined; @@ -196,37 +187,3 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler { }); } } - -interface ResolveInspectPanelByKeyState extends SceneObjectState { - panelKey: string; -} - -class ResolveInspectPanelByKey extends SceneObjectBase { - constructor(state: ResolveInspectPanelByKeyState) { - super(state); - this.addActivationHandler(this._onActivate); - } - - private _onActivate = () => { - const parent = this.parent; - - if (!parent || !(parent instanceof PanelInspectDrawer)) { - throw new Error('ResolveInspectPanelByKey must be attached to a PanelInspectDrawer'); - } - - const dashboard = getDashboardSceneFor(parent); - if (!dashboard) { - return; - } - const panelId = this.state.panelKey; - let panel = findVizPanelByKey(dashboard, panelId); - - if (dashboard.state.editPanel) { - panel = dashboard.state.editPanel.state.vizManager.state.panel; - } - - if (panel) { - parent.setState({ panelRef: panel.getRef() }); - } - }; -} diff --git a/public/app/features/dashboard-scene/scene/LibraryPanelBehavior.tsx b/public/app/features/dashboard-scene/scene/LibraryPanelBehavior.tsx index d3848d1fccf..ba2551e57ec 100644 --- a/public/app/features/dashboard-scene/scene/LibraryPanelBehavior.tsx +++ b/public/app/features/dashboard-scene/scene/LibraryPanelBehavior.tsx @@ -77,6 +77,16 @@ export class LibraryPanelBehavior extends SceneObjectBase b !== this) }); + } + } + private async loadLibraryPanelFromPanelModel() { let vizPanel = this.parent; diff --git a/public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx b/public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx index 22c2acde4fa..39048ed5724 100644 --- a/public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx +++ b/public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx @@ -4,7 +4,7 @@ import { TestProvider } from 'test/helpers/TestProvider'; import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; import { selectors } from '@grafana/e2e-selectors'; -import { config, locationService } from '@grafana/runtime'; +import { LocationServiceProvider, config, locationService } from '@grafana/runtime'; import { SceneGridLayout, SceneQueryRunner, SceneTimeRange, UrlSyncContextProvider, VizPanel } from '@grafana/scenes'; import { playlistSrv } from 'app/features/playlist/PlaylistSrv'; import { DashboardMeta } from 'app/types'; @@ -236,9 +236,11 @@ function setup(meta?: DashboardMeta) { render( - - - + + + + + ); diff --git a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx index d062d96a7f0..4b00fa431ba 100644 --- a/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx +++ b/public/app/features/dashboard-scene/scene/NavToolbarActions.tsx @@ -61,8 +61,8 @@ export function ToolbarActions({ dashboard }: Props) { const styles = useStyles2(getStyles); const isEditingPanel = Boolean(editPanel); const isViewingPanel = Boolean(viewPanelScene); - const isEditedPanelDirty = useVizManagerDirty(editPanel); - const isEditingLibraryPanel = useEditingLibraryPanel(editPanel); + const isEditedPanelDirty = usePanelEditDirty(editPanel); + const isEditingLibraryPanel = editPanel && isLibraryPanel(editPanel.state.panelRef.resolve()); const hasCopiedPanel = store.exists(LS_PANEL_COPY_KEY); // Means we are not in settings view, fullscreen panel or edit panel const isShowingDashboard = !editview && !isViewingPanel && !isEditingPanel; @@ -422,7 +422,7 @@ export function ToolbarActions({ dashboard }: Props) { onClick={editPanel?.onDiscard} tooltip={editPanel?.state.isNewPanel ? 'Discard panel' : 'Discard panel changes'} size="sm" - disabled={!isEditedPanelDirty || !isDirty} + disabled={!isEditedPanelDirty} key="discard" fill="outline" variant="destructive" @@ -613,41 +613,22 @@ function addDynamicActions( } } -function useEditingLibraryPanel(panelEditor?: PanelEditor) { - const [isEditingLibraryPanel, setEditingLibraryPanel] = useState(false); - - useEffect(() => { - if (panelEditor) { - const unsub = panelEditor.state.vizManager.subscribeToState((vizManagerState) => - setEditingLibraryPanel(isLibraryPanel(vizManagerState.sourcePanel.resolve())) - ); - return () => { - unsub.unsubscribe(); - }; - } - setEditingLibraryPanel(false); - return; - }, [panelEditor]); - - return isEditingLibraryPanel; -} - // This hook handles when panelEditor is not defined to avoid conditionally hook usage -function useVizManagerDirty(panelEditor?: PanelEditor) { - const [isDirty, setIsDirty] = useState(false); +function usePanelEditDirty(panelEditor?: PanelEditor) { + const [isDirty, setIsDirty] = useState(); useEffect(() => { if (panelEditor) { - const unsub = panelEditor.state.vizManager.subscribeToState((vizManagerState) => - setIsDirty(vizManagerState.isDirty || false) - ); - return () => { - unsub.unsubscribe(); - }; + const unsub = panelEditor.subscribeToState((state) => { + if (state.isDirty !== isDirty) { + setIsDirty(state.isDirty); + } + }); + + return () => unsub.unsubscribe(); } - setIsDirty(false); return; - }, [panelEditor]); + }, [panelEditor, isDirty]); return isDirty; } diff --git a/public/app/features/dashboard-scene/scene/row-actions/RowOptionsForm.test.tsx b/public/app/features/dashboard-scene/scene/row-actions/RowOptionsForm.test.tsx index f112fb3d465..7b019aa5405 100644 --- a/public/app/features/dashboard-scene/scene/row-actions/RowOptionsForm.test.tsx +++ b/public/app/features/dashboard-scene/scene/row-actions/RowOptionsForm.test.tsx @@ -24,7 +24,7 @@ describe('DashboardRow', () => { { it('Should not show warning component when does not have warningMessage prop', () => { render( - + ); expect( diff --git a/public/app/features/dashboard-scene/scene/row-actions/RowOptionsForm.tsx b/public/app/features/dashboard-scene/scene/row-actions/RowOptionsForm.tsx index 270f7a7f5ea..9977d96b93e 100644 --- a/public/app/features/dashboard-scene/scene/row-actions/RowOptionsForm.tsx +++ b/public/app/features/dashboard-scene/scene/row-actions/RowOptionsForm.tsx @@ -12,13 +12,13 @@ export type OnRowOptionsUpdate = (title: string, repeat?: string | null) => void export interface Props { title: string; repeat?: string; - parent: SceneObject; + sceneContext: SceneObject; onUpdate: OnRowOptionsUpdate; onCancel: () => void; warning?: React.ReactNode; } -export const RowOptionsForm = ({ repeat, title, parent, warning, onUpdate, onCancel }: Props) => { +export const RowOptionsForm = ({ repeat, title, sceneContext, warning, onUpdate, onCancel }: Props) => { const [newRepeat, setNewRepeat] = useState(repeat); const onChangeRepeat = useCallback((name?: string) => setNewRepeat(name), [setNewRepeat]); @@ -38,7 +38,7 @@ export const RowOptionsForm = ({ repeat, title, parent, warning, onUpdate, onCan
    - + {warning && ( { activateFullSceneTree(scene); expect(repeater.state.repeatedPanels?.length).toBe(2); - const result = panelRepeaterToPanels(repeater, undefined, true); + const result = panelRepeaterToPanels(repeater, true); expect(result).toHaveLength(2); @@ -861,7 +852,7 @@ describe('transformSceneToSaveModel', () => { ); activateFullSceneTree(scene); - const result = panelRepeaterToPanels(repeater, undefined, true); + const result = panelRepeaterToPanels(repeater, true); expect(result).toHaveLength(1); @@ -886,7 +877,7 @@ describe('transformSceneToSaveModel', () => { activateFullSceneTree(scene); let panels: Panel[] = []; - gridRowToSaveModel(row, panels, undefined, true); + gridRowToSaveModel(row, panels, true); expect(panels).toHaveLength(2); expect(panels[0].repeat).toBe('handler'); @@ -914,7 +905,7 @@ describe('transformSceneToSaveModel', () => { activateFullSceneTree(scene); let panels: Panel[] = []; - gridRowToSaveModel(row, panels, undefined, true); + gridRowToSaveModel(row, panels, true); expect(panels[0].repeat).toBe('handler'); @@ -1024,94 +1015,6 @@ describe('transformSceneToSaveModel', () => { }); }); - describe('Given a scene with an open panel editor', () => { - it('should persist changes to panel model', async () => { - const panel = new VizPanel({ - key: 'panel-1', - pluginId: 'text', - }); - - const gridItem = new DashboardGridItem({ body: panel }); - - const editScene = buildPanelEditScene(panel); - const scene = new DashboardScene({ - editPanel: editScene, - isEditing: true, - body: new SceneGridLayout({ - children: [gridItem], - }), - $timeRange: new SceneTimeRange({ - from: 'now-6h', - to: 'now', - timeZone: '', - }), - }); - - editScene!.state.vizManager.state.panel.setState({ - options: { - mode: 'markdown', - code: { - language: 'plaintext', - showLineNumbers: false, - showMiniMap: false, - }, - content: 'new content', - }, - }); - activateFullSceneTree(scene); - const saveModel = transformSceneToSaveModel(scene); - expect((saveModel.panels![0] as any).options.content).toBe('new content'); - }); - - it('should persist changes to panel model in row', async () => { - const panel = new VizPanel({ - key: 'panel-1', - pluginId: 'text', - options: { - content: 'old content', - }, - }); - - const gridItem = new DashboardGridItem({ body: panel }); - - const editScene = buildPanelEditScene(panel); - const scene = new DashboardScene({ - editPanel: editScene, - isEditing: true, - body: new SceneGridLayout({ - children: [ - new SceneGridRow({ - key: '23', - isCollapsed: false, - children: [gridItem], - }), - ], - }), - $timeRange: new SceneTimeRange({ - from: 'now-6h', - to: 'now', - timeZone: '', - }), - }); - activateFullSceneTree(scene); - - editScene!.state.vizManager.state.panel.setState({ - options: { - mode: 'markdown', - code: { - language: 'plaintext', - showLineNumbers: false, - showMiniMap: false, - }, - content: 'new content', - }, - }); - - const saveModel = transformSceneToSaveModel(scene); - expect((saveModel.panels![1] as any).options.content).toBe('new content'); - }); - }); - describe('Given a scene with repeated panels and non-repeated panels', () => { it('should save repeated panels itemHeight as height', () => { const scene = transformSaveModelToScene({ diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts index 45db9cf1fdb..01605ed28ab 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts @@ -33,7 +33,7 @@ import { GrafanaQueryType } from 'app/plugins/datasource/grafana/types'; import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet'; import { DashboardGridItem } from '../scene/DashboardGridItem'; -import { DashboardScene, DashboardSceneState } from '../scene/DashboardScene'; +import { DashboardScene } from '../scene/DashboardScene'; import { PanelTimeRange } from '../scene/PanelTimeRange'; import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior'; import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; @@ -58,9 +58,9 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa if (child instanceof DashboardGridItem) { // handle panel repeater scenario if (child.state.variableName) { - panels = panels.concat(panelRepeaterToPanels(child, state, isSnapshot)); + panels = panels.concat(panelRepeaterToPanels(child, isSnapshot)); } else { - panels.push(gridItemToPanel(child, state, isSnapshot)); + panels.push(gridItemToPanel(child, isSnapshot)); } } @@ -69,7 +69,7 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa if (child.state.key!.indexOf('-clone-') > 0 && !isSnapshot) { continue; } - gridRowToSaveModel(child, panels, state, isSnapshot); + gridRowToSaveModel(child, panels, isSnapshot); } } } @@ -139,11 +139,7 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa return sortedDeepCloneWithoutNulls(dashboard); } -export function gridItemToPanel( - gridItem: DashboardGridItem, - sceneState?: DashboardSceneState, - isSnapshot = false -): Panel { +export function gridItemToPanel(gridItem: DashboardGridItem, isSnapshot = false): Panel { let vizPanel: VizPanel | undefined; let x = 0, y = 0, @@ -152,19 +148,6 @@ export function gridItemToPanel( let gridItem_ = gridItem; - // If we're saving while the panel editor is open, we need to persist those changes in the panel model - if ( - sceneState && - sceneState.editPanel?.state.vizManager && - sceneState.editPanel.state.vizManager.state.sourcePanel.resolve() === gridItem.state.body - ) { - const gridItemClone = gridItem.clone(); - if (gridItemClone.state.body instanceof VizPanel && !isLibraryPanel(gridItemClone.state.body)) { - sceneState.editPanel.state.vizManager.commitChangesTo(gridItemClone.state.body); - gridItem_ = gridItemClone; - } - } - if (!(gridItem_.state.body instanceof VizPanel)) { throw new Error('DashboardGridItem body expected to be VizPanel'); } @@ -325,13 +308,9 @@ function vizPanelDataToPanel( return panel; } -export function panelRepeaterToPanels( - repeater: DashboardGridItem, - sceneState?: DashboardSceneState, - isSnapshot = false -): Panel[] { +export function panelRepeaterToPanels(repeater: DashboardGridItem, isSnapshot = false): Panel[] { if (!isSnapshot) { - return [gridItemToPanel(repeater, sceneState)]; + return [gridItemToPanel(repeater)]; } else { // return early if the repeated panel is a library panel if (repeater.state.body instanceof VizPanel && isLibraryPanel(repeater.state.body)) { @@ -388,12 +367,7 @@ export function panelRepeaterToPanels( } } -export function gridRowToSaveModel( - gridRow: SceneGridRow, - panelsArray: Array, - sceneState?: DashboardSceneState, - isSnapshot = false -) { +export function gridRowToSaveModel(gridRow: SceneGridRow, panelsArray: Array, isSnapshot = false) { const collapsed = Boolean(gridRow.state.isCollapsed); const rowPanel: RowPanel = { type: 'row', @@ -443,10 +417,10 @@ export function gridRowToSaveModel( if (c instanceof DashboardGridItem) { if (c.state.variableName) { // Perform snapshot only for uncollapsed rows - panelsInsideRow = panelsInsideRow.concat(panelRepeaterToPanels(c, sceneState, !collapsed)); + panelsInsideRow = panelsInsideRow.concat(panelRepeaterToPanels(c, !collapsed)); } else { // Perform snapshot only for uncollapsed panels - panelsInsideRow.push(gridItemToPanel(c, sceneState, !collapsed)); + panelsInsideRow.push(gridItemToPanel(c, !collapsed)); } } }); @@ -455,7 +429,7 @@ export function gridRowToSaveModel( if (!(c instanceof DashboardGridItem)) { throw new Error('Row child expected to be DashboardGridItem'); } - return gridItemToPanel(c, sceneState); + return gridItemToPanel(c); }); } diff --git a/public/app/features/dashboard-scene/sharing/ShareButton/ShareButton.tsx b/public/app/features/dashboard-scene/sharing/ShareButton/ShareButton.tsx index 0d8363c9d1b..f5574ea89f9 100644 --- a/public/app/features/dashboard-scene/sharing/ShareButton/ShareButton.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareButton/ShareButton.tsx @@ -18,12 +18,13 @@ export default function ShareButton({ dashboard, panel }: { dashboard: Dashboard const [isOpen, setIsOpen] = useState(false); const [_, buildUrl] = useAsyncFn(async () => { + DashboardInteractions.toolbarShareClick(); return await buildShareUrl(dashboard, panel); }, [dashboard]); const onMenuClick = useCallback((isOpen: boolean) => { if (isOpen) { - DashboardInteractions.toolbarShareClick(); + DashboardInteractions.toolbarShareDropdownClick(); } setIsOpen(isOpen); diff --git a/public/app/features/dashboard-scene/utils/interactions.ts b/public/app/features/dashboard-scene/utils/interactions.ts index fef5f346551..6ed95fe6cb8 100644 --- a/public/app/features/dashboard-scene/utils/interactions.ts +++ b/public/app/features/dashboard-scene/utils/interactions.ts @@ -28,6 +28,9 @@ export const DashboardInteractions = { toolbarShareClick: () => { reportDashboardInteraction('toolbar_actions_clicked', { item: 'share' }); }, + toolbarShareDropdownClick: () => { + reportDashboardInteraction('toolbar_actions_clicked', { item: 'share_dropdown' }); + }, toolbarAddClick: () => { reportDashboardInteraction('toolbar_actions_clicked', { item: 'add' }); }, diff --git a/public/app/features/dashboard/components/PanelEditor/getFieldOverrideElements.tsx b/public/app/features/dashboard/components/PanelEditor/getFieldOverrideElements.tsx index 5ed6672b923..918d9fc6906 100644 --- a/public/app/features/dashboard/components/PanelEditor/getFieldOverrideElements.tsx +++ b/public/app/features/dashboard/components/PanelEditor/getFieldOverrideElements.tsx @@ -110,20 +110,17 @@ export function getFieldOverrideCategories( onOverrideChange(idx, override); }; - const onDynamicConfigValueAdd = (o: ConfigOverrideRule, value: SelectableValue) => { + const onDynamicConfigValueAdd = (override: ConfigOverrideRule, value: SelectableValue) => { const registryItem = registry.get(value.value!); const propertyConfig: DynamicConfigValue = { id: registryItem.id, value: registryItem.defaultValue, }; - if (override.properties) { - o.properties.push(propertyConfig); - } else { - o.properties = [propertyConfig]; - } + const properties = override.properties ?? []; + properties.push(propertyConfig); - onOverrideChange(idx, o); + onOverrideChange(idx, { ...override, properties }); }; /** @@ -158,13 +155,23 @@ export function getFieldOverrideCategories( } const onPropertyChange = (value: DynamicConfigValue) => { - override.properties[propIdx].value = value; - onOverrideChange(idx, override); + onOverrideChange(idx, { + ...override, + properties: override.properties.map((prop, i) => { + if (i === propIdx) { + return { ...prop, value: value }; + } + + return prop; + }), + }); }; const onPropertyRemove = () => { - override.properties.splice(propIdx, 1); - onOverrideChange(idx, override); + onOverrideChange(idx, { + ...override, + properties: override.properties.filter((_, i) => i !== propIdx), + }); }; /** diff --git a/public/app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect.tsx b/public/app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect.tsx index c35a286a4ea..67e9fcd45b4 100644 --- a/public/app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect.tsx +++ b/public/app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect.tsx @@ -44,14 +44,14 @@ export const RepeatRowSelect = ({ repeat, onChange, id }: Props) => { }; interface Props2 { - parent: SceneObject; + sceneContext: SceneObject; repeat: string | undefined; id?: string; onChange: (name?: string) => void; } -export const RepeatRowSelect2 = ({ parent, repeat, id, onChange }: Props2) => { - const sceneVars = useMemo(() => sceneGraph.getVariables(parent), [parent]); +export const RepeatRowSelect2 = ({ sceneContext, repeat, id, onChange }: Props2) => { + const sceneVars = useMemo(() => sceneGraph.getVariables(sceneContext.getRoot()), [sceneContext]); const variables = sceneVars.useState().variables; const variableOptions = useMemo(() => { diff --git a/public/app/features/dashboard/containers/DashboardPageProxy.test.tsx b/public/app/features/dashboard/containers/DashboardPageProxy.test.tsx index 35370d47621..178c4b96cef 100644 --- a/public/app/features/dashboard/containers/DashboardPageProxy.test.tsx +++ b/public/app/features/dashboard/containers/DashboardPageProxy.test.tsx @@ -61,6 +61,7 @@ jest.mock('@grafana/runtime', () => ({ }, get: jest.fn().mockResolvedValue({}), }), + useChromeHeaderHeight: jest.fn(), })); jest.mock('react-virtualized-auto-sizer', () => { diff --git a/public/app/features/dashboard/containers/PublicDashboardPageProxy.test.tsx b/public/app/features/dashboard/containers/PublicDashboardPageProxy.test.tsx index 6da46204b9a..49193505061 100644 --- a/public/app/features/dashboard/containers/PublicDashboardPageProxy.test.tsx +++ b/public/app/features/dashboard/containers/PublicDashboardPageProxy.test.tsx @@ -4,7 +4,7 @@ import { Router } from 'react-router-dom'; import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock'; import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; -import { config, locationService } from '@grafana/runtime'; +import { LocationServiceProvider, config, locationService } from '@grafana/runtime'; import { GrafanaContext } from 'app/core/context/GrafanaContext'; import { backendSrv } from 'app/core/services/backend_srv'; import { configureStore } from 'app/store/configureStore'; @@ -32,16 +32,18 @@ function setup(props: Partial) { return render( - - null, path: '/:accessToken' }} - match={{ params: { accessToken: 'an-access-token' }, isExact: true, path: '/', url: '/' }} - {...props} - /> - + + + null, path: '/:accessToken' }} + match={{ params: { accessToken: 'an-access-token' }, isExact: true, path: '/', url: '/' }} + {...props} + /> + + ); diff --git a/public/app/features/dashboard/dashgrid/panelOptionsLogger.ts b/public/app/features/dashboard/dashgrid/panelOptionsLogger.ts index 21748075306..0e75d7fdad1 100644 --- a/public/app/features/dashboard/dashgrid/panelOptionsLogger.ts +++ b/public/app/features/dashboard/dashgrid/panelOptionsLogger.ts @@ -1,5 +1,6 @@ import { FieldConfigSource } from '@grafana/data'; import { faro } from '@grafana/faro-web-sdk'; +import { config } from 'app/core/config'; import { FIELD_CONFIG_CUSTOM_KEY, FIELD_CONFIG_OVERRIDES_KEY, PanelLogEvents } from 'app/core/log_events'; interface PanelLogInfo { @@ -29,6 +30,9 @@ export class PanelOptionsLogger { }; logPanelEvent = (eventName: string, newKey: string, newVal: string, oldVal?: string) => { + if (!config.grafanaJavascriptAgent.enabled) { + return; + } const logObj = { key: newKey, newValue: newVal, diff --git a/public/app/features/dashboard/state/DashboardMigrator.test.ts b/public/app/features/dashboard/state/DashboardMigrator.test.ts index b3aef514daf..6ba62a860e5 100644 --- a/public/app/features/dashboard/state/DashboardMigrator.test.ts +++ b/public/app/features/dashboard/state/DashboardMigrator.test.ts @@ -2413,6 +2413,21 @@ describe('when migrating table cell display mode to cell options', () => { }); }); +describe('when migrating variable refresh to on dashboard load', () => { + let model: DashboardModel; + + beforeEach(() => { + model = new DashboardModel({ + //@ts-ignore + refresh: false, + }); + }); + + it('should migrate to empty string', () => { + expect(model.refresh).toBe(''); + }); +}); + 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 bd04031eebd..618d684ab16 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 = 39; +export const DASHBOARD_SCHEMA_VERSION = 40; export class DashboardMigrator { dashboard: DashboardModel; @@ -904,6 +904,13 @@ export class DashboardMigrator { }); } + if (oldVersion < 40) { + // In old ashboards refresh property can be a boolean + if (typeof this.dashboard.refresh !== 'string') { + this.dashboard.refresh = ''; + } + } + /** * -==- Add migration here -==- * Your migration should go below the previous diff --git a/public/app/features/dashboard/state/getPanelPluginToMigrateTo.ts b/public/app/features/dashboard/state/getPanelPluginToMigrateTo.ts index 5eb7c201ee4..e4cc5b6dac2 100644 --- a/public/app/features/dashboard/state/getPanelPluginToMigrateTo.ts +++ b/public/app/features/dashboard/state/getPanelPluginToMigrateTo.ts @@ -32,6 +32,10 @@ export function getPanelPluginToMigrateTo(panel: any, forceMigration?: boolean): isUrlFeatureFlagEnabled('autoMigrateGraphPanel')) ) { if (panel.xaxis?.mode === 'series') { + if (panel.legend?.values) { + return 'bargauge'; + } + return 'barchart'; } diff --git a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/QueryDescriptionCell.tsx b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/QueryDescriptionCell.tsx index 7d44fe5a634..4dce16f9ced 100644 --- a/public/app/features/explore/QueryLibrary/QueryTemplatesTable/QueryDescriptionCell.tsx +++ b/public/app/features/explore/QueryLibrary/QueryTemplatesTable/QueryDescriptionCell.tsx @@ -2,6 +2,7 @@ import { cx } from '@emotion/css'; import { CellProps } from 'react-table'; import { Spinner, Tooltip } from '@grafana/ui'; +import { createQueryText } from 'app/core/utils/richHistory'; import { useDatasource } from '../utils/useDatasource'; @@ -20,7 +21,7 @@ export function QueryDescriptionCell(props: CellProps) { return
    No queries
    ; } const query = props.row.original.query; - const queryDisplayText = datasourceApi?.getQueryDisplayText?.(query) || ''; + const queryDisplayText = createQueryText(query, datasourceApi); const description = props.row.original.description; const dsName = datasourceApi?.name || ''; diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanLinks.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanLinks.tsx index 67fb2eab3e1..724e0f3b895 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanLinks.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanLinks.tsx @@ -42,6 +42,7 @@ const renderMenuItems = ( : undefined } url={link.href} + target={link.target} className={styles.menuItem} /> )); diff --git a/public/app/features/explore/TraceView/components/types/links.ts b/public/app/features/explore/TraceView/components/types/links.ts index e01a081c43f..00f8f74b6ba 100644 --- a/public/app/features/explore/TraceView/components/types/links.ts +++ b/public/app/features/explore/TraceView/components/types/links.ts @@ -1,6 +1,6 @@ import * as React from 'react'; -import { Field } from '@grafana/data'; +import { Field, LinkTarget } from '@grafana/data'; import { TraceSpan } from './trace'; @@ -20,6 +20,7 @@ export type SpanLinkDef = { title?: string; field: Field; type: SpanLinkType; + target?: LinkTarget; }; export type SpanLinkFunc = (span: TraceSpan) => SpanLinkDef[] | undefined; diff --git a/public/app/features/explore/TraceView/createSpanLink.test.ts b/public/app/features/explore/TraceView/createSpanLink.test.ts index f73ed1d5f68..18cf81ecb6d 100644 --- a/public/app/features/explore/TraceView/createSpanLink.test.ts +++ b/public/app/features/explore/TraceView/createSpanLink.test.ts @@ -1654,6 +1654,11 @@ function createMultiLinkDataFrame() { }, datasourceUid: 'loki1_uid', datasourceName: 'loki1', + }, + url: '', + title: 'Test', + origin: DataLinkConfigOrigin.Correlations, + meta: { transformations: [ { type: SupportedTransformationType.Regex, @@ -1662,9 +1667,6 @@ function createMultiLinkDataFrame() { }, ], }, - url: '', - title: 'Test', - origin: DataLinkConfigOrigin.Correlations, }, { internal: { @@ -1673,6 +1675,11 @@ function createMultiLinkDataFrame() { }, datasourceUid: 'loki1_uid', datasourceName: 'loki1', + }, + url: '', + title: 'Test', + origin: DataLinkConfigOrigin.Correlations, + meta: { transformations: [ { type: SupportedTransformationType.Regex, @@ -1681,9 +1688,6 @@ function createMultiLinkDataFrame() { }, ], }, - url: '', - title: 'Test2', - origin: DataLinkConfigOrigin.Correlations, }, ], }, diff --git a/public/app/features/explore/TraceView/createSpanLink.tsx b/public/app/features/explore/TraceView/createSpanLink.tsx index 3b4a5b19fb2..15cb385e178 100644 --- a/public/app/features/explore/TraceView/createSpanLink.tsx +++ b/public/app/features/explore/TraceView/createSpanLink.tsx @@ -110,6 +110,7 @@ export function createSpanLinkFactory({ content: , field: link.origin, type: shouldCreatePyroscopeLink ? SpanLinkType.Profiles : SpanLinkType.Unknown, + target: link.target, }; }); diff --git a/public/app/features/explore/utils/decorators.test.ts b/public/app/features/explore/utils/decorators.test.ts index 67adfacbb73..b61a87f89ec 100644 --- a/public/app/features/explore/utils/decorators.test.ts +++ b/public/app/features/explore/utils/decorators.test.ts @@ -482,6 +482,7 @@ describe('decorateWithCorrelations', () => { source: datasourceInstance, target: datasourceInstance, provisioned: true, + type: 'query', config: { field: panelData.series[0].fields[0].name }, }, ] as CorrelationData[]; diff --git a/public/app/features/explore/utils/decorators.ts b/public/app/features/explore/utils/decorators.ts index fe8db4a38d5..b09f0b61b61 100644 --- a/public/app/features/explore/utils/decorators.ts +++ b/public/app/features/explore/utils/decorators.ts @@ -127,9 +127,9 @@ export const decorateWithCorrelations = ({ datasourceUid: defaultTargetDatasource.uid, datasourceName: defaultTargetDatasource.name, query: { datasource: { uid: defaultTargetDatasource.uid } }, - meta: { - correlationData: { resultField: field.name, vars: availableVars, origVars: availableVars }, - }, + }, + meta: { + correlationData: { resultField: field.name, vars: availableVars, origVars: availableVars }, }, }); } diff --git a/public/app/features/explore/utils/links.test.ts b/public/app/features/explore/utils/links.test.ts index 60a9c8fd5fe..9c7f12afc10 100644 --- a/public/app/features/explore/utils/links.test.ts +++ b/public/app/features/explore/utils/links.test.ts @@ -281,6 +281,8 @@ describe('explore links utils', () => { query: { query: 'http_requests{app=${application} env=${environment}}' }, datasourceUid: 'uid_1', datasourceName: 'test_ds', + }, + meta: { transformations: [ { type: SupportedTransformationType.Logfmt }, { type: SupportedTransformationType.Regex, expression: 'host=(dev|prod)', mapValue: 'environment' }, @@ -330,6 +332,8 @@ describe('explore links utils', () => { query: { query: 'http_requests{env=${msg}}' }, datasourceUid: 'uid_1', datasourceName: 'test_ds', + }, + meta: { transformations: [ { type: SupportedTransformationType.Regex, expression: 'fieldA=(asparagus|broccoli)' }, { type: SupportedTransformationType.Regex, expression: 'fieldB=(apple|banana)' }, @@ -372,6 +376,8 @@ describe('explore links utils', () => { query: { query: 'http_requests{env=${msg}}' }, datasourceUid: 'uid_1', datasourceName: 'test_ds', + }, + meta: { transformations: [ { type: SupportedTransformationType.Regex, @@ -427,8 +433,8 @@ describe('explore links utils', () => { query: { query: 'http_requests{app=${application} isOnline=${online}}' }, datasourceUid: 'uid_1', datasourceName: 'test_ds', - transformations: [{ type: SupportedTransformationType.Logfmt }], }, + meta: { transformations: [{ type: SupportedTransformationType.Logfmt }] }, }; const { field, range, dataFrame } = setup(transformationLink, true, { @@ -466,8 +472,8 @@ describe('explore links utils', () => { query: { query: 'http_requests{app=${application}}' }, datasourceUid: 'uid_1', datasourceName: 'test_ds', - transformations: [{ type: SupportedTransformationType.Logfmt, field: 'fieldNamedInTransformation' }], }, + meta: { transformations: [{ type: SupportedTransformationType.Logfmt, field: 'fieldNamedInTransformation' }] }, }; // fieldWithLink has the transformation, but the transformation has defined fieldNamedInTransformation as its field to transform @@ -518,6 +524,8 @@ describe('explore links utils', () => { query: { query: 'http_requests{app=${application} env=${environment}}' }, datasourceUid: 'uid_1', datasourceName: 'test_ds', + }, + meta: { transformations: [ { type: SupportedTransformationType.Regex, @@ -598,6 +606,8 @@ describe('explore links utils', () => { query: { query: 'http_requests{app=${application} env=${diffVar}}' }, datasourceUid: 'uid_1', datasourceName: 'test_ds', + }, + meta: { transformations: [{ type: SupportedTransformationType.Logfmt }], }, }; @@ -623,6 +633,8 @@ describe('explore links utils', () => { query: { query: 'http_requests{app=test}' }, datasourceUid: 'uid_1', datasourceName: 'test_ds', + }, + meta: { transformations: [{ type: SupportedTransformationType.Logfmt }], }, }; diff --git a/public/app/features/explore/utils/links.ts b/public/app/features/explore/utils/links.ts index bc530234b24..e36ba898134 100644 --- a/public/app/features/explore/utils/links.ts +++ b/public/app/features/explore/utils/links.ts @@ -49,7 +49,7 @@ const DATA_LINK_FILTERS: DataLinkFilter[] = [dataLinkHasRequiredPermissionsFilte * for internal links and undefined for non-internal links */ export interface ExploreFieldLinkModel extends LinkModel { - variables?: VariableInterpolation[]; + variables: VariableInterpolation[]; } const DATA_LINK_USAGE_KEY = 'grafana_data_link_clicked'; @@ -65,7 +65,7 @@ export const exploreDataLinkPostProcessorFactory = ( const { field, dataLinkScopedVars: vars, frame: dataFrame, link, linkModel } = options; const { valueRowIndex: rowIndex } = options.config; - if (!link.internal || rowIndex === undefined) { + if (rowIndex === undefined) { return linkModel; } @@ -159,57 +159,57 @@ export const getFieldLinksForExplore = (options: { }); const fieldLinks = links.map((link) => { - if (!link.internal) { - const replace: InterpolateFunction = (value, vars) => - getTemplateSrv().replace(value, { ...vars, ...scopedVars }); + let internalLinkSpecificVars: ScopedVars = {}; + if (link.meta?.transformations) { + link.meta?.transformations.forEach((transformation) => { + let fieldValue; + if (transformation.field) { + const transformField = dataFrame?.fields.find((field) => field.name === transformation.field); + fieldValue = transformField?.values[rowIndex]; + } else { + fieldValue = field.values[rowIndex]; + } - const linkModel = getLinkSrv().getDataLinkUIModel(link, replace, field); - if (!linkModel.title) { - linkModel.title = getTitleFromHref(linkModel.href); - } - return linkModel; + internalLinkSpecificVars = { + ...internalLinkSpecificVars, + ...getTransformationVars(transformation, fieldValue, field.name), + }; + }); + } + + const allVars = { ...scopedVars, ...internalLinkSpecificVars }; + const variableData = getVariableUsageInfo(link, allVars); + let variables: VariableInterpolation[] = []; + + // if the link has no variables (static link), add it with the right key but an empty value so we know what field the static link is associated with + if (variableData.variables.length === 0) { + const fieldName = field.name.toString(); + variables.push({ variableName: fieldName, value: '', match: '' }); } else { - let internalLinkSpecificVars: ScopedVars = {}; - if (link.internal?.transformations) { - link.internal?.transformations.forEach((transformation) => { - let fieldValue; - if (transformation.field) { - const transformField = dataFrame?.fields.find((field) => field.name === transformation.field); - fieldValue = transformField?.values[rowIndex]; - } else { - fieldValue = field.values[rowIndex]; - } + variables = variableData.variables; + } + if (variableData.allVariablesDefined) { + if (!link.internal) { + const replace: InterpolateFunction = (value, vars) => + getTemplateSrv().replace(value, { ...vars, ...allVars, ...scopedVars }); - internalLinkSpecificVars = { - ...internalLinkSpecificVars, - ...getTransformationVars(transformation, fieldValue, field.name), - }; - }); - } - - const allVars = { ...scopedVars, ...internalLinkSpecificVars }; - const variableData = getVariableUsageInfo(link, allVars); - let variables: VariableInterpolation[] = []; - - // if the link has no variables (static link), add it with the right key but an empty value so we know what field the static link is associated with - if (variableData.variables.length === 0) { - const fieldName = field.name.toString(); - variables.push({ variableName: fieldName, value: '', match: '' }); + const linkModel = getLinkSrv().getDataLinkUIModel(link, replace, field); + if (!linkModel.title) { + linkModel.title = getTitleFromHref(linkModel.href); + } + linkModel.target = '_blank'; + return { ...linkModel, variables: variables }; } else { - variables = variableData.variables; - } + const splitFnWithTracking = (options?: SplitOpenOptions) => { + reportInteraction(DATA_LINK_USAGE_KEY, { + origin: link.origin || DataLinkConfigOrigin.Datasource, + app: CoreApp.Explore, + internal: true, + }); - const splitFnWithTracking = (options?: SplitOpenOptions) => { - reportInteraction(DATA_LINK_USAGE_KEY, { - origin: link.origin || DataLinkConfigOrigin.Datasource, - app: CoreApp.Explore, - internal: true, - }); + splitOpenFn?.(options); + }; - splitOpenFn?.(options); - }; - - if (variableData.allVariablesDefined) { const internalLink = mapInternalLinkToExplore({ link, internalLink: link.internal, @@ -221,9 +221,9 @@ export const getFieldLinksForExplore = (options: { replaceVariables: getTemplateSrv().replace.bind(getTemplateSrv()), }); return { ...internalLink, variables: variables }; - } else { - return undefined; } + } else { + return undefined; } }); return fieldLinks.filter((link): link is ExploreFieldLinkModel => !!link); diff --git a/public/app/features/expressions/guards.ts b/public/app/features/expressions/guards.ts index ba272a88537..40a61add235 100644 --- a/public/app/features/expressions/guards.ts +++ b/public/app/features/expressions/guards.ts @@ -1,7 +1,7 @@ import { isExpressionReference } from '@grafana/runtime/src/utils/DataSourceWithBackend'; import { DataQuery } from '@grafana/schema'; -import { ExpressionQuery, ExpressionQueryType } from './types'; +import { ExpressionQuery, ExpressionQueryType, ReducerType } from './types'; export const isExpressionQuery = (dataQuery?: DataQuery): dataQuery is ExpressionQuery => { if (!dataQuery) { @@ -19,3 +19,20 @@ export const isExpressionQuery = (dataQuery?: DataQuery): dataQuery is Expressio } return Object.values(ExpressionQueryType).includes(expression.type); }; + +export function isReducerType(value: string): value is ReducerType { + return [ + 'avg', + 'min', + 'max', + 'sum', + 'count', + 'last', + 'median', + 'diff', + 'diff_abs', + 'percent_diff', + 'percent_diff_abs', + 'count_non_null', + ].includes(value); +} diff --git a/public/app/features/expressions/utils/expressionTypes.ts b/public/app/features/expressions/utils/expressionTypes.ts index 27c8b2c5941..514dfdcb907 100644 --- a/public/app/features/expressions/utils/expressionTypes.ts +++ b/public/app/features/expressions/utils/expressionTypes.ts @@ -1,7 +1,8 @@ import { ReducerID } from '@grafana/data'; import { EvalFunction } from '../../alerting/state/alertDef'; -import { ClassicCondition, ExpressionQuery, ExpressionQueryType } from '../types'; +import { isReducerType } from '../guards'; +import { ClassicCondition, ExpressionQuery, ExpressionQueryType, ReducerType } from '../types'; export const getDefaults = (query: ExpressionQuery) => { switch (query.type) { @@ -57,3 +58,14 @@ export const defaultCondition: ClassicCondition = { type: EvalFunction.IsAbove, }, }; + +/** + * Returns the ReducerType if the value is a valid ReducerType, otherwise undefined + * @param value string + */ +export function getReducerType(value: string): ReducerType | undefined { + if (isReducerType(value)) { + return value; + } + return undefined; +} diff --git a/public/app/features/library-panels/state/api.ts b/public/app/features/library-panels/state/api.ts index c92d295b3d6..8356e8e3ec9 100644 --- a/public/app/features/library-panels/state/api.ts +++ b/public/app/features/library-panels/state/api.ts @@ -3,7 +3,6 @@ import { lastValueFrom } from 'rxjs'; import { VizPanel } from '@grafana/scenes'; import { LibraryPanel, defaultDashboard } from '@grafana/schema'; import { DashboardModel } from 'app/features/dashboard/state'; -import { VizPanelManager } from 'app/features/dashboard-scene/panel-edit/VizPanelManager'; import { DashboardGridItem } from 'app/features/dashboard-scene/scene/DashboardGridItem'; import { vizPanelToPanel } from 'app/features/dashboard-scene/serialization/transformSceneToSaveModel'; import { getLibraryPanelBehavior } from 'app/features/dashboard-scene/utils/utils'; @@ -146,10 +145,6 @@ export function libraryVizPanelToSaveModel(vizPanel: VizPanel) { let gridItem = vizPanel.parent; - if (gridItem instanceof VizPanelManager) { - gridItem = gridItem.state.sourcePanel.resolve().parent; - } - if (!gridItem || !(gridItem instanceof DashboardGridItem)) { throw new Error('Trying to save a library panel that does not have a DashboardGridItem parent'); } diff --git a/public/app/features/logs/components/logParser.test.ts b/public/app/features/logs/components/logParser.test.ts index 68487d12690..5ddba19b5f0 100644 --- a/public/app/features/logs/components/logParser.test.ts +++ b/public/app/features/logs/components/logParser.test.ts @@ -448,6 +448,7 @@ describe('logParser', () => { }, title: 'test', target: '_self', + variables: [], }; const fieldWithVarLink: FieldDef = { diff --git a/public/app/features/logs/components/logParser.ts b/public/app/features/logs/components/logParser.ts index 6f412f070f1..b566c42b89d 100644 --- a/public/app/features/logs/components/logParser.ts +++ b/public/app/features/logs/components/logParser.ts @@ -35,8 +35,16 @@ export const getAllFields = ( export const createLogLineLinks = (hiddenFieldsWithLinks: FieldDef[]): FieldDef[] => { let fieldsWithLinksFromVariableMap: FieldDef[] = []; hiddenFieldsWithLinks.forEach((linkField) => { - linkField.links?.forEach((link: ExploreFieldLinkModel) => { - if (link.variables) { + linkField.links?.forEach((link: LinkModel | ExploreFieldLinkModel) => { + if ('variables' in link && link.variables.length > 0) { + // convert ExploreFieldLinkModel to LinkModel by omitting variables field + const fieldDefFromLink: LinkModel = { + href: link.href, + title: link.title, + origin: link.origin, + onClick: link.onClick, + target: link.target, + }; const variableKeys = link.variables.map((variable) => { const varName = variable.variableName; const fieldPath = variable.fieldPath ? `.${variable.fieldPath}` : ''; @@ -46,7 +54,7 @@ export const createLogLineLinks = (hiddenFieldsWithLinks: FieldDef[]): FieldDef[ fieldsWithLinksFromVariableMap.push({ keys: variableKeys, values: variableValues, - links: [link], + links: [fieldDefFromLink], fieldIndex: linkField.fieldIndex, }); } diff --git a/public/app/features/migrate-to-cloud/api/endpoints.gen.ts b/public/app/features/migrate-to-cloud/api/endpoints.gen.ts index c5eebb2a30a..447ba395491 100644 --- a/public/app/features/migrate-to-cloud/api/endpoints.gen.ts +++ b/public/app/features/migrate-to-cloud/api/endpoints.gen.ts @@ -157,7 +157,7 @@ export type MigrateDataResponseItemDto = { message?: string; refId: string; status: 'OK' | 'WARNING' | 'ERROR' | 'PENDING' | 'UNKNOWN'; - type: 'DASHBOARD' | 'DATASOURCE' | 'FOLDER'; + type: 'DASHBOARD' | 'DATASOURCE' | 'FOLDER' | 'LIBRARY_ELEMENT'; }; export type SnapshotResourceStats = { statuses?: { diff --git a/public/app/features/migrate-to-cloud/cloud/MigrationTokenPane/MigrationTokenPane.tsx b/public/app/features/migrate-to-cloud/cloud/MigrationTokenPane/MigrationTokenPane.tsx index 16c7fa004f5..609d9e654e7 100644 --- a/public/app/features/migrate-to-cloud/cloud/MigrationTokenPane/MigrationTokenPane.tsx +++ b/public/app/features/migrate-to-cloud/cloud/MigrationTokenPane/MigrationTokenPane.tsx @@ -1,6 +1,6 @@ import { useCallback, useState } from 'react'; -import { isFetchError } from '@grafana/runtime'; +import { isFetchError, reportInteraction } from '@grafana/runtime'; import { Box, Button, Text } from '@grafana/ui'; import { t, Trans } from 'app/core/internationalization'; @@ -51,6 +51,8 @@ export const MigrationTokenPane = () => { const isLoading = getTokenQuery.isFetching || createTokenResponse.isLoading; const handleGenerateToken = useCallback(async () => { + reportInteraction('grafana_e2c_generate_token_clicked'); + const resp = await createTokenMutation(); if (!('error' in resp)) { @@ -63,6 +65,7 @@ export const MigrationTokenPane = () => { return; } + reportInteraction('grafana_e2c_delete_token_clicked'); const resp = await deleteTokenMutation({ uid: getTokenQuery.data.id }); if (!('error' in resp)) { setShowDeleteModal(false); @@ -98,7 +101,10 @@ export const MigrationTokenPane = () => { setShowCreateModal(false)} + hideModal={() => { + reportInteraction('grafana_e2c_generated_token_modal_dismissed'); + setShowCreateModal(false); + }} migrationToken={createTokenResponse.data?.token} /> diff --git a/public/app/features/migrate-to-cloud/onprem/NameCell.tsx b/public/app/features/migrate-to-cloud/onprem/NameCell.tsx index b69d622dcd1..b95c7403267 100644 --- a/public/app/features/migrate-to-cloud/onprem/NameCell.tsx +++ b/public/app/features/migrate-to-cloud/onprem/NameCell.tsx @@ -35,6 +35,8 @@ function ResourceInfo({ data }: { data: ResourceTableItem }) { return ; case 'FOLDER': return ; + case 'LIBRARY_ELEMENT': + return null; } } diff --git a/public/app/features/plugins/admin/components/UpdateAllModal.tsx b/public/app/features/plugins/admin/components/UpdateAllModal.tsx index 6dfa1758a32..d0c07295f59 100644 --- a/public/app/features/plugins/admin/components/UpdateAllModal.tsx +++ b/public/app/features/plugins/admin/components/UpdateAllModal.tsx @@ -1,14 +1,13 @@ -import { css } from '@emotion/css'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; -import { GrafanaTheme2 } from '@grafana/data'; import { config, reportInteraction } from '@grafana/runtime'; -import { Checkbox, ConfirmModal, EmptyState, Icon, Spinner, Tooltip, useStyles2 } from '@grafana/ui'; -import { t, Trans } from 'app/core/internationalization'; +import { ConfirmModal } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; import { useInstall, useInstallStatus } from '../state/hooks'; import { CatalogPlugin } from '../types'; +import { UpdateModalBody } from './UpdateAllModalBody'; const PLUGINS_UPDATE_ALL_INTERACTION_EVENT_NAME = 'plugins_update_all_clicked'; type UpdateError = { @@ -16,150 +15,6 @@ type UpdateError = { message: string; }; -function getIcon({ - id, - inProgress, - errorMap, - selectedPlugins, -}: { - id: string; - inProgress: boolean; - errorMap: Map; - selectedPlugins?: Set; -}) { - if (errorMap && errorMap.has(id)) { - return ( - - - - ); - } - if (inProgress && selectedPlugins?.has(id)) { - return ; - } - return ''; -} - -const getStyles = (theme: GrafanaTheme2) => ({ - table: css({ - marginTop: theme.spacing(2), - width: '100%', - borderCollapse: 'collapse', - }), - tableRow: css({ - borderBottom: `1px solid ${theme.colors.border.weak}`, - td: { - paddingRight: theme.spacing(1), - }, - }), - icon: css({ - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - }), - header: css({ - textAlign: 'left', - padding: theme.spacing(1), - borderBottom: `2px solid ${theme.colors.border.strong}`, - th: { - paddingRight: theme.spacing(1), - }, - }), - data: css({ - padding: '10px', - }), - footer: css({ - fontSize: theme.typography.bodySmall.fontSize, - marginTop: theme.spacing(3), - }), - noPluginsMessage: css({ - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - height: '100%', - }), - tableContainer: css({ - overflowY: 'auto', - overflowX: 'hidden', - height: theme.spacing(32), - }), - modalContainer: css({ - height: theme.spacing(41), - }), -}); - -type ModalBodyProps = { - plugins: CatalogPlugin[]; - inProgress: boolean; - selectedPlugins?: Set; - onCheckboxChange: (id: string) => void; - errorMap: Map; -}; - -const ModalBody = ({ plugins, inProgress, selectedPlugins, onCheckboxChange, errorMap }: ModalBodyProps) => { - const styles = useStyles2(getStyles); - - return ( -
    - {plugins.length === 0 ? ( - - ) : ( - <> -
    - The following plugins have update available -
    -
    - - - - - - - - - - - - {plugins.map(({ id, name, installedVersion, latestVersion }: CatalogPlugin) => ( - - - - - - - - ))} - -
    - Update - - Name - - Installed - - Available -
    - onCheckboxChange(id)} value={selectedPlugins?.has(id)} /> - {name}{installedVersion}{latestVersion}{getIcon({ id, inProgress, errorMap, selectedPlugins })}
    -
    - {config.pluginAdminExternalManageEnabled && config.featureToggles.managedPluginsInstall && ( -
    - - * It may take a few minutes for the plugins to be available for usage. - -
    - )} - - )} -
    - ); -}; - type Props = { isOpen: boolean; isLoading: boolean; @@ -173,10 +28,19 @@ export const UpdateAllModal = ({ isOpen, onDismiss, isLoading, plugins }: Props) const [errorMap, setErrorMap] = useState(new Map()); const [inProgress, setInProgress] = useState(false); const [selectedPlugins, setSelectedPlugins] = useState>(); + const initialPluginsRef = useRef(plugins); const pluginsSet = useMemo(() => new Set(plugins.map((plugin) => plugin.id)), [plugins]); const installsRemaining = plugins.length; + // Since the plugins comes from the store and changes every time we update a plugin, + // we need to keep track of the initial plugins. + useEffect(() => { + if (initialPluginsRef.current.length === 0) { + initialPluginsRef.current = [...plugins]; + } + }, [plugins]); + // Updates the component state on every plugins change, since the installation will change the store content useEffect(() => { if (inProgress) { @@ -245,6 +109,7 @@ export const UpdateAllModal = ({ isOpen, onDismiss, isLoading, plugins }: Props) }; const onDismissClick = () => { + initialPluginsRef.current = []; setErrorMap(new Map()); setInProgress(false); setSelectedPlugins(undefined); @@ -277,8 +142,9 @@ export const UpdateAllModal = ({ isOpen, onDismiss, isLoading, plugins }: Props) isOpen={isOpen} title={t('plugins.catalog.update-all.modal-title', 'Update Plugins')} body={ - 0 ? onConfirm : onDismissClick} onDismiss={onDismissClick} - disabled={pluginsSelected === 0 || inProgress} - confirmText={ - installsRemaining > 0 - ? `${t('plugins.catalog.update-all.modal-confirmation', 'Update')} (${pluginsSelected})` - : t('plugins.catalog.update-all.modal-dismiss', 'Close') - } + disabled={shouldDisableConfirm(inProgress, installsRemaining, pluginsSelected)} + confirmText={getConfirmationText(installsRemaining, inProgress, pluginsSelected)} + confirmButtonVariant="primary" /> ); }; +function getConfirmationText(installsRemaining: number, inProgress: boolean, pluginsSelected: number) { + if (inProgress) { + return t('plugins.catalog.update-all.modal-in-progress', 'Updating...'); + } + + if (installsRemaining > 0) { + return t('plugins.catalog.update-all.modal-confirmation', 'Update') + ` (${pluginsSelected})`; + } + return t('plugins.catalog.update-all.modal-dismiss', 'Close'); +} + +function shouldDisableConfirm(inProgress: boolean, installsRemaining: number, pluginsSelected: number) { + if (inProgress) { + return true; + } + + if (installsRemaining > 0 && pluginsSelected === 0) { + return true; + } + + return false; +} + export default UpdateAllModal; diff --git a/public/app/features/plugins/admin/components/UpdateAllModalBody.tsx b/public/app/features/plugins/admin/components/UpdateAllModalBody.tsx new file mode 100644 index 00000000000..20214900d3a --- /dev/null +++ b/public/app/features/plugins/admin/components/UpdateAllModalBody.tsx @@ -0,0 +1,208 @@ +import { css } from '@emotion/css'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { config } from '@grafana/runtime'; +import { Checkbox, EmptyState, Icon, Spinner, Tooltip, useStyles2 } from '@grafana/ui'; +import { t, Trans } from 'app/core/internationalization'; + +import { CatalogPlugin } from '../types'; + +type UpdateError = { + id: string; + message: string; +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + table: css({ + marginTop: theme.spacing(2), + width: '100%', + borderCollapse: 'collapse', + }), + tableRow: css({ + borderBottom: `1px solid ${theme.colors.border.weak}`, + td: { + paddingRight: theme.spacing(1), + }, + }), + icon: css({ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + }), + header: css({ + textAlign: 'left', + padding: theme.spacing(1), + borderBottom: `2px solid ${theme.colors.border.strong}`, + th: { + paddingRight: theme.spacing(1), + }, + }), + data: css({ + padding: '10px', + }), + footer: css({ + fontSize: theme.typography.bodySmall.fontSize, + marginTop: theme.spacing(3), + }), + noPluginsMessage: css({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: '100%', + }), + tableContainer: css({ + overflowY: 'auto', + overflowX: 'hidden', + maxHeight: theme.spacing(41), + marginBottom: theme.spacing(2), + }), + errorIcon: css({ + color: theme.colors.error.main, + }), + successIcon: css({ + color: theme.colors.success.main, + }), + pluginsInstalled: css({ + svg: { + marginRight: theme.spacing(1), + }, + }), +}); + +const StatusIcon = ({ + id, + inProgress, + isSelected, + isInstalled, + errorMap, +}: { + id: string; + inProgress: boolean; + isSelected: boolean; + isInstalled: boolean; + errorMap: Map; +}) => { + const styles = useStyles2(getStyles); + + if (errorMap && errorMap.has(id)) { + return ( + + + + ); + } + if (isInstalled) { + return ; + } + if (inProgress && isSelected) { + return ; + } + return ''; +}; + +type Props = { + plugins: CatalogPlugin[]; + pluginsNotInstalled: Set; + inProgress: boolean; + selectedPlugins?: Set; + onCheckboxChange: (id: string) => void; + errorMap: Map; +}; + +export const UpdateModalBody = ({ + plugins, + pluginsNotInstalled, + inProgress, + selectedPlugins, + onCheckboxChange, + errorMap, +}: Props) => { + const styles = useStyles2(getStyles); + + const numberInstalled = plugins.length - pluginsNotInstalled.size; + const installationFinished = plugins.length !== pluginsNotInstalled.size && !inProgress; + + return ( +
    + {plugins.length === 0 ? ( + + ) : ( + <> +
    + The following plugins have update available +
    +
    + + + + + + + + + + + + {plugins.map(({ id, name, installedVersion, latestVersion }: CatalogPlugin) => ( + + + + + + + + ))} + +
    + Update + + Name + + Installed + + Available +
    + onCheckboxChange(id)} + value={selectedPlugins?.has(id)} + disabled={!pluginsNotInstalled.has(id)} + /> + {name}{installedVersion}{latestVersion} + +
    +
    + {numberInstalled > 0 && installationFinished && ( +
    + + {`${numberInstalled} ${t('plugins.catalog.update-all.update-status-text', 'plugins updated')}`} +
    + )} + {errorMap.size > 0 && installationFinished && ( +
    + + {`${errorMap.size} ${t('plugins.catalog.update-all.error-status-text', 'failed - see error messages')}`} +
    + )} + {config.pluginAdminExternalManageEnabled && config.featureToggles.managedPluginsInstall && ( +
    + + * It may take a few minutes for the plugins to be available for usage. + +
    + )} + + )} +
    + ); +}; diff --git a/public/app/features/plugins/datasource_srv.ts b/public/app/features/plugins/datasource_srv.ts index 4ac58e3adfd..a71f46e5f9b 100644 --- a/public/app/features/plugins/datasource_srv.ts +++ b/public/app/features/plugins/datasource_srv.ts @@ -299,7 +299,7 @@ export class DatasourceSrv implements DataSourceService { if (!filters.tracing) { const grafanaInstanceSettings = this.getInstanceSettings('-- Grafana --'); - if (grafanaInstanceSettings) { + if (grafanaInstanceSettings && filters.filter?.(grafanaInstanceSettings) !== false) { base.push(grafanaInstanceSettings); } } diff --git a/public/app/features/plugins/tests/datasource_srv.test.ts b/public/app/features/plugins/tests/datasource_srv.test.ts index f9ec6eba97e..9d9dac7b553 100644 --- a/public/app/features/plugins/tests/datasource_srv.test.ts +++ b/public/app/features/plugins/tests/datasource_srv.test.ts @@ -297,6 +297,11 @@ describe('datasource_srv', () => { expect(list[2].name).toBe('${datasource}'); }); + it('Should filter out the -- Grafana -- datasource', () => { + const list = dataSourceSrv.getList({ filter: (x) => x.name !== '-- Grafana --' }); + expect(list.find((x) => x.name === '-- Grafana --')).toBeUndefined(); + }); + it('Can get list of data sources with tracing: true', () => { const list = dataSourceSrv.getList({ tracing: true }); expect(list[0].name).toBe('Jaeger'); diff --git a/public/app/features/query/components/QueryEditorRow.tsx b/public/app/features/query/components/QueryEditorRow.tsx index 637817cfad6..fb76109bc45 100644 --- a/public/app/features/query/components/QueryEditorRow.tsx +++ b/public/app/features/query/components/QueryEditorRow.tsx @@ -2,8 +2,8 @@ import classNames from 'classnames'; import { cloneDeep, filter, has, uniqBy, uniqueId } from 'lodash'; import pluralize from 'pluralize'; -import { PureComponent, ReactNode } from 'react'; import * as React from 'react'; +import { PureComponent, ReactNode } from 'react'; // Utils & Services import { @@ -35,7 +35,7 @@ import { QueryOperationRow, QueryOperationRowRenderProps, } from 'app/core/components/QueryOperationRow/QueryOperationRow'; -import { t, Trans } from 'app/core/internationalization'; +import { Trans, t } from 'app/core/internationalization'; import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; import { PanelModel } from 'app/features/dashboard/state/PanelModel'; @@ -64,6 +64,7 @@ export interface Props { history?: Array>; eventBus?: EventBusExtended; alerting?: boolean; + hideActionButtons?: boolean; onQueryCopied?: () => void; onQueryRemoved?: () => void; onQueryToggled?: (queryStatus?: boolean | undefined) => void; @@ -527,7 +528,7 @@ export class QueryEditorRow extends PureComponent extends PureComponent
    diff --git a/public/app/features/teams/state/navModel.ts b/public/app/features/teams/state/navModel.ts index c3fe188968a..3a676de1c28 100644 --- a/public/app/features/teams/state/navModel.ts +++ b/public/app/features/teams/state/navModel.ts @@ -23,7 +23,7 @@ export function buildNavModel(team: Team): NavModelItem { img: team.avatarUrl, id: 'team-' + team.id, subTitle: 'Manage members and settings', - url: '', + url: `org/teams/edit/${team.id}`, text: team.name, children: [ // With RBAC this tab will always be available (but not always editable) diff --git a/public/app/features/trails/AutomaticMetricQueries/AutoVizPanel.tsx b/public/app/features/trails/AutomaticMetricQueries/AutoVizPanel.tsx index 17b59f21322..9a6b164ca7f 100644 --- a/public/app/features/trails/AutomaticMetricQueries/AutoVizPanel.tsx +++ b/public/app/features/trails/AutomaticMetricQueries/AutoVizPanel.tsx @@ -1,7 +1,7 @@ import { SceneObjectState, SceneObjectBase, SceneComponentProps, VizPanel, SceneQueryRunner } from '@grafana/scenes'; import { RadioButtonGroup } from '@grafana/ui'; -import { trailDS } from '../shared'; +import { MDP_METRIC_OVERVIEW, trailDS } from '../shared'; import { getMetricSceneFor } from '../utils'; import { AutoQueryDef } from './types'; @@ -52,7 +52,7 @@ export class AutoVizPanel extends SceneObjectBase { .setData( new SceneQueryRunner({ datasource: trailDS, - maxDataPoints: 500, + maxDataPoints: MDP_METRIC_OVERVIEW, queries: def.queries, }) ) diff --git a/public/app/features/trails/Breakdown/LabelBreakdownScene.tsx b/public/app/features/trails/Breakdown/LabelBreakdownScene.tsx index fc1b8d3f9c8..620e5a05766 100644 --- a/public/app/features/trails/Breakdown/LabelBreakdownScene.tsx +++ b/public/app/features/trails/Breakdown/LabelBreakdownScene.tsx @@ -33,7 +33,7 @@ import { MetricScene } from '../MetricScene'; import { StatusWrapper } from '../StatusWrapper'; import { reportExploreMetrics } from '../interactions'; import { ALL_VARIABLE_VALUE } from '../services/variables'; -import { trailDS, VAR_FILTERS, VAR_GROUP_BY, VAR_GROUP_BY_EXP } from '../shared'; +import { MDP_METRIC_PREVIEW, trailDS, VAR_FILTERS, VAR_GROUP_BY, VAR_GROUP_BY_EXP } from '../shared'; import { getColorByIndex, getTrailFor } from '../utils'; import { AddToFiltersGraphAction } from './AddToFiltersGraphAction'; @@ -304,7 +304,7 @@ export function buildAllLayout( .setTitle(option.label!) .setData( new SceneQueryRunner({ - maxDataPoints: 250, + maxDataPoints: MDP_METRIC_PREVIEW, datasource: trailDS, queries: [ { @@ -395,7 +395,7 @@ function buildNormalLayout( return new LayoutSwitcher({ $data: new SceneQueryRunner({ datasource: trailDS, - maxDataPoints: 300, + maxDataPoints: MDP_METRIC_PREVIEW, queries: queryDef.queries, }), breakdownLayoutOptions: [ diff --git a/public/app/features/trails/DataTrailsHistory.test.tsx b/public/app/features/trails/DataTrailsHistory.test.tsx index 13e65c6adce..24e27dadc23 100644 --- a/public/app/features/trails/DataTrailsHistory.test.tsx +++ b/public/app/features/trails/DataTrailsHistory.test.tsx @@ -24,7 +24,7 @@ describe('DataTrailsHistory', () => { { name: 'from history', input: { from: '2024-07-22T18:30:00.000Z', to: '2024-07-22T19:30:00.000Z' }, - expected: '2024-07-22 12:30:00 - 2024-07-22 13:30:00', + expected: '2024-07-22 13:30:00 - 2024-07-22 14:30:00', }, { name: 'time change event with timezone', @@ -33,7 +33,7 @@ describe('DataTrailsHistory', () => { }, ])('$name', ({ input, expected }) => { const result = parseTimeTooltip(input); - expect(result).toBe(expected); + expect(result).toEqual(expected); }); }); diff --git a/public/app/features/trails/MetricSelect/MetricSelectScene.tsx b/public/app/features/trails/MetricSelect/MetricSelectScene.tsx index f6a5001689e..1a28aac9146 100644 --- a/public/app/features/trails/MetricSelect/MetricSelectScene.tsx +++ b/public/app/features/trails/MetricSelect/MetricSelectScene.tsx @@ -287,8 +287,7 @@ export class MetricSelectScene extends SceneObjectBase i } if (missingOtelTargets) { - metricNamesWarning += - 'The list of metrics is not complete. Select more OTel resource attributes to see a full list of metrics.'; + metricNamesWarning = `${metricNamesWarning ?? ''} The list of metrics is not complete. Select more OTel resource attributes to see a full list of metrics.`; } let bodyLayout = this.state.body; diff --git a/public/app/features/trails/MetricSelect/previewPanel.ts b/public/app/features/trails/MetricSelect/previewPanel.ts index 2300a8eb88d..eda861dfa2b 100644 --- a/public/app/features/trails/MetricSelect/previewPanel.ts +++ b/public/app/features/trails/MetricSelect/previewPanel.ts @@ -2,7 +2,7 @@ import { PromQuery } from '@grafana/prometheus'; import { SceneCSSGridItem, SceneQueryRunner, SceneVariableSet } from '@grafana/scenes'; import { getAutoQueriesForMetric } from '../AutomaticMetricQueries/AutoQueryEngine'; -import { getVariablesWithMetricConstant, trailDS } from '../shared'; +import { getVariablesWithMetricConstant, MDP_METRIC_PREVIEW, trailDS } from '../shared'; import { getColorByIndex } from '../utils'; import { SelectMetricAction } from './SelectMetricAction'; @@ -29,7 +29,7 @@ export function getPreviewPanelFor(metric: string, index: number, currentFilterC $behaviors: [hideEmptyPreviews(metric)], $data: new SceneQueryRunner({ datasource: trailDS, - maxDataPoints: 200, + maxDataPoints: MDP_METRIC_PREVIEW, queries, }), body: vizPanel, diff --git a/public/app/features/trails/shared.ts b/public/app/features/trails/shared.ts index ed23c24cfbe..9bad5b0b449 100644 --- a/public/app/features/trails/shared.ts +++ b/public/app/features/trails/shared.ts @@ -37,11 +37,12 @@ export const trailDS = { uid: VAR_DATASOURCE_EXPR }; // Local storage keys export const RECENT_TRAILS_KEY = 'grafana.trails.recent'; - export const TRAIL_BOOKMARKS_KEY = 'grafana.trails.bookmarks'; - export const TRAIL_BREAKDOWN_VIEW_KEY = 'grafana.trails.breakdown.view'; +export const MDP_METRIC_PREVIEW = 250; +export const MDP_METRIC_OVERVIEW = 500; + export type MakeOptional = Pick, K> & Omit; export function getVariablesWithMetricConstant(metric: string) { diff --git a/public/app/features/transformers/editors/ConvertFieldTypeTransformerEditor.tsx b/public/app/features/transformers/editors/ConvertFieldTypeTransformerEditor.tsx index db059c21230..77a981540e4 100644 --- a/public/app/features/transformers/editors/ConvertFieldTypeTransformerEditor.tsx +++ b/public/app/features/transformers/editors/ConvertFieldTypeTransformerEditor.tsx @@ -172,22 +172,21 @@ export const ConvertFieldTypeTransformerEditor = ({ )} - {c.dateFormat || - (targetField?.type === FieldType.time && ( - <> - - - - - + + +