Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 79a61a2b63 | |||
| dc4c106e91 | |||
| 33a1c60433 | |||
| 521670981a | |||
| 79ca4e5aec | |||
| e3bc61e7d2 | |||
| cc6a75d021 | |||
| 6d0f7f3567 | |||
| 913c0ba3c5 | |||
| 552b6aa717 | |||
| 2ddb4049c6 | |||
| 318a0ebb36 |
+1
-1
@@ -519,7 +519,7 @@ i18next.config.ts @grafana/grafana-frontend-platform
|
|||||||
/e2e-playwright/various-suite/solo-route.spec.ts @grafana/dashboards-squad
|
/e2e-playwright/various-suite/solo-route.spec.ts @grafana/dashboards-squad
|
||||||
/e2e-playwright/various-suite/trace-view-scrolling.spec.ts @grafana/observability-traces-and-profiling
|
/e2e-playwright/various-suite/trace-view-scrolling.spec.ts @grafana/observability-traces-and-profiling
|
||||||
/e2e-playwright/various-suite/verify-i18n.spec.ts @grafana/grafana-frontend-platform
|
/e2e-playwright/various-suite/verify-i18n.spec.ts @grafana/grafana-frontend-platform
|
||||||
/e2e-playwright/various-suite/visualization-suggestions.spec.ts @grafana/dataviz-squad
|
/e2e-playwright/various-suite/visualization-suggestions*.spec.ts @grafana/dataviz-squad
|
||||||
/e2e-playwright/various-suite/perf-test.spec.ts @grafana/grafana-frontend-platform
|
/e2e-playwright/various-suite/perf-test.spec.ts @grafana/grafana-frontend-platform
|
||||||
|
|
||||||
# Packages
|
# Packages
|
||||||
|
|||||||
+1
-1
@@ -157,7 +157,7 @@ require (
|
|||||||
github.com/google/go-querystring v1.1.0 // indirect
|
github.com/google/go-querystring v1.1.0 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/google/wire v0.7.0 // indirect
|
github.com/google/wire v0.7.0 // indirect
|
||||||
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 // indirect
|
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f // indirect
|
||||||
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect
|
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect
|
||||||
github.com/grafana/dataplane/sdata v0.0.9 // indirect
|
github.com/grafana/dataplane/sdata v0.0.9 // indirect
|
||||||
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 // indirect
|
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 // indirect
|
||||||
|
|||||||
+2
-2
@@ -619,8 +619,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
|||||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
||||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
||||||
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ=
|
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts=
|
||||||
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
|
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
|
||||||
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o=
|
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o=
|
||||||
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg=
|
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg=
|
||||||
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY=
|
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY=
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ go 1.25.5
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/go-kit/log v0.2.1
|
github.com/go-kit/log v0.2.1
|
||||||
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196
|
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f
|
||||||
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4
|
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4
|
||||||
github.com/grafana/grafana-app-sdk v0.48.7
|
github.com/grafana/grafana-app-sdk v0.48.7
|
||||||
github.com/grafana/grafana-app-sdk/logging v0.48.7
|
github.com/grafana/grafana-app-sdk/logging v0.48.7
|
||||||
|
|||||||
@@ -243,8 +243,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||||
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ=
|
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts=
|
||||||
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
|
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
|
||||||
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 h1:jSojuc7njleS3UOz223WDlXOinmuLAIPI0z2vtq8EgI=
|
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 h1:jSojuc7njleS3UOz223WDlXOinmuLAIPI0z2vtq8EgI=
|
||||||
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4/go.mod h1:VahT+GtfQIM+o8ht2StR6J9g+Ef+C2Vokh5uuSmOD/4=
|
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4/go.mod h1:VahT+GtfQIM+o8ht2StR6J9g+Ef+C2Vokh5uuSmOD/4=
|
||||||
github.com/grafana/grafana-app-sdk v0.48.7 h1:9mF7nqkqP0QUYYDlznoOt+GIyjzj45wGfUHB32u2ZMo=
|
github.com/grafana/grafana-app-sdk v0.48.7 h1:9mF7nqkqP0QUYYDlznoOt+GIyjzj45wGfUHB32u2ZMo=
|
||||||
|
|||||||
+1
-1
@@ -223,7 +223,7 @@ require (
|
|||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
|
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
|
||||||
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
|
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
|
||||||
github.com/gorilla/mux v1.8.1 // indirect
|
github.com/gorilla/mux v1.8.1 // indirect
|
||||||
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 // indirect
|
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f // indirect
|
||||||
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect
|
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect
|
||||||
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // indirect
|
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // indirect
|
||||||
github.com/grafana/dataplane/sdata v0.0.9 // indirect
|
github.com/grafana/dataplane/sdata v0.0.9 // indirect
|
||||||
|
|||||||
+2
-2
@@ -827,8 +827,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
|||||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
||||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
||||||
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ=
|
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts=
|
||||||
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
|
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
|
||||||
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o=
|
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o=
|
||||||
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg=
|
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg=
|
||||||
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY=
|
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY=
|
||||||
|
|||||||
+1
-1
@@ -90,7 +90,7 @@ require (
|
|||||||
github.com/google/gnostic-models v0.7.1 // indirect
|
github.com/google/gnostic-models v0.7.1 // indirect
|
||||||
github.com/google/go-cmp v0.7.0 // indirect
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 // indirect
|
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f // indirect
|
||||||
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect
|
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect
|
||||||
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // indirect
|
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // indirect
|
||||||
github.com/grafana/dataplane/sdata v0.0.9 // indirect
|
github.com/grafana/dataplane/sdata v0.0.9 // indirect
|
||||||
|
|||||||
+2
-2
@@ -213,8 +213,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
||||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
||||||
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ=
|
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts=
|
||||||
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
|
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
|
||||||
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o=
|
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o=
|
||||||
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg=
|
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg=
|
||||||
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY=
|
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY=
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ To convert data source-managed alert rules to Grafana managed alerts:
|
|||||||
|
|
||||||
Pausing stops alert rule evaluation behavior for the newly created Grafana-managed alert rules.
|
Pausing stops alert rule evaluation behavior for the newly created Grafana-managed alert rules.
|
||||||
|
|
||||||
9. (Optional) In the **Target data source** of the **Recording rules** section, you can select the data source that the imported recording rules will query. By default, it is the data source selected in the **Data source** dropdown.
|
9. (Optional) In the **Target data source** of the **Recording rules** section, you can select the data source to which the imported recording rules will write metrics. By default, it is the data source selected in the **Data source** dropdown.
|
||||||
|
|
||||||
10. Click **Import**.
|
10. Click **Import**.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { BootData } from '@grafana/data';
|
import { BootData, PanelPluginMeta } from '@grafana/data';
|
||||||
import { test, expect } from '@grafana/plugin-e2e';
|
import { test, expect } from '@grafana/plugin-e2e';
|
||||||
|
|
||||||
test.describe(
|
test.describe(
|
||||||
@@ -22,7 +22,7 @@ test.describe(
|
|||||||
await dashboardPage.addPanel();
|
await dashboardPage.addPanel();
|
||||||
|
|
||||||
// Get panel types from window object
|
// Get panel types from window object
|
||||||
const panelTypes = await page.evaluate(() => {
|
const panelTypes: PanelPluginMeta[] = await page.evaluate(() => {
|
||||||
// @grafana/plugin-e2e doesn't export the full bootdata config
|
// @grafana/plugin-e2e doesn't export the full bootdata config
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
const win = window as typeof window & { grafanaBootData: BootData };
|
const win = window as typeof window & { grafanaBootData: BootData };
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { test, expect } from '@grafana/plugin-e2e';
|
||||||
|
|
||||||
|
test.use({
|
||||||
|
featureToggles: {
|
||||||
|
newVizSuggestions: true,
|
||||||
|
externalVizSuggestions: false,
|
||||||
|
},
|
||||||
|
viewport: {
|
||||||
|
width: 800,
|
||||||
|
height: 1500,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe(
|
||||||
|
'Visualization suggestions v2',
|
||||||
|
{
|
||||||
|
tag: ['@various', '@suggestions'],
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
test('Should be shown and clickable', async ({ selectors, gotoPanelEditPage }) => {
|
||||||
|
// Open dashboard with edit panel
|
||||||
|
const panelEditPage = await gotoPanelEditPage({
|
||||||
|
dashboard: {
|
||||||
|
uid: 'aBXrJ0R7z',
|
||||||
|
},
|
||||||
|
id: '9',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
panelEditPage.getByGrafanaSelector(selectors.components.Panels.Panel.content).locator('.uplot'),
|
||||||
|
'time series to be rendered inside panel'
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Try visualization suggestions
|
||||||
|
await panelEditPage.getByGrafanaSelector(selectors.components.PanelEditor.toggleVizPicker).click();
|
||||||
|
await panelEditPage.getByGrafanaSelector(selectors.components.Tab.title('Suggestions')).click();
|
||||||
|
|
||||||
|
// Verify we see suggestions
|
||||||
|
await expect(
|
||||||
|
panelEditPage.getByGrafanaSelector(selectors.components.VisualizationPreview.card('Line chart')),
|
||||||
|
'line chart suggestion to be rendered'
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// TODO: in this part of the test, we will change the query and the transforms and observe suggestions being updated.
|
||||||
|
|
||||||
|
// Select a visualization and verify table header is visible from preview
|
||||||
|
await panelEditPage.getByGrafanaSelector(selectors.components.VisualizationPreview.card('Table')).click();
|
||||||
|
await expect(
|
||||||
|
panelEditPage
|
||||||
|
.getByGrafanaSelector(selectors.components.Panels.Panel.content)
|
||||||
|
.getByRole('grid')
|
||||||
|
.getByRole('row')
|
||||||
|
.first(),
|
||||||
|
'table to be rendered inside panel'
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
panelEditPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.discardChangesButton),
|
||||||
|
'discard changes button disabled since panel has not yet changed'
|
||||||
|
).toBeDisabled();
|
||||||
|
|
||||||
|
// apply the suggestion and verify panel options are visible
|
||||||
|
await panelEditPage.getByGrafanaSelector(selectors.components.VisualizationPreview.confirm('Table')).click();
|
||||||
|
await expect(
|
||||||
|
panelEditPage
|
||||||
|
.getByGrafanaSelector(selectors.components.Panels.Panel.content)
|
||||||
|
.getByRole('grid')
|
||||||
|
.getByRole('row')
|
||||||
|
.first(),
|
||||||
|
'table to be rendered inside panel'
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
panelEditPage.getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.header),
|
||||||
|
'options pane to be rendered'
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
panelEditPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.discardChangesButton),
|
||||||
|
'discard changes button enabled now that panel is dirty'
|
||||||
|
).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not apply suggestion if you navigate toggle the viz picker back off', async ({
|
||||||
|
selectors,
|
||||||
|
gotoPanelEditPage,
|
||||||
|
}) => {
|
||||||
|
// Open dashboard with edit panel
|
||||||
|
const panelEditPage = await gotoPanelEditPage({
|
||||||
|
dashboard: {
|
||||||
|
uid: 'aBXrJ0R7z',
|
||||||
|
},
|
||||||
|
id: '9',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
panelEditPage.getByGrafanaSelector(selectors.components.Panels.Panel.content).locator('.uplot'),
|
||||||
|
'time series to be rendered inside panel;'
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Try visualization suggestions
|
||||||
|
await panelEditPage.getByGrafanaSelector(selectors.components.PanelEditor.toggleVizPicker).click();
|
||||||
|
await panelEditPage.getByGrafanaSelector(selectors.components.Tab.title('Suggestions')).click();
|
||||||
|
|
||||||
|
// Verify we see suggestions
|
||||||
|
await expect(
|
||||||
|
panelEditPage.getByGrafanaSelector(selectors.components.VisualizationPreview.card('Line chart')),
|
||||||
|
'line chart suggestion to be rendered'
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Select a visualization
|
||||||
|
await panelEditPage.getByGrafanaSelector(selectors.components.VisualizationPreview.card('Table')).click();
|
||||||
|
await expect(
|
||||||
|
panelEditPage
|
||||||
|
.getByGrafanaSelector(selectors.components.Panels.Panel.content)
|
||||||
|
.getByRole('grid')
|
||||||
|
.getByRole('row')
|
||||||
|
.first(),
|
||||||
|
'table to be rendered inside panel'
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
panelEditPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.discardChangesButton)
|
||||||
|
).toBeDisabled();
|
||||||
|
|
||||||
|
// Verify that toggling the viz picker back cancels the suggestion, restores the line chart, shows panel options
|
||||||
|
await panelEditPage.getByGrafanaSelector(selectors.components.PanelEditor.toggleVizPicker).click();
|
||||||
|
await expect(
|
||||||
|
panelEditPage.getByGrafanaSelector(selectors.components.Panels.Panel.content).locator('.uplot'),
|
||||||
|
'time series to be rendered inside panel'
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
panelEditPage.getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.header),
|
||||||
|
'options pane to be rendered'
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
panelEditPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.discardChangesButton),
|
||||||
|
'discard changes button is still disabled since no changes were applied'
|
||||||
|
).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not apply suggestion if you navigate back to the dashboard', async ({
|
||||||
|
page,
|
||||||
|
selectors,
|
||||||
|
gotoPanelEditPage,
|
||||||
|
}) => {
|
||||||
|
// Open dashboard with edit panel
|
||||||
|
const panelEditPage = await gotoPanelEditPage({
|
||||||
|
dashboard: {
|
||||||
|
uid: 'aBXrJ0R7z',
|
||||||
|
},
|
||||||
|
id: '9',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Try visualization suggestions
|
||||||
|
await panelEditPage.getByGrafanaSelector(selectors.components.PanelEditor.toggleVizPicker).click();
|
||||||
|
await panelEditPage.getByGrafanaSelector(selectors.components.Tab.title('Suggestions')).click();
|
||||||
|
|
||||||
|
// Verify we see suggestions
|
||||||
|
await expect(
|
||||||
|
panelEditPage.getByGrafanaSelector(selectors.components.VisualizationPreview.card('Line chart')),
|
||||||
|
'line chart suggestion to be rendered'
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Select a visualization
|
||||||
|
await panelEditPage.getByGrafanaSelector(selectors.components.VisualizationPreview.card('Table')).click();
|
||||||
|
await expect(page.getByRole('grid').getByRole('row').first(), 'table row to be rendered').toBeVisible();
|
||||||
|
await expect(
|
||||||
|
panelEditPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.discardChangesButton)
|
||||||
|
).toBeDisabled();
|
||||||
|
|
||||||
|
// Verify that navigating back to the dashboard cancels the suggestion and restores the line chart.
|
||||||
|
await panelEditPage
|
||||||
|
.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.backToDashboardButton)
|
||||||
|
.click();
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-viz-panel-key="panel-9"]').locator('.uplot'),
|
||||||
|
'time series to be rendered inside the panel'
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -3,7 +3,7 @@ import { test, expect } from '@grafana/plugin-e2e';
|
|||||||
test.describe(
|
test.describe(
|
||||||
'Visualization suggestions',
|
'Visualization suggestions',
|
||||||
{
|
{
|
||||||
tag: ['@various'],
|
tag: ['@various', '@suggestions'],
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
test('Should be shown and clickable', async ({ page, selectors, gotoPanelEditPage }) => {
|
test('Should be shown and clickable', async ({ page, selectors, gotoPanelEditPage }) => {
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ require (
|
|||||||
github.com/googleapis/gax-go/v2 v2.15.0 // @grafana/grafana-backend-group
|
github.com/googleapis/gax-go/v2 v2.15.0 // @grafana/grafana-backend-group
|
||||||
github.com/gorilla/mux v1.8.1 // @grafana/grafana-backend-group
|
github.com/gorilla/mux v1.8.1 // @grafana/grafana-backend-group
|
||||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // @grafana/grafana-app-platform-squad
|
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // @grafana/grafana-app-platform-squad
|
||||||
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 // @grafana/alerting-backend
|
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f // @grafana/alerting-backend
|
||||||
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // @grafana/identity-access-team
|
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // @grafana/identity-access-team
|
||||||
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // @grafana/identity-access-team
|
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // @grafana/identity-access-team
|
||||||
github.com/grafana/dataplane/examples v0.0.1 // @grafana/observability-metrics
|
github.com/grafana/dataplane/examples v0.0.1 // @grafana/observability-metrics
|
||||||
@@ -181,6 +181,7 @@ require (
|
|||||||
github.com/xlab/treeprint v1.2.0 // @grafana/observability-traces-and-profiling
|
github.com/xlab/treeprint v1.2.0 // @grafana/observability-traces-and-profiling
|
||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // @grafana/grafana-operator-experience-squad
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // @grafana/grafana-operator-experience-squad
|
||||||
github.com/yudai/gojsondiff v1.0.0 // @grafana/grafana-backend-group
|
github.com/yudai/gojsondiff v1.0.0 // @grafana/grafana-backend-group
|
||||||
|
go.etcd.io/bbolt v1.4.2 // @grafana/grafana-search-and-storage
|
||||||
go.opentelemetry.io/collector/pdata v1.44.0 // @grafana/grafana-backend-group
|
go.opentelemetry.io/collector/pdata v1.44.0 // @grafana/grafana-backend-group
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 // @grafana/plugins-platform-backend
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 // @grafana/plugins-platform-backend
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.63.0 // @grafana/grafana-operator-experience-squad
|
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.63.0 // @grafana/grafana-operator-experience-squad
|
||||||
@@ -603,7 +604,6 @@ require (
|
|||||||
github.com/yuin/gopher-lua v1.1.1 // indirect
|
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||||
github.com/zclconf/go-cty v1.16.3 // indirect
|
github.com/zclconf/go-cty v1.16.3 // indirect
|
||||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||||
go.etcd.io/bbolt v1.4.2 // indirect
|
|
||||||
go.etcd.io/etcd/api/v3 v3.6.6 // indirect
|
go.etcd.io/etcd/api/v3 v3.6.6 // indirect
|
||||||
go.etcd.io/etcd/client/pkg/v3 v3.6.6 // indirect
|
go.etcd.io/etcd/client/pkg/v3 v3.6.6 // indirect
|
||||||
go.etcd.io/etcd/client/v3 v3.6.6 // indirect
|
go.etcd.io/etcd/client/v3 v3.6.6 // indirect
|
||||||
|
|||||||
@@ -1622,8 +1622,8 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg
|
|||||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
||||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
||||||
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ=
|
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts=
|
||||||
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
|
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
|
||||||
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o=
|
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o=
|
||||||
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg=
|
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg=
|
||||||
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY=
|
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY=
|
||||||
|
|||||||
@@ -1293,6 +1293,9 @@ export const versionedComponents = {
|
|||||||
card: {
|
card: {
|
||||||
[MIN_GRAFANA_VERSION]: (name: string) => `data-testid suggestion-${name}`,
|
[MIN_GRAFANA_VERSION]: (name: string) => `data-testid suggestion-${name}`,
|
||||||
},
|
},
|
||||||
|
confirm: {
|
||||||
|
'12.4.0': (name: string) => `data-testid suggestion-${name} confirm button`,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
ColorSwatch: {
|
ColorSwatch: {
|
||||||
name: {
|
name: {
|
||||||
|
|||||||
Generated
+2
-2
@@ -847,7 +847,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
zanzanaReconciler := dualwrite2.ProvideZanzanaReconciler(cfg, featureToggles, zanzanaClient, sqlStore, serverLockService, folderimplService)
|
zanzanaReconciler := dualwrite2.ProvideZanzanaReconciler(cfg, featureToggles, zanzanaClient, sqlStore, serverLockService, folderimplService, registerer)
|
||||||
investigationsAppProvider := investigations.RegisterApp(cfg)
|
investigationsAppProvider := investigations.RegisterApp(cfg)
|
||||||
appregistryService, err := appregistry.ProvideBuilderRunners(apiserverService, eventualRestConfigProvider, featureToggles, investigationsAppProvider, cfg)
|
appregistryService, err := appregistry.ProvideBuilderRunners(apiserverService, eventualRestConfigProvider, featureToggles, investigationsAppProvider, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1509,7 +1509,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
zanzanaReconciler := dualwrite2.ProvideZanzanaReconciler(cfg, featureToggles, zanzanaClient, sqlStore, serverLockService, folderimplService)
|
zanzanaReconciler := dualwrite2.ProvideZanzanaReconciler(cfg, featureToggles, zanzanaClient, sqlStore, serverLockService, folderimplService, registerer)
|
||||||
investigationsAppProvider := investigations.RegisterApp(cfg)
|
investigationsAppProvider := investigations.RegisterApp(cfg)
|
||||||
appregistryService, err := appregistry.ProvideBuilderRunners(apiserverService, eventualRestConfigProvider, featureToggles, investigationsAppProvider, cfg)
|
appregistryService, err := appregistry.ProvideBuilderRunners(apiserverService, eventualRestConfigProvider, featureToggles, investigationsAppProvider, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package acimpl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ossBasicRoleSeedLockName = "oss-ac-basic-role-seeder"
|
||||||
|
ossBasicRoleSeedTimeout = 2 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// refreshBasicRolePermissionsInDB ensures basic role permissions are fully derived from in-memory registrations
|
||||||
|
func (s *Service) refreshBasicRolePermissionsInDB(ctx context.Context, rolesSnapshot map[string][]accesscontrol.Permission) error {
|
||||||
|
if s.sql == nil || s.seeder == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
run := func(ctx context.Context) error {
|
||||||
|
desired := map[accesscontrol.SeedPermission]struct{}{}
|
||||||
|
for role, permissions := range rolesSnapshot {
|
||||||
|
for _, permission := range permissions {
|
||||||
|
desired[accesscontrol.SeedPermission{BuiltInRole: role, Action: permission.Action, Scope: permission.Scope}] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.seeder.SetDesiredPermissions(desired)
|
||||||
|
return s.seeder.Seed(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.serverLock == nil {
|
||||||
|
return run(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
errLock := s.serverLock.LockExecuteAndRelease(ctx, ossBasicRoleSeedLockName, ossBasicRoleSeedTimeout, func(ctx context.Context) {
|
||||||
|
err = run(ctx)
|
||||||
|
})
|
||||||
|
if errLock != nil {
|
||||||
|
return errLock
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package acimpl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/infra/db"
|
||||||
|
"github.com/grafana/grafana/pkg/infra/localcache"
|
||||||
|
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||||
|
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
|
"github.com/grafana/grafana/pkg/services/accesscontrol/database"
|
||||||
|
"github.com/grafana/grafana/pkg/services/accesscontrol/permreg"
|
||||||
|
"github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions"
|
||||||
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||||
|
"github.com/grafana/grafana/pkg/services/org"
|
||||||
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
|
"github.com/grafana/grafana/pkg/util/testutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIntegration_OSSBasicRolePermissions_PersistAndRefreshOnRegisterFixedRoles(t *testing.T) {
|
||||||
|
testutil.SkipIntegrationTestInShortMode(t)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
sql := db.InitTestDB(t)
|
||||||
|
store := database.ProvideService(sql)
|
||||||
|
|
||||||
|
svc := ProvideOSSService(
|
||||||
|
setting.NewCfg(),
|
||||||
|
store,
|
||||||
|
&resourcepermissions.FakeActionSetSvc{},
|
||||||
|
localcache.ProvideService(),
|
||||||
|
featuremgmt.WithFeatures(),
|
||||||
|
tracing.InitializeTracerForTest(),
|
||||||
|
sql,
|
||||||
|
permreg.ProvidePermissionRegistry(),
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
require.NoError(t, svc.DeclareFixedRoles(accesscontrol.RoleRegistration{
|
||||||
|
Role: accesscontrol.RoleDTO{
|
||||||
|
Name: "fixed:test:role",
|
||||||
|
Permissions: []accesscontrol.Permission{
|
||||||
|
{Action: "test:read", Scope: ""},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Grants: []string{string(org.RoleViewer)},
|
||||||
|
}))
|
||||||
|
|
||||||
|
require.NoError(t, svc.RegisterFixedRoles(ctx))
|
||||||
|
|
||||||
|
// verify permission is persisted to DB for basic:viewer
|
||||||
|
require.NoError(t, sql.WithDbSession(ctx, func(sess *db.Session) error {
|
||||||
|
var role accesscontrol.Role
|
||||||
|
ok, err := sess.Table("role").Where("uid = ?", accesscontrol.BasicRoleUIDPrefix+"viewer").Get(&role)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
count, err = sess.Table("permission").Where("role_id = ? AND action = ? AND scope = ?", role.ID, "test:read", "").Count()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, int64(1), count)
|
||||||
|
return nil
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ensure RegisterFixedRoles refreshes it back to defaults
|
||||||
|
require.NoError(t, sql.WithDbSession(ctx, func(sess *db.Session) error {
|
||||||
|
ts := time.Now()
|
||||||
|
var role accesscontrol.Role
|
||||||
|
ok, err := sess.Table("role").Where("uid = ?", accesscontrol.BasicRoleUIDPrefix+"viewer").Get(&role)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
_, err = sess.Exec("DELETE FROM permission WHERE role_id = ?", role.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
p := accesscontrol.Permission{
|
||||||
|
RoleID: role.ID,
|
||||||
|
Action: "custom:keep",
|
||||||
|
Scope: "",
|
||||||
|
Created: ts,
|
||||||
|
Updated: ts,
|
||||||
|
}
|
||||||
|
p.Kind, p.Attribute, p.Identifier = accesscontrol.SplitScope(p.Scope)
|
||||||
|
_, err = sess.Table("permission").Insert(&p)
|
||||||
|
return err
|
||||||
|
}))
|
||||||
|
|
||||||
|
svc2 := ProvideOSSService(
|
||||||
|
setting.NewCfg(),
|
||||||
|
store,
|
||||||
|
&resourcepermissions.FakeActionSetSvc{},
|
||||||
|
localcache.ProvideService(),
|
||||||
|
featuremgmt.WithFeatures(),
|
||||||
|
tracing.InitializeTracerForTest(),
|
||||||
|
sql,
|
||||||
|
permreg.ProvidePermissionRegistry(),
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
require.NoError(t, svc2.DeclareFixedRoles(accesscontrol.RoleRegistration{
|
||||||
|
Role: accesscontrol.RoleDTO{
|
||||||
|
Name: "fixed:test:role",
|
||||||
|
Permissions: []accesscontrol.Permission{
|
||||||
|
{Action: "test:read", Scope: ""},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Grants: []string{string(org.RoleViewer)},
|
||||||
|
}))
|
||||||
|
require.NoError(t, svc2.RegisterFixedRoles(ctx))
|
||||||
|
|
||||||
|
require.NoError(t, sql.WithDbSession(ctx, func(sess *db.Session) error {
|
||||||
|
var role accesscontrol.Role
|
||||||
|
ok, err := sess.Table("role").Where("uid = ?", accesscontrol.BasicRoleUIDPrefix+"viewer").Get(&role)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
count, err = sess.Table("permission").Where("role_id = ? AND action = ? AND scope = ?", role.ID, "test:read", "").Count()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, int64(1), count)
|
||||||
|
|
||||||
|
count, err = sess.Table("permission").Where("role_id = ? AND action = ?", role.ID, "custom:keep").Count()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, int64(0), count)
|
||||||
|
return nil
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/services/accesscontrol/migrator"
|
"github.com/grafana/grafana/pkg/services/accesscontrol/migrator"
|
||||||
"github.com/grafana/grafana/pkg/services/accesscontrol/permreg"
|
"github.com/grafana/grafana/pkg/services/accesscontrol/permreg"
|
||||||
"github.com/grafana/grafana/pkg/services/accesscontrol/pluginutils"
|
"github.com/grafana/grafana/pkg/services/accesscontrol/pluginutils"
|
||||||
|
"github.com/grafana/grafana/pkg/services/accesscontrol/seeding"
|
||||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||||
"github.com/grafana/grafana/pkg/services/folder"
|
"github.com/grafana/grafana/pkg/services/folder"
|
||||||
@@ -96,6 +97,12 @@ func ProvideOSSService(
|
|||||||
roles: accesscontrol.BuildBasicRoleDefinitions(),
|
roles: accesscontrol.BuildBasicRoleDefinitions(),
|
||||||
store: store,
|
store: store,
|
||||||
permRegistry: permRegistry,
|
permRegistry: permRegistry,
|
||||||
|
sql: db,
|
||||||
|
serverLock: lock,
|
||||||
|
}
|
||||||
|
|
||||||
|
if backend, ok := store.(*database.AccessControlStore); ok {
|
||||||
|
s.seeder = seeding.New(log.New("accesscontrol.seeder"), backend, backend)
|
||||||
}
|
}
|
||||||
|
|
||||||
return s
|
return s
|
||||||
@@ -112,8 +119,11 @@ type Service struct {
|
|||||||
rolesMu sync.RWMutex
|
rolesMu sync.RWMutex
|
||||||
roles map[string]*accesscontrol.RoleDTO
|
roles map[string]*accesscontrol.RoleDTO
|
||||||
store accesscontrol.Store
|
store accesscontrol.Store
|
||||||
|
seeder *seeding.Seeder
|
||||||
permRegistry permreg.PermissionRegistry
|
permRegistry permreg.PermissionRegistry
|
||||||
isInitialized bool
|
isInitialized bool
|
||||||
|
sql db.DB
|
||||||
|
serverLock *serverlock.ServerLockService
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) GetUsageStats(_ context.Context) map[string]any {
|
func (s *Service) GetUsageStats(_ context.Context) map[string]any {
|
||||||
@@ -431,17 +441,54 @@ func (s *Service) RegisterFixedRoles(ctx context.Context) error {
|
|||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
s.rolesMu.Lock()
|
s.rolesMu.Lock()
|
||||||
defer s.rolesMu.Unlock()
|
registrations := s.registrations.Slice()
|
||||||
|
|
||||||
s.registrations.Range(func(registration accesscontrol.RoleRegistration) bool {
|
s.registrations.Range(func(registration accesscontrol.RoleRegistration) bool {
|
||||||
s.registerRolesLocked(registration)
|
s.registerRolesLocked(registration)
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
s.isInitialized = true
|
s.isInitialized = true
|
||||||
|
|
||||||
|
rolesSnapshot := s.getBasicRolePermissionsLocked()
|
||||||
|
s.rolesMu.Unlock()
|
||||||
|
|
||||||
|
if s.seeder != nil {
|
||||||
|
if err := s.seeder.SeedRoles(ctx, registrations); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.seeder.RemoveAbsentRoles(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.refreshBasicRolePermissionsInDB(ctx, rolesSnapshot); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getBasicRolePermissionsSnapshotFromRegistrationsLocked computes the desired basic role permissions from the
|
||||||
|
// current registration list, using the shared seeding registration logic.
|
||||||
|
//
|
||||||
|
// it has to be called while holding the roles lock
|
||||||
|
func (s *Service) getBasicRolePermissionsLocked() map[string][]accesscontrol.Permission {
|
||||||
|
desired := map[accesscontrol.SeedPermission]struct{}{}
|
||||||
|
s.registrations.Range(func(registration accesscontrol.RoleRegistration) bool {
|
||||||
|
seeding.AppendDesiredPermissions(desired, s.log, ®istration.Role, registration.Grants, registration.Exclude, true)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
out := make(map[string][]accesscontrol.Permission)
|
||||||
|
for sp := range desired {
|
||||||
|
out[sp.BuiltInRole] = append(out[sp.BuiltInRole], accesscontrol.Permission{
|
||||||
|
Action: sp.Action,
|
||||||
|
Scope: sp.Scope,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// registerRolesLocked processes a single role registration and adds permissions to basic roles.
|
// registerRolesLocked processes a single role registration and adds permissions to basic roles.
|
||||||
// Must be called with s.rolesMu locked.
|
// Must be called with s.rolesMu locked.
|
||||||
func (s *Service) registerRolesLocked(registration accesscontrol.RoleRegistration) {
|
func (s *Service) registerRolesLocked(registration accesscontrol.RoleRegistration) {
|
||||||
@@ -474,6 +521,7 @@ func (s *Service) DeclarePluginRoles(ctx context.Context, ID, name string, regs
|
|||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
acRegs := pluginutils.ToRegistrations(ID, name, regs)
|
acRegs := pluginutils.ToRegistrations(ID, name, regs)
|
||||||
|
updatedBasicRoles := false
|
||||||
for _, r := range acRegs {
|
for _, r := range acRegs {
|
||||||
if err := pluginutils.ValidatePluginRole(ID, r.Role); err != nil {
|
if err := pluginutils.ValidatePluginRole(ID, r.Role); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -500,11 +548,23 @@ func (s *Service) DeclarePluginRoles(ctx context.Context, ID, name string, regs
|
|||||||
if initialized {
|
if initialized {
|
||||||
s.rolesMu.Lock()
|
s.rolesMu.Lock()
|
||||||
s.registerRolesLocked(r)
|
s.registerRolesLocked(r)
|
||||||
|
updatedBasicRoles = true
|
||||||
s.rolesMu.Unlock()
|
s.rolesMu.Unlock()
|
||||||
s.cache.Flush()
|
s.cache.Flush()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if updatedBasicRoles {
|
||||||
|
s.rolesMu.RLock()
|
||||||
|
rolesSnapshot := s.getBasicRolePermissionsLocked()
|
||||||
|
s.rolesMu.RUnlock()
|
||||||
|
|
||||||
|
// plugin roles can be declared after startup - keep DB in sync
|
||||||
|
if err := s.refreshBasicRolePermissionsInDB(ctx, rolesSnapshot); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,623 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/infra/db"
|
||||||
|
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
|
"github.com/grafana/grafana/pkg/services/accesscontrol/seeding"
|
||||||
|
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
||||||
|
"github.com/grafana/grafana/pkg/util/xorm/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
const basicRolePermBatchSize = 500
|
||||||
|
|
||||||
|
// LoadRoles returns all fixed and plugin roles (global org) with permissions, indexed by role name.
|
||||||
|
func (s *AccessControlStore) LoadRoles(ctx context.Context) (map[string]*accesscontrol.RoleDTO, error) {
|
||||||
|
out := map[string]*accesscontrol.RoleDTO{}
|
||||||
|
|
||||||
|
err := s.sql.WithDbSession(ctx, func(sess *db.Session) error {
|
||||||
|
type roleRow struct {
|
||||||
|
ID int64 `xorm:"id"`
|
||||||
|
OrgID int64 `xorm:"org_id"`
|
||||||
|
Version int64 `xorm:"version"`
|
||||||
|
UID string `xorm:"uid"`
|
||||||
|
Name string `xorm:"name"`
|
||||||
|
DisplayName string `xorm:"display_name"`
|
||||||
|
Description string `xorm:"description"`
|
||||||
|
Group string `xorm:"group_name"`
|
||||||
|
Hidden bool `xorm:"hidden"`
|
||||||
|
Updated time.Time `xorm:"updated"`
|
||||||
|
Created time.Time `xorm:"created"`
|
||||||
|
}
|
||||||
|
|
||||||
|
roles := []roleRow{}
|
||||||
|
if err := sess.Table("role").
|
||||||
|
Where("org_id = ?", accesscontrol.GlobalOrgID).
|
||||||
|
Where("(name LIKE ? OR name LIKE ?)", accesscontrol.FixedRolePrefix+"%", accesscontrol.PluginRolePrefix+"%").
|
||||||
|
Find(&roles); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(roles) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
roleIDs := make([]any, 0, len(roles))
|
||||||
|
roleByID := make(map[int64]*accesscontrol.RoleDTO, len(roles))
|
||||||
|
for _, r := range roles {
|
||||||
|
dto := &accesscontrol.RoleDTO{
|
||||||
|
ID: r.ID,
|
||||||
|
OrgID: r.OrgID,
|
||||||
|
Version: r.Version,
|
||||||
|
UID: r.UID,
|
||||||
|
Name: r.Name,
|
||||||
|
DisplayName: r.DisplayName,
|
||||||
|
Description: r.Description,
|
||||||
|
Group: r.Group,
|
||||||
|
Hidden: r.Hidden,
|
||||||
|
Updated: r.Updated,
|
||||||
|
Created: r.Created,
|
||||||
|
}
|
||||||
|
out[dto.Name] = dto
|
||||||
|
roleByID[dto.ID] = dto
|
||||||
|
roleIDs = append(roleIDs, dto.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
type permRow struct {
|
||||||
|
RoleID int64 `xorm:"role_id"`
|
||||||
|
Action string `xorm:"action"`
|
||||||
|
Scope string `xorm:"scope"`
|
||||||
|
}
|
||||||
|
perms := []permRow{}
|
||||||
|
if err := sess.Table("permission").In("role_id", roleIDs...).Find(&perms); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range perms {
|
||||||
|
dto := roleByID[p.RoleID]
|
||||||
|
if dto == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dto.Permissions = append(dto.Permissions, accesscontrol.Permission{
|
||||||
|
RoleID: p.RoleID,
|
||||||
|
Action: p.Action,
|
||||||
|
Scope: p.Scope,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AccessControlStore) SetRole(ctx context.Context, existingRole *accesscontrol.RoleDTO, wantedRole accesscontrol.RoleDTO) error {
|
||||||
|
if existingRole == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.sql.WithDbSession(ctx, func(sess *db.Session) error {
|
||||||
|
_, err := sess.Table("role").
|
||||||
|
Where("id = ? AND org_id = ?", existingRole.ID, accesscontrol.GlobalOrgID).
|
||||||
|
Update(map[string]any{
|
||||||
|
"display_name": wantedRole.DisplayName,
|
||||||
|
"description": wantedRole.Description,
|
||||||
|
"group_name": wantedRole.Group,
|
||||||
|
"hidden": wantedRole.Hidden,
|
||||||
|
"updated": time.Now(),
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AccessControlStore) SetPermissions(ctx context.Context, existingRole *accesscontrol.RoleDTO, wantedRole accesscontrol.RoleDTO) error {
|
||||||
|
if existingRole == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type key struct{ Action, Scope string }
|
||||||
|
existing := map[key]struct{}{}
|
||||||
|
for _, p := range existingRole.Permissions {
|
||||||
|
existing[key{p.Action, p.Scope}] = struct{}{}
|
||||||
|
}
|
||||||
|
desired := map[key]struct{}{}
|
||||||
|
for _, p := range wantedRole.Permissions {
|
||||||
|
desired[key{p.Action, p.Scope}] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
toAdd := make([]accesscontrol.Permission, 0)
|
||||||
|
toRemove := make([]accesscontrol.SeedPermission, 0)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
for k := range desired {
|
||||||
|
if _, ok := existing[k]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
perm := accesscontrol.Permission{
|
||||||
|
RoleID: existingRole.ID,
|
||||||
|
Action: k.Action,
|
||||||
|
Scope: k.Scope,
|
||||||
|
Created: now,
|
||||||
|
Updated: now,
|
||||||
|
}
|
||||||
|
perm.Kind, perm.Attribute, perm.Identifier = accesscontrol.SplitScope(perm.Scope)
|
||||||
|
toAdd = append(toAdd, perm)
|
||||||
|
}
|
||||||
|
|
||||||
|
for k := range existing {
|
||||||
|
if _, ok := desired[k]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
toRemove = append(toRemove, accesscontrol.SeedPermission{Action: k.Action, Scope: k.Scope})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(toAdd) == 0 && len(toRemove) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.sql.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
|
||||||
|
if len(toRemove) > 0 {
|
||||||
|
if err := DeleteRolePermissionTuples(sess, s.sql.GetDBType(), existingRole.ID, toRemove); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(toAdd) > 0 {
|
||||||
|
_, err := sess.InsertMulti(toAdd)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AccessControlStore) CreateRole(ctx context.Context, role accesscontrol.RoleDTO) error {
|
||||||
|
now := time.Now()
|
||||||
|
uid := role.UID
|
||||||
|
if uid == "" && (strings.HasPrefix(role.Name, accesscontrol.FixedRolePrefix) || strings.HasPrefix(role.Name, accesscontrol.PluginRolePrefix)) {
|
||||||
|
uid = accesscontrol.PrefixedRoleUID(role.Name)
|
||||||
|
}
|
||||||
|
r := accesscontrol.Role{
|
||||||
|
OrgID: accesscontrol.GlobalOrgID,
|
||||||
|
Version: role.Version,
|
||||||
|
UID: uid,
|
||||||
|
Name: role.Name,
|
||||||
|
DisplayName: role.DisplayName,
|
||||||
|
Description: role.Description,
|
||||||
|
Group: role.Group,
|
||||||
|
Hidden: role.Hidden,
|
||||||
|
Created: now,
|
||||||
|
Updated: now,
|
||||||
|
}
|
||||||
|
if r.Version == 0 {
|
||||||
|
r.Version = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.sql.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
|
||||||
|
if _, err := sess.Insert(&r); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(role.Permissions) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// De-duplicate permissions on (action, scope) to avoid unique constraint violations.
|
||||||
|
// Some role definitions may accidentally include duplicates.
|
||||||
|
type permKey struct{ Action, Scope string }
|
||||||
|
seen := make(map[permKey]struct{}, len(role.Permissions))
|
||||||
|
|
||||||
|
perms := make([]accesscontrol.Permission, 0, len(role.Permissions))
|
||||||
|
for _, p := range role.Permissions {
|
||||||
|
k := permKey{Action: p.Action, Scope: p.Scope}
|
||||||
|
if _, ok := seen[k]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[k] = struct{}{}
|
||||||
|
|
||||||
|
perm := accesscontrol.Permission{
|
||||||
|
RoleID: r.ID,
|
||||||
|
Action: p.Action,
|
||||||
|
Scope: p.Scope,
|
||||||
|
Created: now,
|
||||||
|
Updated: now,
|
||||||
|
}
|
||||||
|
perm.Kind, perm.Attribute, perm.Identifier = accesscontrol.SplitScope(perm.Scope)
|
||||||
|
perms = append(perms, perm)
|
||||||
|
}
|
||||||
|
_, err := sess.InsertMulti(perms)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AccessControlStore) DeleteRoles(ctx context.Context, roleUIDs []string) error {
|
||||||
|
if len(roleUIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
uids := make([]any, 0, len(roleUIDs))
|
||||||
|
for _, uid := range roleUIDs {
|
||||||
|
uids = append(uids, uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.sql.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
|
||||||
|
type row struct {
|
||||||
|
ID int64 `xorm:"id"`
|
||||||
|
UID string `xorm:"uid"`
|
||||||
|
}
|
||||||
|
rows := []row{}
|
||||||
|
if err := sess.Table("role").
|
||||||
|
Where("org_id = ?", accesscontrol.GlobalOrgID).
|
||||||
|
In("uid", uids...).
|
||||||
|
Find(&rows); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
roleIDs := make([]any, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
roleIDs = append(roleIDs, r.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove permissions and assignments first to avoid FK issues (if enabled).
|
||||||
|
{
|
||||||
|
args := append([]any{"DELETE FROM permission WHERE role_id IN (?" + strings.Repeat(",?", len(roleIDs)-1) + ")"}, roleIDs...)
|
||||||
|
if _, err := sess.Exec(args...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{
|
||||||
|
args := append([]any{"DELETE FROM user_role WHERE role_id IN (?" + strings.Repeat(",?", len(roleIDs)-1) + ")"}, roleIDs...)
|
||||||
|
if _, err := sess.Exec(args...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{
|
||||||
|
args := append([]any{"DELETE FROM team_role WHERE role_id IN (?" + strings.Repeat(",?", len(roleIDs)-1) + ")"}, roleIDs...)
|
||||||
|
if _, err := sess.Exec(args...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{
|
||||||
|
args := append([]any{"DELETE FROM builtin_role WHERE role_id IN (?" + strings.Repeat(",?", len(roleIDs)-1) + ")"}, roleIDs...)
|
||||||
|
if _, err := sess.Exec(args...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
args := append([]any{"DELETE FROM role WHERE org_id = ? AND uid IN (?" + strings.Repeat(",?", len(uids)-1) + ")", accesscontrol.GlobalOrgID}, uids...)
|
||||||
|
_, err := sess.Exec(args...)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// OSS basic-role permission refresh uses seeding.Seeder.Seed() with a desired set computed in memory.
|
||||||
|
// These methods implement the permission seeding part of seeding.SeedingBackend against the current permission table.
|
||||||
|
func (s *AccessControlStore) LoadPrevious(ctx context.Context) (map[accesscontrol.SeedPermission]struct{}, error) {
|
||||||
|
var out map[accesscontrol.SeedPermission]struct{}
|
||||||
|
err := s.sql.WithDbSession(ctx, func(sess *db.Session) error {
|
||||||
|
rows, err := LoadBasicRoleSeedPermissions(sess)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
out = make(map[accesscontrol.SeedPermission]struct{}, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
r.Origin = ""
|
||||||
|
out[r] = struct{}{}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AccessControlStore) Apply(ctx context.Context, added, removed []accesscontrol.SeedPermission, updated map[accesscontrol.SeedPermission]accesscontrol.SeedPermission) error {
|
||||||
|
rolesToUpgrade := seeding.RolesToUpgrade(added, removed)
|
||||||
|
|
||||||
|
// Run the same OSS apply logic as ossBasicRoleSeedBackend.Apply inside a single transaction.
|
||||||
|
return s.sql.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
|
||||||
|
defs := accesscontrol.BuildBasicRoleDefinitions()
|
||||||
|
builtinToRoleID, err := EnsureBasicRolesExist(sess, defs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
backend := &ossBasicRoleSeedBackend{
|
||||||
|
sess: sess,
|
||||||
|
now: time.Now(),
|
||||||
|
builtinToRoleID: builtinToRoleID,
|
||||||
|
desired: nil,
|
||||||
|
dbType: s.sql.GetDBType(),
|
||||||
|
}
|
||||||
|
if err := backend.Apply(ctx, added, removed, updated); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return BumpBasicRoleVersions(sess, rolesToUpgrade)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureBasicRolesExist ensures the built-in basic roles exist in the role table and are bound in builtin_role.
|
||||||
|
// It returns a mapping from builtin role name (for example "Admin") to role ID.
|
||||||
|
func EnsureBasicRolesExist(sess *db.Session, defs map[string]*accesscontrol.RoleDTO) (map[string]int64, error) {
|
||||||
|
uidToBuiltin := make(map[string]string, len(defs))
|
||||||
|
uids := make([]any, 0, len(defs))
|
||||||
|
for builtin, def := range defs {
|
||||||
|
uidToBuiltin[def.UID] = builtin
|
||||||
|
uids = append(uids, def.UID)
|
||||||
|
}
|
||||||
|
|
||||||
|
type roleRow struct {
|
||||||
|
ID int64 `xorm:"id"`
|
||||||
|
UID string `xorm:"uid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := []roleRow{}
|
||||||
|
if err := sess.Table("role").
|
||||||
|
Where("org_id = ?", accesscontrol.GlobalOrgID).
|
||||||
|
In("uid", uids...).
|
||||||
|
Find(&rows); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ts := time.Now()
|
||||||
|
|
||||||
|
builtinToRoleID := make(map[string]int64, len(defs))
|
||||||
|
for _, r := range rows {
|
||||||
|
br, ok := uidToBuiltin[r.UID]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
builtinToRoleID[br] = r.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
for builtin, def := range defs {
|
||||||
|
roleID, ok := builtinToRoleID[builtin]
|
||||||
|
if !ok {
|
||||||
|
role := accesscontrol.Role{
|
||||||
|
OrgID: def.OrgID,
|
||||||
|
Version: def.Version,
|
||||||
|
UID: def.UID,
|
||||||
|
Name: def.Name,
|
||||||
|
DisplayName: def.DisplayName,
|
||||||
|
Description: def.Description,
|
||||||
|
Group: def.Group,
|
||||||
|
Hidden: def.Hidden,
|
||||||
|
Created: ts,
|
||||||
|
Updated: ts,
|
||||||
|
}
|
||||||
|
if _, err := sess.Insert(&role); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
roleID = role.ID
|
||||||
|
builtinToRoleID[builtin] = roleID
|
||||||
|
}
|
||||||
|
|
||||||
|
has, err := sess.Table("builtin_role").
|
||||||
|
Where("role_id = ? AND role = ? AND org_id = ?", roleID, builtin, accesscontrol.GlobalOrgID).
|
||||||
|
Exist()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !has {
|
||||||
|
br := accesscontrol.BuiltinRole{
|
||||||
|
RoleID: roleID,
|
||||||
|
OrgID: accesscontrol.GlobalOrgID,
|
||||||
|
Role: builtin,
|
||||||
|
Created: ts,
|
||||||
|
Updated: ts,
|
||||||
|
}
|
||||||
|
if _, err := sess.Table("builtin_role").Insert(&br); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return builtinToRoleID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRolePermissionTuples deletes permissions for a single role by (action, scope) pairs.
|
||||||
|
//
|
||||||
|
// It uses a row-constructor IN clause where supported (MySQL, Postgres, SQLite) and falls back
|
||||||
|
// to a WHERE ... OR ... form for MSSQL.
|
||||||
|
func DeleteRolePermissionTuples(sess *db.Session, dbType core.DbType, roleID int64, perms []accesscontrol.SeedPermission) error {
|
||||||
|
if len(perms) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if dbType == migrator.MSSQL {
|
||||||
|
// MSSQL doesn't support (action, scope) IN ((?,?),(?,?)) row constructors.
|
||||||
|
where := make([]string, 0, len(perms))
|
||||||
|
args := make([]any, 0, 1+len(perms)*2)
|
||||||
|
args = append(args, roleID)
|
||||||
|
for _, p := range perms {
|
||||||
|
where = append(where, "(action = ? AND scope = ?)")
|
||||||
|
args = append(args, p.Action, p.Scope)
|
||||||
|
}
|
||||||
|
_, err := sess.Exec(
|
||||||
|
append([]any{
|
||||||
|
"DELETE FROM permission WHERE role_id = ? AND (" + strings.Join(where, " OR ") + ")",
|
||||||
|
}, args...)...,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
args := make([]any, 0, 1+len(perms)*2)
|
||||||
|
args = append(args, roleID)
|
||||||
|
for _, p := range perms {
|
||||||
|
args = append(args, p.Action, p.Scope)
|
||||||
|
}
|
||||||
|
sql := "DELETE FROM permission WHERE role_id = ? AND (action, scope) IN (" +
|
||||||
|
strings.Repeat("(?, ?),", len(perms)-1) + "(?, ?))"
|
||||||
|
_, err := sess.Exec(append([]any{sql}, args...)...)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type ossBasicRoleSeedBackend struct {
|
||||||
|
sess *db.Session
|
||||||
|
now time.Time
|
||||||
|
builtinToRoleID map[string]int64
|
||||||
|
desired map[accesscontrol.SeedPermission]struct{}
|
||||||
|
dbType core.DbType
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *ossBasicRoleSeedBackend) LoadPrevious(_ context.Context) (map[accesscontrol.SeedPermission]struct{}, error) {
|
||||||
|
rows, err := LoadBasicRoleSeedPermissions(b.sess)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make(map[accesscontrol.SeedPermission]struct{}, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
// Ensure the key matches what OSS seeding uses (Origin is always empty for basic role refresh).
|
||||||
|
r.Origin = ""
|
||||||
|
out[r] = struct{}{}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *ossBasicRoleSeedBackend) LoadDesired(_ context.Context) (map[accesscontrol.SeedPermission]struct{}, error) {
|
||||||
|
return b.desired, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *ossBasicRoleSeedBackend) Apply(_ context.Context, added, removed []accesscontrol.SeedPermission, updated map[accesscontrol.SeedPermission]accesscontrol.SeedPermission) error {
|
||||||
|
// Delete removed permissions (this includes user-defined permissions that aren't in desired).
|
||||||
|
if len(removed) > 0 {
|
||||||
|
permsByRoleID := map[int64][]accesscontrol.SeedPermission{}
|
||||||
|
for _, p := range removed {
|
||||||
|
roleID, ok := b.builtinToRoleID[p.BuiltInRole]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
permsByRoleID[roleID] = append(permsByRoleID[roleID], p)
|
||||||
|
}
|
||||||
|
|
||||||
|
for roleID, perms := range permsByRoleID {
|
||||||
|
// Chunk to keep statement sizes and parameter counts bounded.
|
||||||
|
if err := batch(len(perms), basicRolePermBatchSize, func(start, end int) error {
|
||||||
|
return DeleteRolePermissionTuples(b.sess, b.dbType, roleID, perms[start:end])
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert added permissions and updated-target permissions.
|
||||||
|
toInsertSeed := make([]accesscontrol.SeedPermission, 0, len(added)+len(updated))
|
||||||
|
toInsertSeed = append(toInsertSeed, added...)
|
||||||
|
for _, v := range updated {
|
||||||
|
toInsertSeed = append(toInsertSeed, v)
|
||||||
|
}
|
||||||
|
if len(toInsertSeed) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// De-duplicate on (role_id, action, scope). This avoids unique constraint violations when:
|
||||||
|
// - the same permission appears in both added and updated
|
||||||
|
// - multiple plugin origins grant the same permission (Origin is not persisted in permission table)
|
||||||
|
type permKey struct {
|
||||||
|
RoleID int64
|
||||||
|
Action string
|
||||||
|
Scope string
|
||||||
|
}
|
||||||
|
seen := make(map[permKey]struct{}, len(toInsertSeed))
|
||||||
|
|
||||||
|
toInsert := make([]accesscontrol.Permission, 0, len(toInsertSeed))
|
||||||
|
for _, p := range toInsertSeed {
|
||||||
|
roleID, ok := b.builtinToRoleID[p.BuiltInRole]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
k := permKey{RoleID: roleID, Action: p.Action, Scope: p.Scope}
|
||||||
|
if _, ok := seen[k]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[k] = struct{}{}
|
||||||
|
|
||||||
|
perm := accesscontrol.Permission{
|
||||||
|
RoleID: roleID,
|
||||||
|
Action: p.Action,
|
||||||
|
Scope: p.Scope,
|
||||||
|
Created: b.now,
|
||||||
|
Updated: b.now,
|
||||||
|
}
|
||||||
|
perm.Kind, perm.Attribute, perm.Identifier = accesscontrol.SplitScope(perm.Scope)
|
||||||
|
toInsert = append(toInsert, perm)
|
||||||
|
}
|
||||||
|
|
||||||
|
return batch(len(toInsert), basicRolePermBatchSize, func(start, end int) error {
|
||||||
|
// MySQL: ignore conflicts to make seeding idempotent under retries/concurrency.
|
||||||
|
// Conflicts can happen if the same permission already exists (unique on role_id, action, scope).
|
||||||
|
if b.dbType == migrator.MySQL {
|
||||||
|
args := make([]any, 0, (end-start)*8)
|
||||||
|
for i := start; i < end; i++ {
|
||||||
|
p := toInsert[i]
|
||||||
|
args = append(args, p.RoleID, p.Action, p.Scope, p.Kind, p.Attribute, p.Identifier, p.Updated, p.Created)
|
||||||
|
}
|
||||||
|
sql := append([]any{`INSERT IGNORE INTO permission (role_id, action, scope, kind, attribute, identifier, updated, created) VALUES ` +
|
||||||
|
strings.Repeat("(?, ?, ?, ?, ?, ?, ?, ?),", end-start-1) + "(?, ?, ?, ?, ?, ?, ?, ?)"}, args...)
|
||||||
|
_, err := b.sess.Exec(sql...)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := b.sess.InsertMulti(toInsert[start:end])
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func batch(count, size int, eachFn func(start, end int) error) error {
|
||||||
|
for i := 0; i < count; {
|
||||||
|
end := i + size
|
||||||
|
if end > count {
|
||||||
|
end = count
|
||||||
|
}
|
||||||
|
if err := eachFn(i, end); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
i = end
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BumpBasicRoleVersions increments the role version for the given builtin basic roles (Viewer/Editor/Admin/Grafana Admin).
|
||||||
|
// Unknown role names are ignored.
|
||||||
|
func BumpBasicRoleVersions(sess *db.Session, basicRoles []string) error {
|
||||||
|
if len(basicRoles) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
defs := accesscontrol.BuildBasicRoleDefinitions()
|
||||||
|
uids := make([]any, 0, len(basicRoles))
|
||||||
|
for _, br := range basicRoles {
|
||||||
|
def, ok := defs[br]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
uids = append(uids, def.UID)
|
||||||
|
}
|
||||||
|
if len(uids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sql := "UPDATE role SET version = version + 1 WHERE org_id = ? AND uid IN (?" + strings.Repeat(",?", len(uids)-1) + ")"
|
||||||
|
_, err := sess.Exec(append([]any{sql, accesscontrol.GlobalOrgID}, uids...)...)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadBasicRoleSeedPermissions returns the current (builtin_role, action, scope) permissions granted to basic roles.
|
||||||
|
// It sets Origin to empty.
|
||||||
|
func LoadBasicRoleSeedPermissions(sess *db.Session) ([]accesscontrol.SeedPermission, error) {
|
||||||
|
rows := []accesscontrol.SeedPermission{}
|
||||||
|
err := sess.SQL(
|
||||||
|
`SELECT role.display_name AS builtin_role, p.action, p.scope, '' AS origin
|
||||||
|
FROM role INNER JOIN permission AS p ON p.role_id = role.id
|
||||||
|
WHERE role.org_id = ? AND role.name LIKE 'basic:%'`,
|
||||||
|
accesscontrol.GlobalOrgID,
|
||||||
|
).Find(&rows)
|
||||||
|
return rows, err
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
"go.opentelemetry.io/otel"
|
"go.opentelemetry.io/otel"
|
||||||
|
|
||||||
claims "github.com/grafana/authlib/types"
|
claims "github.com/grafana/authlib/types"
|
||||||
@@ -13,6 +15,7 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/infra/db"
|
"github.com/grafana/grafana/pkg/infra/db"
|
||||||
"github.com/grafana/grafana/pkg/infra/log"
|
"github.com/grafana/grafana/pkg/infra/log"
|
||||||
"github.com/grafana/grafana/pkg/infra/serverlock"
|
"github.com/grafana/grafana/pkg/infra/serverlock"
|
||||||
|
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
"github.com/grafana/grafana/pkg/services/authz/zanzana"
|
"github.com/grafana/grafana/pkg/services/authz/zanzana"
|
||||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||||
"github.com/grafana/grafana/pkg/services/folder"
|
"github.com/grafana/grafana/pkg/services/folder"
|
||||||
@@ -33,12 +36,15 @@ type ZanzanaReconciler struct {
|
|||||||
store db.DB
|
store db.DB
|
||||||
client zanzana.Client
|
client zanzana.Client
|
||||||
lock *serverlock.ServerLockService
|
lock *serverlock.ServerLockService
|
||||||
|
metrics struct {
|
||||||
|
lastSuccess prometheus.Gauge
|
||||||
|
}
|
||||||
// reconcilers are migrations that tries to reconcile the state of grafana db to zanzana store.
|
// reconcilers are migrations that tries to reconcile the state of grafana db to zanzana store.
|
||||||
// These are run periodically to try to maintain a consistent state.
|
// These are run periodically to try to maintain a consistent state.
|
||||||
reconcilers []resourceReconciler
|
reconcilers []resourceReconciler
|
||||||
}
|
}
|
||||||
|
|
||||||
func ProvideZanzanaReconciler(cfg *setting.Cfg, features featuremgmt.FeatureToggles, client zanzana.Client, store db.DB, lock *serverlock.ServerLockService, folderService folder.Service) *ZanzanaReconciler {
|
func ProvideZanzanaReconciler(cfg *setting.Cfg, features featuremgmt.FeatureToggles, client zanzana.Client, store db.DB, lock *serverlock.ServerLockService, folderService folder.Service, reg prometheus.Registerer) *ZanzanaReconciler {
|
||||||
zanzanaReconciler := &ZanzanaReconciler{
|
zanzanaReconciler := &ZanzanaReconciler{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
log: reconcilerLogger,
|
log: reconcilerLogger,
|
||||||
@@ -92,6 +98,13 @@ func ProvideZanzanaReconciler(cfg *setting.Cfg, features featuremgmt.FeatureTogg
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if reg != nil {
|
||||||
|
zanzanaReconciler.metrics.lastSuccess = promauto.With(reg).NewGauge(prometheus.GaugeOpts{
|
||||||
|
Name: "grafana_zanzana_reconcile_last_success_timestamp_seconds",
|
||||||
|
Help: "Unix timestamp (seconds) when the Zanzana reconciler last completed a reconciliation cycle.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if cfg.Anonymous.Enabled {
|
if cfg.Anonymous.Enabled {
|
||||||
zanzanaReconciler.reconcilers = append(zanzanaReconciler.reconcilers,
|
zanzanaReconciler.reconcilers = append(zanzanaReconciler.reconcilers,
|
||||||
newResourceReconciler(
|
newResourceReconciler(
|
||||||
@@ -118,6 +131,9 @@ func (r *ZanzanaReconciler) Run(ctx context.Context) error {
|
|||||||
// Reconcile schedules as job that will run and reconcile resources between
|
// Reconcile schedules as job that will run and reconcile resources between
|
||||||
// legacy access control and zanzana.
|
// legacy access control and zanzana.
|
||||||
func (r *ZanzanaReconciler) Reconcile(ctx context.Context) error {
|
func (r *ZanzanaReconciler) Reconcile(ctx context.Context) error {
|
||||||
|
// Ensure we don't reconcile an empty/partial RBAC state before OSS has seeded basic role permissions.
|
||||||
|
// This matters most during startup where fixed-role loading + basic-role permission refresh runs as another background service.
|
||||||
|
r.waitForBasicRolesSeeded(ctx)
|
||||||
r.reconcile(ctx)
|
r.reconcile(ctx)
|
||||||
|
|
||||||
// FIXME:
|
// FIXME:
|
||||||
@@ -133,6 +149,57 @@ func (r *ZanzanaReconciler) Reconcile(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *ZanzanaReconciler) hasBasicRolePermissions(ctx context.Context) bool {
|
||||||
|
var count int64
|
||||||
|
// Basic role permissions are stored on "basic:%" roles in the global org (0).
|
||||||
|
// In a fresh DB, this will be empty until fixed roles are registered and the basic role permission refresh runs.
|
||||||
|
type row struct {
|
||||||
|
Count int64 `xorm:"count"`
|
||||||
|
}
|
||||||
|
_ = r.store.WithDbSession(ctx, func(sess *db.Session) error {
|
||||||
|
var rr row
|
||||||
|
_, err := sess.SQL(
|
||||||
|
`SELECT COUNT(*) AS count
|
||||||
|
FROM role INNER JOIN permission AS p ON p.role_id = role.id
|
||||||
|
WHERE role.org_id = ? AND role.name LIKE ?`,
|
||||||
|
accesscontrol.GlobalOrgID,
|
||||||
|
accesscontrol.BasicRolePrefix+"%",
|
||||||
|
).Get(&rr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
count = rr.Count
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return count > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ZanzanaReconciler) waitForBasicRolesSeeded(ctx context.Context) {
|
||||||
|
// Best-effort: don't block forever. If we can't observe basic roles, proceed anyway.
|
||||||
|
const (
|
||||||
|
maxWait = 15 * time.Second
|
||||||
|
interval = 1 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
deadline := time.NewTimer(maxWait)
|
||||||
|
defer deadline.Stop()
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
if r.hasBasicRolePermissions(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-deadline.C:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (r *ZanzanaReconciler) reconcile(ctx context.Context) {
|
func (r *ZanzanaReconciler) reconcile(ctx context.Context) {
|
||||||
run := func(ctx context.Context, namespace string) {
|
run := func(ctx context.Context, namespace string) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -144,6 +211,9 @@ func (r *ZanzanaReconciler) reconcile(ctx context.Context) {
|
|||||||
r.log.Warn("Failed to perform reconciliation for resource", "err", err)
|
r.log.Warn("Failed to perform reconciliation for resource", "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if r.metrics.lastSuccess != nil {
|
||||||
|
r.metrics.lastSuccess.SetToCurrentTime()
|
||||||
|
}
|
||||||
r.log.Debug("Finished reconciliation", "elapsed", time.Since(now))
|
r.log.Debug("Finished reconciliation", "elapsed", time.Since(now))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package dualwrite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/infra/db"
|
||||||
|
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestZanzanaReconciler_hasBasicRolePermissions(t *testing.T) {
|
||||||
|
env := setupTestEnv(t)
|
||||||
|
|
||||||
|
r := &ZanzanaReconciler{
|
||||||
|
store: env.db,
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
require.False(t, r.hasBasicRolePermissions(ctx))
|
||||||
|
|
||||||
|
err := env.db.WithDbSession(ctx, func(sess *db.Session) error {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
_, err := sess.Exec(
|
||||||
|
`INSERT INTO role (org_id, uid, name, display_name, group_name, description, hidden, version, created, updated)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
accesscontrol.GlobalOrgID,
|
||||||
|
"basic_viewer_uid_test",
|
||||||
|
accesscontrol.BasicRolePrefix+"viewer",
|
||||||
|
"Viewer",
|
||||||
|
"Basic",
|
||||||
|
"Viewer role",
|
||||||
|
false,
|
||||||
|
1,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var roleID int64
|
||||||
|
if _, err := sess.SQL(`SELECT id FROM role WHERE org_id = ? AND uid = ?`, accesscontrol.GlobalOrgID, "basic_viewer_uid_test").Get(&roleID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = sess.Exec(
|
||||||
|
`INSERT INTO permission (role_id, action, scope, kind, attribute, identifier, created, updated)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
roleID,
|
||||||
|
"dashboards:read",
|
||||||
|
"dashboards:*",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.True(t, r.hasBasicRolePermissions(ctx))
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package accesscontrol
|
package accesscontrol
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -594,3 +595,18 @@ type QueryWithOrg struct {
|
|||||||
OrgId *int64 `json:"orgId"`
|
OrgId *int64 `json:"orgId"`
|
||||||
Global bool `json:"global"`
|
Global bool `json:"global"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SeedPermission struct {
|
||||||
|
BuiltInRole string `xorm:"builtin_role"`
|
||||||
|
Action string `xorm:"action"`
|
||||||
|
Scope string `xorm:"scope"`
|
||||||
|
Origin string `xorm:"origin"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RoleStore interface {
|
||||||
|
LoadRoles(ctx context.Context) (map[string]*RoleDTO, error)
|
||||||
|
SetRole(ctx context.Context, existingRole *RoleDTO, wantedRole RoleDTO) error
|
||||||
|
SetPermissions(ctx context.Context, existingRole *RoleDTO, wantedRole RoleDTO) error
|
||||||
|
CreateRole(ctx context.Context, role RoleDTO) error
|
||||||
|
DeleteRoles(ctx context.Context, roleUIDs []string) error
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,451 @@
|
|||||||
|
package seeding
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/infra/log"
|
||||||
|
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
|
"github.com/grafana/grafana/pkg/services/accesscontrol/pluginutils"
|
||||||
|
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Seeder struct {
|
||||||
|
log log.Logger
|
||||||
|
roleStore accesscontrol.RoleStore
|
||||||
|
backend SeedingBackend
|
||||||
|
builtinsPermissions map[accesscontrol.SeedPermission]struct{}
|
||||||
|
seededFixedRoles map[string]bool
|
||||||
|
seededPluginRoles map[string]bool
|
||||||
|
seededPlugins map[string]bool
|
||||||
|
hasSeededAlready bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeedingBackend provides the seed-set specific operations needed to seed.
|
||||||
|
type SeedingBackend interface {
|
||||||
|
// LoadPrevious returns the currently stored permissions for previously seeded roles.
|
||||||
|
LoadPrevious(ctx context.Context) (map[accesscontrol.SeedPermission]struct{}, error)
|
||||||
|
|
||||||
|
// Apply updates the database to match the desired permissions.
|
||||||
|
Apply(ctx context.Context,
|
||||||
|
added, removed []accesscontrol.SeedPermission,
|
||||||
|
updated map[accesscontrol.SeedPermission]accesscontrol.SeedPermission,
|
||||||
|
) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(log log.Logger, roleStore accesscontrol.RoleStore, backend SeedingBackend) *Seeder {
|
||||||
|
return &Seeder{
|
||||||
|
log: log,
|
||||||
|
roleStore: roleStore,
|
||||||
|
backend: backend,
|
||||||
|
builtinsPermissions: map[accesscontrol.SeedPermission]struct{}{},
|
||||||
|
seededFixedRoles: map[string]bool{},
|
||||||
|
seededPluginRoles: map[string]bool{},
|
||||||
|
seededPlugins: map[string]bool{},
|
||||||
|
hasSeededAlready: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDesiredPermissions replaces the in-memory desired permission set used by Seed().
|
||||||
|
func (s *Seeder) SetDesiredPermissions(desired map[accesscontrol.SeedPermission]struct{}) {
|
||||||
|
if desired == nil {
|
||||||
|
s.builtinsPermissions = map[accesscontrol.SeedPermission]struct{}{}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.builtinsPermissions = desired
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed loads current and desired permissions, diffs them (including scope updates), applies changes, and bumps versions.
|
||||||
|
func (s *Seeder) Seed(ctx context.Context) error {
|
||||||
|
previous, err := s.backend.LoadPrevious(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// - Do not remove plugin permissions when the plugin didn't register this run (Origin set but not in seededPlugins).
|
||||||
|
// - Preserve legacy plugin app access permissions in the persisted seed set (these are granted by default).
|
||||||
|
if len(previous) > 0 {
|
||||||
|
filtered := make(map[accesscontrol.SeedPermission]struct{}, len(previous))
|
||||||
|
for p := range previous {
|
||||||
|
if p.Action == pluginaccesscontrol.ActionAppAccess {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p.Origin != "" && !s.seededPlugins[p.Origin] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered[p] = struct{}{}
|
||||||
|
}
|
||||||
|
previous = filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
added, removed, updated := s.permissionDiff(previous, s.builtinsPermissions)
|
||||||
|
|
||||||
|
if err := s.backend.Apply(ctx, added, removed, updated); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeedRoles populates the database with the roles and their assignments
|
||||||
|
// It will create roles that do not exist and update roles that have changed
|
||||||
|
// Do not use for provisioning. Validation is not enforced.
|
||||||
|
func (s *Seeder) SeedRoles(ctx context.Context, registrationList []accesscontrol.RoleRegistration) error {
|
||||||
|
roleMap, err := s.roleStore.LoadRoles(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
missingRoles := make([]accesscontrol.RoleRegistration, 0, len(registrationList))
|
||||||
|
|
||||||
|
// Diff existing roles with the ones we want to seed.
|
||||||
|
// If a role is missing, we add it to the missingRoles list
|
||||||
|
for _, registration := range registrationList {
|
||||||
|
registration := registration
|
||||||
|
role, ok := roleMap[registration.Role.Name]
|
||||||
|
switch {
|
||||||
|
case registration.Role.IsFixed():
|
||||||
|
s.seededFixedRoles[registration.Role.Name] = true
|
||||||
|
case registration.Role.IsPlugin():
|
||||||
|
s.seededPluginRoles[registration.Role.Name] = true
|
||||||
|
// To be resilient to failed plugin loadings, we remember the plugins that have registered,
|
||||||
|
// later we'll ignore permissions and roles of other plugins
|
||||||
|
s.seededPlugins[pluginutils.PluginIDFromName(registration.Role.Name)] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
s.rememberPermissionAssignments(®istration.Role, registration.Grants, registration.Exclude)
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
missingRoles = append(missingRoles, registration)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if needsRoleUpdate(role, registration.Role) {
|
||||||
|
if err := s.roleStore.SetRole(ctx, role, registration.Role); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if needsPermissionsUpdate(role, registration.Role) {
|
||||||
|
if err := s.roleStore.SetPermissions(ctx, role, registration.Role); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, registration := range missingRoles {
|
||||||
|
if err := s.roleStore.CreateRole(ctx, registration.Role); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func needsPermissionsUpdate(existingRole *accesscontrol.RoleDTO, wantedRole accesscontrol.RoleDTO) bool {
|
||||||
|
if existingRole == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(existingRole.Permissions) != len(wantedRole.Permissions) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range wantedRole.Permissions {
|
||||||
|
found := false
|
||||||
|
for _, ep := range existingRole.Permissions {
|
||||||
|
if ep.Action == p.Action && ep.Scope == p.Scope {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func needsRoleUpdate(existingRole *accesscontrol.RoleDTO, wantedRole accesscontrol.RoleDTO) bool {
|
||||||
|
if existingRole == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if existingRole.Name != wantedRole.Name {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if existingRole.DisplayName != wantedRole.DisplayName {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if existingRole.Description != wantedRole.Description {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if existingRole.Group != wantedRole.Group {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if existingRole.Hidden != wantedRole.Hidden {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: SeedRole is deprecated and should not be used.
|
||||||
|
// SeedRoles only does boot up seeding and should not be used for runtime seeding.
|
||||||
|
func (s *Seeder) SeedRole(ctx context.Context, role accesscontrol.RoleDTO, builtInRoles []string) error {
|
||||||
|
addedPermissions := make(map[string]struct{}, len(role.Permissions))
|
||||||
|
permissions := make([]accesscontrol.Permission, 0, len(role.Permissions))
|
||||||
|
for _, p := range role.Permissions {
|
||||||
|
key := fmt.Sprintf("%s:%s", p.Action, p.Scope)
|
||||||
|
if _, ok := addedPermissions[key]; !ok {
|
||||||
|
addedPermissions[key] = struct{}{}
|
||||||
|
permissions = append(permissions, accesscontrol.Permission{Action: p.Action, Scope: p.Scope})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wantedRole := accesscontrol.RoleDTO{
|
||||||
|
OrgID: accesscontrol.GlobalOrgID,
|
||||||
|
Version: role.Version,
|
||||||
|
UID: role.UID,
|
||||||
|
Name: role.Name,
|
||||||
|
DisplayName: role.DisplayName,
|
||||||
|
Description: role.Description,
|
||||||
|
Group: role.Group,
|
||||||
|
Permissions: permissions,
|
||||||
|
Hidden: role.Hidden,
|
||||||
|
}
|
||||||
|
roleMap, err := s.roleStore.LoadRoles(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
existingRole := roleMap[wantedRole.Name]
|
||||||
|
if existingRole == nil {
|
||||||
|
if err := s.roleStore.CreateRole(ctx, wantedRole); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if needsRoleUpdate(existingRole, wantedRole) {
|
||||||
|
if err := s.roleStore.SetRole(ctx, existingRole, wantedRole); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if needsPermissionsUpdate(existingRole, wantedRole) {
|
||||||
|
if err := s.roleStore.SetPermissions(ctx, existingRole, wantedRole); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remember seeded roles
|
||||||
|
if wantedRole.IsFixed() {
|
||||||
|
s.seededFixedRoles[wantedRole.Name] = true
|
||||||
|
}
|
||||||
|
isPluginRole := wantedRole.IsPlugin()
|
||||||
|
if isPluginRole {
|
||||||
|
s.seededPluginRoles[wantedRole.Name] = true
|
||||||
|
|
||||||
|
// To be resilient to failed plugin loadings, we remember the plugins that have registered,
|
||||||
|
// later we'll ignore permissions and roles of other plugins
|
||||||
|
s.seededPlugins[pluginutils.PluginIDFromName(role.Name)] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
s.rememberPermissionAssignments(&wantedRole, builtInRoles, []string{})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Seeder) rememberPermissionAssignments(role *accesscontrol.RoleDTO, builtInRoles []string, excludedRoles []string) {
|
||||||
|
AppendDesiredPermissions(s.builtinsPermissions, s.log, role, builtInRoles, excludedRoles, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppendDesiredPermissions accumulates permissions from a role registration onto basic roles (Viewer/Editor/Admin/Grafana Admin).
|
||||||
|
// - It expands parents via accesscontrol.BuiltInRolesWithParents.
|
||||||
|
// - It can optionally ignore plugin app access permissions (which are granted by default).
|
||||||
|
func AppendDesiredPermissions(
|
||||||
|
out map[accesscontrol.SeedPermission]struct{},
|
||||||
|
logger log.Logger,
|
||||||
|
role *accesscontrol.RoleDTO,
|
||||||
|
builtInRoles []string,
|
||||||
|
excludedRoles []string,
|
||||||
|
ignorePluginAppAccess bool,
|
||||||
|
) {
|
||||||
|
if out == nil || role == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for builtInRole := range accesscontrol.BuiltInRolesWithParents(builtInRoles) {
|
||||||
|
// Skip excluded grants
|
||||||
|
if slices.Contains(excludedRoles, builtInRole) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, perm := range role.Permissions {
|
||||||
|
if ignorePluginAppAccess && perm.Action == pluginaccesscontrol.ActionAppAccess {
|
||||||
|
logger.Debug("Role is attempting to grant access permission, but this permission is already granted by default and will be ignored",
|
||||||
|
"role", role.Name, "permission", perm.Action, "scope", perm.Scope)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
sp := accesscontrol.SeedPermission{
|
||||||
|
BuiltInRole: builtInRole,
|
||||||
|
Action: perm.Action,
|
||||||
|
Scope: perm.Scope,
|
||||||
|
}
|
||||||
|
|
||||||
|
if role.IsPlugin() {
|
||||||
|
sp.Origin = pluginutils.PluginIDFromName(role.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
out[sp] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// permissionDiff returns:
|
||||||
|
// - added: present in desired permissions, not in previous permissions
|
||||||
|
// - removed: present in previous permissions, not in desired permissions
|
||||||
|
// - updated: same role + action, but scope changed
|
||||||
|
func (s *Seeder) permissionDiff(previous, desired map[accesscontrol.SeedPermission]struct{}) (added, removed []accesscontrol.SeedPermission, updated map[accesscontrol.SeedPermission]accesscontrol.SeedPermission) {
|
||||||
|
addedSet := make(map[accesscontrol.SeedPermission]struct{}, 0)
|
||||||
|
for n := range desired {
|
||||||
|
if _, already := previous[n]; !already {
|
||||||
|
addedSet[n] = struct{}{}
|
||||||
|
} else {
|
||||||
|
delete(previous, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any of the new permissions is actually an old permission with an updated scope
|
||||||
|
updated = make(map[accesscontrol.SeedPermission]accesscontrol.SeedPermission, 0)
|
||||||
|
for n := range addedSet {
|
||||||
|
for p := range previous {
|
||||||
|
if n.BuiltInRole == p.BuiltInRole && n.Action == p.Action {
|
||||||
|
updated[p] = n
|
||||||
|
delete(addedSet, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for p := range addedSet {
|
||||||
|
added = append(added, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
for p := range previous {
|
||||||
|
if p.Action == pluginaccesscontrol.ActionAppAccess &&
|
||||||
|
p.Scope != pluginaccesscontrol.ScopeProvider.GetResourceAllScope() {
|
||||||
|
// Allows backward compatibility with plugins that have been seeded before the grant ignore rule was added
|
||||||
|
s.log.Info("This permission already existed so it will not be removed",
|
||||||
|
"role", p.BuiltInRole, "permission", p.Action, "scope", p.Scope)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
removed = append(removed, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
return added, removed, updated
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Seeder) ClearBasicRolesPluginPermissions(ID string) {
|
||||||
|
removable := []accesscontrol.SeedPermission{}
|
||||||
|
|
||||||
|
for key := range s.builtinsPermissions {
|
||||||
|
if matchPermissionByPluginID(key, ID) {
|
||||||
|
removable = append(removable, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, perm := range removable {
|
||||||
|
delete(s.builtinsPermissions, perm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchPermissionByPluginID(perm accesscontrol.SeedPermission, pluginID string) bool {
|
||||||
|
if perm.Origin != pluginID {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
actionTemplate := regexp.MustCompile(fmt.Sprintf("%s[.:]", pluginID))
|
||||||
|
scopeTemplate := fmt.Sprintf(":%s", pluginID)
|
||||||
|
return actionTemplate.MatchString(perm.Action) || strings.HasSuffix(perm.Scope, scopeTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RolesToUpgrade returns the unique basic roles that should have their version incremented.
|
||||||
|
func RolesToUpgrade(added, removed []accesscontrol.SeedPermission) []string {
|
||||||
|
set := map[string]struct{}{}
|
||||||
|
for _, p := range added {
|
||||||
|
set[p.BuiltInRole] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, p := range removed {
|
||||||
|
set[p.BuiltInRole] = struct{}{}
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(set))
|
||||||
|
for r := range set {
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Seeder) ClearPluginRoles(ID string) {
|
||||||
|
expectedPrefix := fmt.Sprintf("%s%s:", accesscontrol.PluginRolePrefix, ID)
|
||||||
|
|
||||||
|
for roleName := range s.seededPluginRoles {
|
||||||
|
if strings.HasPrefix(roleName, expectedPrefix) {
|
||||||
|
delete(s.seededPluginRoles, roleName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Seeder) MarkSeededAlready() {
|
||||||
|
s.hasSeededAlready = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Seeder) HasSeededAlready() bool {
|
||||||
|
return s.hasSeededAlready
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Seeder) RemoveAbsentRoles(ctx context.Context) error {
|
||||||
|
roleMap, errGet := s.roleStore.LoadRoles(ctx)
|
||||||
|
if errGet != nil {
|
||||||
|
s.log.Error("failed to get fixed roles from store", "err", errGet)
|
||||||
|
return errGet
|
||||||
|
}
|
||||||
|
|
||||||
|
toRemove := []string{}
|
||||||
|
for _, r := range roleMap {
|
||||||
|
if r == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r.IsFixed() {
|
||||||
|
if !s.seededFixedRoles[r.Name] {
|
||||||
|
s.log.Info("role is not seeded anymore, mark it for deletion", "role", r.Name)
|
||||||
|
toRemove = append(toRemove, r.UID)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.IsPlugin() {
|
||||||
|
if !s.seededPlugins[pluginutils.PluginIDFromName(r.Name)] {
|
||||||
|
// To be resilient to failed plugin loadings
|
||||||
|
// ignore stored roles related to plugins that have not registered this time
|
||||||
|
s.log.Debug("plugin role has not been registered on this run skipping its removal", "role", r.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !s.seededPluginRoles[r.Name] {
|
||||||
|
s.log.Info("role is not seeded anymore, mark it for deletion", "role", r.Name)
|
||||||
|
toRemove = append(toRemove, r.UID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if errDelete := s.roleStore.DeleteRoles(ctx, toRemove); errDelete != nil {
|
||||||
|
s.log.Error("failed to delete absent fixed and plugin roles", "err", errDelete)
|
||||||
|
return errDelete
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -78,6 +78,9 @@ func ProvideZanzanaClient(cfg *setting.Cfg, db db.DB, tracer tracing.Tracer, fea
|
|||||||
ctx = types.WithAuthInfo(ctx, authnlib.NewAccessTokenAuthInfo(authnlib.Claims[authnlib.AccessTokenClaims]{
|
ctx = types.WithAuthInfo(ctx, authnlib.NewAccessTokenAuthInfo(authnlib.Claims[authnlib.AccessTokenClaims]{
|
||||||
Rest: authnlib.AccessTokenClaims{
|
Rest: authnlib.AccessTokenClaims{
|
||||||
Namespace: "*",
|
Namespace: "*",
|
||||||
|
Permissions: []string{
|
||||||
|
zanzana.TokenPermissionUpdate,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
return ctx, nil
|
return ctx, nil
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/infra/log"
|
"github.com/grafana/grafana/pkg/infra/log"
|
||||||
|
"github.com/grafana/grafana/pkg/services/authz/zanzana"
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
|
"golang.org/x/exp/slices"
|
||||||
|
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
@@ -30,3 +32,20 @@ func authorize(ctx context.Context, namespace string, ss setting.ZanzanaServerSe
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func authorizeWrite(ctx context.Context, namespace string, ss setting.ZanzanaServerSettings) error {
|
||||||
|
if err := authorize(ctx, namespace, ss); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
c, ok := claims.AuthInfoFrom(ctx)
|
||||||
|
if !ok {
|
||||||
|
return status.Errorf(codes.Unauthenticated, "unauthenticated")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Contains(c.GetTokenPermissions(), zanzana.TokenPermissionUpdate) {
|
||||||
|
return status.Errorf(codes.PermissionDenied, "missing token permission %s", zanzana.TokenPermissionUpdate)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -391,7 +391,7 @@ func setupBenchmarkServer(b *testing.B) (*Server, *benchmarkData) {
|
|||||||
b.Logf("Total tuples to write: %d", len(allTuples))
|
b.Logf("Total tuples to write: %d", len(allTuples))
|
||||||
|
|
||||||
// Get store info
|
// Get store info
|
||||||
ctx := newContextWithNamespace()
|
ctx := newContextWithZanzanaUpdatePermission()
|
||||||
storeInf, err := srv.getStoreInfo(ctx, benchNamespace)
|
storeInf, err := srv.getStoreInfo(ctx, benchNamespace)
|
||||||
require.NoError(b, err)
|
require.NoError(b, err)
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||||
"go.opentelemetry.io/otel/codes"
|
"go.opentelemetry.io/otel/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
|
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
|
||||||
)
|
)
|
||||||
@@ -35,6 +36,9 @@ func (s *Server) Mutate(ctx context.Context, req *authzextv1.MutateRequest) (*au
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
span.RecordError(err)
|
span.RecordError(err)
|
||||||
span.SetStatus(codes.Error, err.Error())
|
span.SetStatus(codes.Error, err.Error())
|
||||||
|
if _, ok := status.FromError(err); ok {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
s.logger.Error("failed to perform mutate request", "error", err, "namespace", req.GetNamespace())
|
s.logger.Error("failed to perform mutate request", "error", err, "namespace", req.GetNamespace())
|
||||||
return nil, errors.New("failed to perform mutate request")
|
return nil, errors.New("failed to perform mutate request")
|
||||||
}
|
}
|
||||||
@@ -43,7 +47,7 @@ func (s *Server) Mutate(ctx context.Context, req *authzextv1.MutateRequest) (*au
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) mutate(ctx context.Context, req *authzextv1.MutateRequest) (*authzextv1.MutateResponse, error) {
|
func (s *Server) mutate(ctx context.Context, req *authzextv1.MutateRequest) (*authzextv1.MutateResponse, error) {
|
||||||
if err := authorize(ctx, req.GetNamespace(), s.cfg); err != nil {
|
if err := authorizeWrite(ctx, req.GetNamespace(), s.cfg); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ func testMutateFolders(t *testing.T, srv *Server) {
|
|||||||
setupMutateFolders(t, srv)
|
setupMutateFolders(t, srv)
|
||||||
|
|
||||||
t.Run("should create new folder parent relation", func(t *testing.T) {
|
t.Run("should create new folder parent relation", func(t *testing.T) {
|
||||||
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
|
_, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{
|
||||||
Namespace: "default",
|
Namespace: "default",
|
||||||
Operations: []*v1.MutateOperation{
|
Operations: []*v1.MutateOperation{
|
||||||
{
|
{
|
||||||
@@ -61,7 +61,7 @@ func testMutateFolders(t *testing.T, srv *Server) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("should delete folder parent relation", func(t *testing.T) {
|
t.Run("should delete folder parent relation", func(t *testing.T) {
|
||||||
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
|
_, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{
|
||||||
Namespace: "default",
|
Namespace: "default",
|
||||||
Operations: []*v1.MutateOperation{
|
Operations: []*v1.MutateOperation{
|
||||||
{
|
{
|
||||||
@@ -88,7 +88,7 @@ func testMutateFolders(t *testing.T, srv *Server) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("should clean up all parent relations", func(t *testing.T) {
|
t.Run("should clean up all parent relations", func(t *testing.T) {
|
||||||
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
|
_, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{
|
||||||
Namespace: "default",
|
Namespace: "default",
|
||||||
Operations: []*v1.MutateOperation{
|
Operations: []*v1.MutateOperation{
|
||||||
{
|
{
|
||||||
@@ -115,7 +115,7 @@ func testMutateFolders(t *testing.T, srv *Server) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("should perform batch mutate if multiple operations are provided", func(t *testing.T) {
|
t.Run("should perform batch mutate if multiple operations are provided", func(t *testing.T) {
|
||||||
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
|
_, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{
|
||||||
Namespace: "default",
|
Namespace: "default",
|
||||||
Operations: []*v1.MutateOperation{
|
Operations: []*v1.MutateOperation{
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func testMutateOrgRoles(t *testing.T, srv *Server) {
|
|||||||
setupMutateOrgRoles(t, srv)
|
setupMutateOrgRoles(t, srv)
|
||||||
|
|
||||||
t.Run("should update user org role and delete old role", func(t *testing.T) {
|
t.Run("should update user org role and delete old role", func(t *testing.T) {
|
||||||
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
|
_, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{
|
||||||
Namespace: "default",
|
Namespace: "default",
|
||||||
Operations: []*v1.MutateOperation{
|
Operations: []*v1.MutateOperation{
|
||||||
{
|
{
|
||||||
@@ -63,7 +63,7 @@ func testMutateOrgRoles(t *testing.T, srv *Server) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("should add user org role and delete old role", func(t *testing.T) {
|
t.Run("should add user org role and delete old role", func(t *testing.T) {
|
||||||
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
|
_, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{
|
||||||
Namespace: "default",
|
Namespace: "default",
|
||||||
Operations: []*v1.MutateOperation{
|
Operations: []*v1.MutateOperation{
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func testMutateResourcePermissions(t *testing.T, srv *Server) {
|
|||||||
setupMutateResourcePermissions(t, srv)
|
setupMutateResourcePermissions(t, srv)
|
||||||
|
|
||||||
t.Run("should create new resource permission", func(t *testing.T) {
|
t.Run("should create new resource permission", func(t *testing.T) {
|
||||||
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
|
_, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{
|
||||||
Namespace: "default",
|
Namespace: "default",
|
||||||
Operations: []*v1.MutateOperation{
|
Operations: []*v1.MutateOperation{
|
||||||
{
|
{
|
||||||
@@ -76,7 +76,7 @@ func testMutateResourcePermissions(t *testing.T, srv *Server) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, res.Tuples, 2)
|
require.Len(t, res.Tuples, 2)
|
||||||
|
|
||||||
_, err = srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
|
_, err = srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{
|
||||||
Namespace: "default",
|
Namespace: "default",
|
||||||
Operations: []*v1.MutateOperation{
|
Operations: []*v1.MutateOperation{
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func testMutateRoleBindings(t *testing.T, srv *Server) {
|
|||||||
setupMutateRoleBindings(t, srv)
|
setupMutateRoleBindings(t, srv)
|
||||||
|
|
||||||
t.Run("should update user role and delete old role", func(t *testing.T) {
|
t.Run("should update user role and delete old role", func(t *testing.T) {
|
||||||
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
|
_, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{
|
||||||
Namespace: "default",
|
Namespace: "default",
|
||||||
Operations: []*v1.MutateOperation{
|
Operations: []*v1.MutateOperation{
|
||||||
{
|
{
|
||||||
@@ -75,7 +75,7 @@ func testMutateRoleBindings(t *testing.T, srv *Server) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("should assign role to basic role", func(t *testing.T) {
|
t.Run("should assign role to basic role", func(t *testing.T) {
|
||||||
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
|
_, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{
|
||||||
Namespace: "default",
|
Namespace: "default",
|
||||||
Operations: []*v1.MutateOperation{
|
Operations: []*v1.MutateOperation{
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func testMutateRoles(t *testing.T, srv *Server) {
|
|||||||
setupMutateRoles(t, srv)
|
setupMutateRoles(t, srv)
|
||||||
|
|
||||||
t.Run("should update role and delete old role permissions", func(t *testing.T) {
|
t.Run("should update role and delete old role permissions", func(t *testing.T) {
|
||||||
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
|
_, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{
|
||||||
Namespace: "default",
|
Namespace: "default",
|
||||||
Operations: []*v1.MutateOperation{
|
Operations: []*v1.MutateOperation{
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func testMutateTeamBindings(t *testing.T, srv *Server) {
|
|||||||
setupMutateTeamBindings(t, srv)
|
setupMutateTeamBindings(t, srv)
|
||||||
|
|
||||||
t.Run("should update user team binding and delete old team binding", func(t *testing.T) {
|
t.Run("should update user team binding and delete old team binding", func(t *testing.T) {
|
||||||
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
|
_, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{
|
||||||
Namespace: "default",
|
Namespace: "default",
|
||||||
Operations: []*v1.MutateOperation{
|
Operations: []*v1.MutateOperation{
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
|
|
||||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
"google.golang.org/protobuf/types/known/structpb"
|
"google.golang.org/protobuf/types/known/structpb"
|
||||||
|
|
||||||
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
|
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
|
||||||
@@ -33,7 +35,7 @@ func testMutate(t *testing.T, srv *Server) {
|
|||||||
setupMutate(t, srv)
|
setupMutate(t, srv)
|
||||||
|
|
||||||
t.Run("should perform multiple mutate operations", func(t *testing.T) {
|
t.Run("should perform multiple mutate operations", func(t *testing.T) {
|
||||||
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
|
_, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{
|
||||||
Namespace: "default",
|
Namespace: "default",
|
||||||
Operations: []*v1.MutateOperation{
|
Operations: []*v1.MutateOperation{
|
||||||
{
|
{
|
||||||
@@ -133,6 +135,25 @@ func testMutate(t *testing.T, srv *Server) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, res.Tuples, 0)
|
require.Len(t, res.Tuples, 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("should reject mutate without zanzana:update", func(t *testing.T) {
|
||||||
|
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
|
||||||
|
Namespace: "default",
|
||||||
|
Operations: []*v1.MutateOperation{
|
||||||
|
{
|
||||||
|
Operation: &v1.MutateOperation_SetFolderParent{
|
||||||
|
SetFolderParent: &v1.SetFolderParentOperation{
|
||||||
|
Folder: "new-folder",
|
||||||
|
Parent: "1",
|
||||||
|
DeleteExisting: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Equal(t, codes.PermissionDenied, status.Code(err))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDeduplicateTupleKeys(t *testing.T) {
|
func TestDeduplicateTupleKeys(t *testing.T) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/infra/db"
|
"github.com/grafana/grafana/pkg/infra/db"
|
||||||
"github.com/grafana/grafana/pkg/infra/log"
|
"github.com/grafana/grafana/pkg/infra/log"
|
||||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||||
|
"github.com/grafana/grafana/pkg/services/authz/zanzana"
|
||||||
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
|
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
|
||||||
"github.com/grafana/grafana/pkg/services/authz/zanzana/store"
|
"github.com/grafana/grafana/pkg/services/authz/zanzana/store"
|
||||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||||
@@ -218,11 +219,21 @@ func setupOpenFGADatabase(t *testing.T, srv *Server, tuples []*openfgav1.TupleKe
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newContextWithNamespace() context.Context {
|
func newContextWithNamespace() context.Context {
|
||||||
|
return newContextWithNamespaceAndPermissions()
|
||||||
|
}
|
||||||
|
|
||||||
|
func newContextWithNamespaceAndPermissions(perms ...string) context.Context {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
ctx = claims.WithAuthInfo(ctx, authnlib.NewAccessTokenAuthInfo(authnlib.Claims[authnlib.AccessTokenClaims]{
|
ctx = claims.WithAuthInfo(ctx, authnlib.NewAccessTokenAuthInfo(authnlib.Claims[authnlib.AccessTokenClaims]{
|
||||||
Rest: authnlib.AccessTokenClaims{
|
Rest: authnlib.AccessTokenClaims{
|
||||||
Namespace: "*",
|
Namespace: "*",
|
||||||
|
Permissions: perms,
|
||||||
|
DelegatedPermissions: perms,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newContextWithZanzanaUpdatePermission() context.Context {
|
||||||
|
return newContextWithNamespaceAndPermissions(zanzana.TokenPermissionUpdate)
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||||
"go.opentelemetry.io/otel/codes"
|
"go.opentelemetry.io/otel/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
|
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
|
||||||
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
|
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
|
||||||
@@ -25,6 +26,9 @@ func (s *Server) Write(ctx context.Context, req *authzextv1.WriteRequest) (*auth
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
span.RecordError(err)
|
span.RecordError(err)
|
||||||
span.SetStatus(codes.Error, err.Error())
|
span.SetStatus(codes.Error, err.Error())
|
||||||
|
if _, ok := status.FromError(err); ok {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
s.logger.Error("failed to perform write request", "error", err, "namespace", req.GetNamespace())
|
s.logger.Error("failed to perform write request", "error", err, "namespace", req.GetNamespace())
|
||||||
return nil, errors.New("failed to perform write request")
|
return nil, errors.New("failed to perform write request")
|
||||||
}
|
}
|
||||||
@@ -33,7 +37,7 @@ func (s *Server) Write(ctx context.Context, req *authzextv1.WriteRequest) (*auth
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) write(ctx context.Context, req *authzextv1.WriteRequest) (*authzextv1.WriteResponse, error) {
|
func (s *Server) write(ctx context.Context, req *authzextv1.WriteRequest) (*authzextv1.WriteResponse, error) {
|
||||||
if err := authorize(ctx, req.GetNamespace(), s.cfg); err != nil {
|
if err := authorizeWrite(ctx, req.GetNamespace(), s.cfg); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||||
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
|
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
|
||||||
|
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWriteAuthorization(t *testing.T) {
|
||||||
|
cfg := setting.NewCfg()
|
||||||
|
testStore := sqlstore.NewTestStore(t, sqlstore.WithCfg(cfg))
|
||||||
|
srv := setupOpenFGAServer(t, testStore, cfg)
|
||||||
|
setup(t, srv)
|
||||||
|
|
||||||
|
req := &authzextv1.WriteRequest{
|
||||||
|
Namespace: namespace,
|
||||||
|
Writes: &authzextv1.WriteRequestWrites{
|
||||||
|
TupleKeys: []*authzextv1.TupleKey{
|
||||||
|
{
|
||||||
|
// Folder parent tuples are valid without any relationship condition.
|
||||||
|
User: "folder:1",
|
||||||
|
Relation: common.RelationParent,
|
||||||
|
Object: "folder:write-authz-test",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("denies Write without zanzana:update", func(t *testing.T) {
|
||||||
|
_, err := srv.Write(newContextWithNamespace(), req)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Equal(t, codes.PermissionDenied, status.Code(err))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("allows Write with zanzana:update", func(t *testing.T) {
|
||||||
|
_, err := srv.Write(newContextWithZanzanaUpdatePermission(), req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -16,6 +16,9 @@ const (
|
|||||||
TypeNamespace = common.TypeGroupResouce
|
TypeNamespace = common.TypeGroupResouce
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TokenPermissionUpdate is required for callers to perform write operations against Zanzana (Mutate/Write).
|
||||||
|
const TokenPermissionUpdate = "zanzana:update" //nolint:gosec // G101: permission identifier, not a credential.
|
||||||
|
|
||||||
const (
|
const (
|
||||||
RelationTeamMember = common.RelationTeamMember
|
RelationTeamMember = common.RelationTeamMember
|
||||||
RelationTeamAdmin = common.RelationTeamAdmin
|
RelationTeamAdmin = common.RelationTeamAdmin
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
SELECT
|
SELECT
|
||||||
{{ .Ident "created" }},
|
{{ .Ident "created" }},
|
||||||
|
{{ .Ident "created_by" }},
|
||||||
{{ .Ident "version" }},
|
{{ .Ident "version" }},
|
||||||
{{ .Ident "active" }},
|
{{ .Ident "active" }},
|
||||||
{{ .Ident "namespace" }},
|
{{ .Ident "namespace" }},
|
||||||
{{ .Ident "name" }}
|
{{ .Ident "name" }}
|
||||||
FROM
|
FROM
|
||||||
{{ .Ident "secret_secure_value" }}
|
{{ .Ident "secret_secure_value" }}
|
||||||
WHERE
|
WHERE
|
||||||
{{ .Ident "namespace" }} = {{ .Arg .Namespace }} AND
|
{{ .Ident "namespace" }} = {{ .Arg .Namespace }} AND
|
||||||
{{ .Ident "name" }} = {{ .Arg .Name }}
|
{{ .Ident "name" }} = {{ .Arg .Name }}
|
||||||
ORDER BY {{ .Ident "version" }} DESC
|
ORDER BY {{ .Ident "version" }} DESC
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ func (sv *secureValueDB) toKubernetes() (*secretv1beta1.SecureValue, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// toCreateRow maps a Kubernetes resource into a DB row for new resources being created/inserted.
|
// toCreateRow maps a Kubernetes resource into a DB row for new resources being created/inserted.
|
||||||
func toCreateRow(createdAt, updatedAt int64, keeper string, sv *secretv1beta1.SecureValue, actorUID string) (*secureValueDB, error) {
|
func toCreateRow(createdAt, updatedAt int64, keeper string, sv *secretv1beta1.SecureValue, createdBy, updatedBy string) (*secureValueDB, error) {
|
||||||
row, err := toRow(keeper, sv, "")
|
row, err := toRow(keeper, sv, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to convert SecureValue to secureValueDB: %w", err)
|
return nil, fmt.Errorf("failed to convert SecureValue to secureValueDB: %w", err)
|
||||||
@@ -130,9 +130,9 @@ func toCreateRow(createdAt, updatedAt int64, keeper string, sv *secretv1beta1.Se
|
|||||||
|
|
||||||
row.GUID = uuid.New().String()
|
row.GUID = uuid.New().String()
|
||||||
row.Created = createdAt
|
row.Created = createdAt
|
||||||
row.CreatedBy = actorUID
|
row.CreatedBy = createdBy
|
||||||
row.Updated = updatedAt
|
row.Updated = updatedAt
|
||||||
row.UpdatedBy = actorUID
|
row.UpdatedBy = updatedBy
|
||||||
|
|
||||||
return row, nil
|
return row, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ func (s *secureValueMetadataStorage) Create(ctx context.Context, keeper string,
|
|||||||
var row *secureValueDB
|
var row *secureValueDB
|
||||||
|
|
||||||
err := s.db.Transaction(ctx, func(ctx context.Context) error {
|
err := s.db.Transaction(ctx, func(ctx context.Context) error {
|
||||||
latest, err := s.getLatestVersionAndCreatedAt(ctx, xkube.Namespace(sv.Namespace), sv.Name)
|
latest, err := s.getLatestVersionAndCreated(ctx, xkube.Namespace(sv.Namespace), sv.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("fetching latest secure value version: %w", err)
|
return fmt.Errorf("fetching latest secure value version: %w", err)
|
||||||
}
|
}
|
||||||
@@ -110,7 +110,13 @@ func (s *secureValueMetadataStorage) Create(ctx context.Context, keeper string,
|
|||||||
}
|
}
|
||||||
updatedAt := now
|
updatedAt := now
|
||||||
|
|
||||||
row, err = toCreateRow(createdAt, updatedAt, keeper, sv, actorUID)
|
createdBy := actorUID
|
||||||
|
if latest.createdBy != "" {
|
||||||
|
createdBy = latest.createdBy
|
||||||
|
}
|
||||||
|
updatedBy := actorUID
|
||||||
|
|
||||||
|
row, err = toCreateRow(createdAt, updatedAt, keeper, sv, createdBy, updatedBy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("to create row: %w", err)
|
return fmt.Errorf("to create row: %w", err)
|
||||||
}
|
}
|
||||||
@@ -161,13 +167,14 @@ func (s *secureValueMetadataStorage) Create(ctx context.Context, keeper string,
|
|||||||
return createdSecureValue, nil
|
return createdSecureValue, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type versionAndCreatedAt struct {
|
type versionAndCreated struct {
|
||||||
createdAt int64
|
createdAt int64
|
||||||
|
createdBy string
|
||||||
version int64
|
version int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *secureValueMetadataStorage) getLatestVersionAndCreatedAt(ctx context.Context, namespace xkube.Namespace, name string) (versionAndCreatedAt, error) {
|
func (s *secureValueMetadataStorage) getLatestVersionAndCreated(ctx context.Context, namespace xkube.Namespace, name string) (versionAndCreated, error) {
|
||||||
ctx, span := s.tracer.Start(ctx, "SecureValueMetadataStorage.getLatestVersionAndCreatedAt", trace.WithAttributes(
|
ctx, span := s.tracer.Start(ctx, "SecureValueMetadataStorage.getLatestVersionAndCreated", trace.WithAttributes(
|
||||||
attribute.String("name", name),
|
attribute.String("name", name),
|
||||||
attribute.String("namespace", namespace.String()),
|
attribute.String("namespace", namespace.String()),
|
||||||
))
|
))
|
||||||
@@ -181,45 +188,48 @@ func (s *secureValueMetadataStorage) getLatestVersionAndCreatedAt(ctx context.Co
|
|||||||
|
|
||||||
q, err := sqltemplate.Execute(sqlGetLatestSecureValueVersionAndCreatedAt, req)
|
q, err := sqltemplate.Execute(sqlGetLatestSecureValueVersionAndCreatedAt, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return versionAndCreatedAt{}, fmt.Errorf("execute template %q: %w", sqlGetLatestSecureValueVersionAndCreatedAt.Name(), err)
|
return versionAndCreated{}, fmt.Errorf("execute template %q: %w", sqlGetLatestSecureValueVersionAndCreatedAt.Name(), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := s.db.QueryContext(ctx, q, req.GetArgs()...)
|
rows, err := s.db.QueryContext(ctx, q, req.GetArgs()...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return versionAndCreatedAt{}, fmt.Errorf("fetching latest version for secure value: namespace=%+v name=%+v %w", namespace, name, err)
|
return versionAndCreated{}, fmt.Errorf("fetching latest version for secure value: namespace=%+v name=%+v %w", namespace, name, err)
|
||||||
}
|
}
|
||||||
defer func() { _ = rows.Close() }()
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
return versionAndCreatedAt{}, fmt.Errorf("error executing query: %w", err)
|
return versionAndCreated{}, fmt.Errorf("error executing query: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !rows.Next() {
|
if !rows.Next() {
|
||||||
return versionAndCreatedAt{}, nil
|
return versionAndCreated{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
createdAt int64
|
createdAt int64
|
||||||
|
createdBy string
|
||||||
version int64
|
version int64
|
||||||
active bool
|
active bool
|
||||||
namespaceFromDB string
|
namespaceFromDB string
|
||||||
nameFromDB string
|
nameFromDB string
|
||||||
)
|
)
|
||||||
if err := rows.Scan(&createdAt, &version, &active, &namespaceFromDB, &nameFromDB); err != nil {
|
if err := rows.Scan(&createdAt, &createdBy, &version, &active, &namespaceFromDB, &nameFromDB); err != nil {
|
||||||
return versionAndCreatedAt{}, fmt.Errorf("scanning version from returned rows: %w", err)
|
return versionAndCreated{}, fmt.Errorf("scanning version and created from returned rows: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if namespaceFromDB != namespace.String() || nameFromDB != name {
|
if namespaceFromDB != namespace.String() || nameFromDB != name {
|
||||||
return versionAndCreatedAt{}, fmt.Errorf("bug: expected to find latest version for namespace=%+v name=%+v but got version for namespace=%+v name=%+v",
|
return versionAndCreated{}, fmt.Errorf("bug: expected to find version and created for namespace=%+v name=%+v but got for namespace=%+v name=%+v",
|
||||||
namespace, name, namespaceFromDB, nameFromDB)
|
namespace, name, namespaceFromDB, nameFromDB)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !active {
|
if !active {
|
||||||
createdAt = 0
|
createdAt = 0
|
||||||
|
createdBy = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
return versionAndCreatedAt{
|
return versionAndCreated{
|
||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
|
createdBy: createdBy,
|
||||||
version: version,
|
version: version,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -1,12 +1,13 @@
|
|||||||
SELECT
|
SELECT
|
||||||
`created`,
|
`created`,
|
||||||
|
`created_by`,
|
||||||
`version`,
|
`version`,
|
||||||
`active`,
|
`active`,
|
||||||
`namespace`,
|
`namespace`,
|
||||||
`name`
|
`name`
|
||||||
FROM
|
FROM
|
||||||
`secret_secure_value`
|
`secret_secure_value`
|
||||||
WHERE
|
WHERE
|
||||||
`namespace` = 'ns' AND
|
`namespace` = 'ns' AND
|
||||||
`name` = 'name'
|
`name` = 'name'
|
||||||
ORDER BY `version` DESC
|
ORDER BY `version` DESC
|
||||||
|
|||||||
+2
-1
@@ -1,12 +1,13 @@
|
|||||||
SELECT
|
SELECT
|
||||||
"created",
|
"created",
|
||||||
|
"created_by",
|
||||||
"version",
|
"version",
|
||||||
"active",
|
"active",
|
||||||
"namespace",
|
"namespace",
|
||||||
"name"
|
"name"
|
||||||
FROM
|
FROM
|
||||||
"secret_secure_value"
|
"secret_secure_value"
|
||||||
WHERE
|
WHERE
|
||||||
"namespace" = 'ns' AND
|
"namespace" = 'ns' AND
|
||||||
"name" = 'name'
|
"name" = 'name'
|
||||||
ORDER BY "version" DESC
|
ORDER BY "version" DESC
|
||||||
|
|||||||
+2
-1
@@ -1,12 +1,13 @@
|
|||||||
SELECT
|
SELECT
|
||||||
"created",
|
"created",
|
||||||
|
"created_by",
|
||||||
"version",
|
"version",
|
||||||
"active",
|
"active",
|
||||||
"namespace",
|
"namespace",
|
||||||
"name"
|
"name"
|
||||||
FROM
|
FROM
|
||||||
"secret_secure_value"
|
"secret_secure_value"
|
||||||
WHERE
|
WHERE
|
||||||
"namespace" = 'ns' AND
|
"namespace" = 'ns' AND
|
||||||
"name" = 'name'
|
"name" = 'name'
|
||||||
ORDER BY "version" DESC
|
ORDER BY "version" DESC
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import (
|
|||||||
bleveSearch "github.com/blevesearch/bleve/v2/search/searcher"
|
bleveSearch "github.com/blevesearch/bleve/v2/search/searcher"
|
||||||
index "github.com/blevesearch/bleve_index_api"
|
index "github.com/blevesearch/bleve_index_api"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
bolterrors "go.etcd.io/bbolt/errors"
|
||||||
"go.opentelemetry.io/otel"
|
"go.opentelemetry.io/otel"
|
||||||
"go.opentelemetry.io/otel/attribute"
|
"go.opentelemetry.io/otel/attribute"
|
||||||
"go.uber.org/atomic"
|
"go.uber.org/atomic"
|
||||||
@@ -44,6 +45,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
indexStorageMemory = "memory"
|
indexStorageMemory = "memory"
|
||||||
indexStorageFile = "file"
|
indexStorageFile = "file"
|
||||||
|
boltTimeout = "500ms"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Keys used to store internal data in index.
|
// Keys used to store internal data in index.
|
||||||
@@ -415,14 +417,25 @@ func (b *bleveBackend) BuildIndex(
|
|||||||
// This happens on startup, or when memory-based index has expired. (We don't expire file-based indexes)
|
// This happens on startup, or when memory-based index has expired. (We don't expire file-based indexes)
|
||||||
// If we do have an unexpired cached index already, we always build a new index from scratch.
|
// If we do have an unexpired cached index already, we always build a new index from scratch.
|
||||||
if cachedIndex == nil && !rebuild {
|
if cachedIndex == nil && !rebuild {
|
||||||
index, fileIndexName, indexRV = b.findPreviousFileBasedIndex(resourceDir)
|
result := b.findPreviousFileBasedIndex(resourceDir)
|
||||||
|
if result != nil && result.IsOpen {
|
||||||
|
// Index file exists but is opened by another process, fallback to memory.
|
||||||
|
// Keep the name so we can skip cleanup of that directory.
|
||||||
|
newIndexType = indexStorageMemory
|
||||||
|
fileIndexName = result.Name
|
||||||
|
} else if result != nil && result.Index != nil {
|
||||||
|
// Found and opened existing index successfully
|
||||||
|
index = result.Index
|
||||||
|
fileIndexName = result.Name
|
||||||
|
indexRV = result.RV
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if index != nil {
|
if newIndexType == indexStorageFile && index != nil {
|
||||||
build = false
|
build = false
|
||||||
logWithDetails.Debug("Existing index found on filesystem", "indexRV", indexRV, "directory", filepath.Join(resourceDir, fileIndexName))
|
logWithDetails.Debug("Existing index found on filesystem", "indexRV", indexRV, "directory", filepath.Join(resourceDir, fileIndexName))
|
||||||
defer closeIndexOnExit(index, "") // Close index, but don't delete directory.
|
defer closeIndexOnExit(index, "") // Close index, but don't delete directory.
|
||||||
} else {
|
} else if newIndexType == indexStorageFile {
|
||||||
// Building index from scratch. Index name has a time component in it to be unique, but if
|
// Building index from scratch. Index name has a time component in it to be unique, but if
|
||||||
// we happen to create non-unique name, we bump the time and try again.
|
// we happen to create non-unique name, we bump the time and try again.
|
||||||
|
|
||||||
@@ -449,7 +462,9 @@ func (b *bleveBackend) BuildIndex(
|
|||||||
logWithDetails.Info("Building index using filesystem", "directory", indexDir)
|
logWithDetails.Info("Building index using filesystem", "directory", indexDir)
|
||||||
defer closeIndexOnExit(index, indexDir) // Close index, and delete new index directory.
|
defer closeIndexOnExit(index, indexDir) // Close index, and delete new index directory.
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
if newIndexType == indexStorageMemory {
|
||||||
index, err = newBleveIndex("", mapper, time.Now(), b.opts.BuildVersion)
|
index, err = newBleveIndex("", mapper, time.Now(), b.opts.BuildVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error creating new in-memory bleve index: %w", err)
|
return nil, fmt.Errorf("error creating new in-memory bleve index: %w", err)
|
||||||
@@ -552,30 +567,30 @@ func cleanFileSegment(input string) string {
|
|||||||
return input
|
return input
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanOldIndexes deletes all subdirectories inside dir, skipping directory with "skipName".
|
// cleanOldIndexes deletes all subdirectories inside resourceDir, skipping directory with "skipName".
|
||||||
// "skipName" can be empty.
|
// "skipName" can be empty.
|
||||||
func (b *bleveBackend) cleanOldIndexes(dir string, skipName string) {
|
func (b *bleveBackend) cleanOldIndexes(resourceDir string, skipName string) {
|
||||||
files, err := os.ReadDir(dir)
|
entries, err := os.ReadDir(resourceDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
b.log.Warn("error cleaning folders from", "directory", dir, "error", err)
|
b.log.Warn("error cleaning folders from", "directory", resourceDir, "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, file := range files {
|
for _, ent := range entries {
|
||||||
if file.IsDir() && file.Name() != skipName {
|
if ent.IsDir() && ent.Name() != skipName {
|
||||||
fpath := filepath.Join(dir, file.Name())
|
indexDir := filepath.Join(resourceDir, ent.Name())
|
||||||
if !isPathWithinRoot(fpath, b.opts.Root) {
|
if !isPathWithinRoot(indexDir, b.opts.Root) {
|
||||||
b.log.Warn("Skipping cleanup of directory", "directory", fpath)
|
b.log.Warn("Skipping cleanup of directory", "directory", indexDir)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
err = os.RemoveAll(fpath)
|
err = os.RemoveAll(indexDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
b.log.Error("Unable to remove old index folder", "directory", fpath, "error", err)
|
b.log.Error("Unable to remove old index folder", "directory", indexDir, "error", err)
|
||||||
} else {
|
} else {
|
||||||
b.log.Info("Removed old index folder", "directory", fpath)
|
b.log.Info("Removed old index folder", "directory", indexDir)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -622,10 +637,17 @@ func formatIndexName(now time.Time) string {
|
|||||||
return now.Format("20060102-150405")
|
return now.Format("20060102-150405")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *bleveBackend) findPreviousFileBasedIndex(resourceDir string) (bleve.Index, string, int64) {
|
type fileIndex struct {
|
||||||
|
Index bleve.Index
|
||||||
|
Name string
|
||||||
|
RV int64
|
||||||
|
IsOpen bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *bleveBackend) findPreviousFileBasedIndex(resourceDir string) *fileIndex {
|
||||||
entries, err := os.ReadDir(resourceDir)
|
entries, err := os.ReadDir(resourceDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", 0
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, ent := range entries {
|
for _, ent := range entries {
|
||||||
@@ -635,8 +657,13 @@ func (b *bleveBackend) findPreviousFileBasedIndex(resourceDir string) (bleve.Ind
|
|||||||
|
|
||||||
indexName := ent.Name()
|
indexName := ent.Name()
|
||||||
indexDir := filepath.Join(resourceDir, indexName)
|
indexDir := filepath.Join(resourceDir, indexName)
|
||||||
idx, err := bleve.Open(indexDir)
|
|
||||||
|
idx, err := bleve.OpenUsing(indexDir, map[string]interface{}{"bolt_timeout": boltTimeout})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, bolterrors.ErrTimeout) {
|
||||||
|
b.log.Debug("Index is opened by another process (timeout), skipping", "indexDir", indexDir)
|
||||||
|
return &fileIndex{Name: indexName, IsOpen: true}
|
||||||
|
}
|
||||||
b.log.Debug("error opening index", "indexDir", indexDir, "err", err)
|
b.log.Debug("error opening index", "indexDir", indexDir, "err", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -648,10 +675,14 @@ func (b *bleveBackend) findPreviousFileBasedIndex(resourceDir string) (bleve.Ind
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
return idx, indexName, indexRV
|
return &fileIndex{
|
||||||
|
Index: idx,
|
||||||
|
Name: indexName,
|
||||||
|
RV: indexRV,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, "", 0
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop closes all indexes and stops background tasks.
|
// Stop closes all indexes and stops background tasks.
|
||||||
|
|||||||
@@ -1583,3 +1583,76 @@ func docCount(t *testing.T, idx resource.ResourceIndex) int {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
return int(cnt)
|
return int(cnt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBleveBackendFallsBackToMemory(t *testing.T) {
|
||||||
|
ns := resource.NamespacedResource{
|
||||||
|
Namespace: "test",
|
||||||
|
Group: "group",
|
||||||
|
Resource: "resource",
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// First, create a file-based index with one backend and keep it open
|
||||||
|
backend1, reg1 := setupBleveBackend(t, withRootDir(tmpDir))
|
||||||
|
index1, err := backend1.BuildIndex(context.Background(), ns, 100 /* file based */, nil, "test", indexTestDocs(ns, 10, 100), nil, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, index1)
|
||||||
|
|
||||||
|
// Verify first index is file-based
|
||||||
|
bleveIdx1, ok := index1.(*bleveIndex)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, indexStorageFile, bleveIdx1.indexStorage)
|
||||||
|
checkOpenIndexes(t, reg1, 0, 1)
|
||||||
|
|
||||||
|
// Now create a second backend using the same directory
|
||||||
|
// This simulates another instance trying to open the same index
|
||||||
|
backend2, reg2 := setupBleveBackend(t, withRootDir(tmpDir))
|
||||||
|
|
||||||
|
// BuildIndex should detect the file is locked and fallback to memory
|
||||||
|
index2, err := backend2.BuildIndex(context.Background(), ns, 100 /* file based */, nil, "test", indexTestDocs(ns, 10, 100), nil, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, index2)
|
||||||
|
|
||||||
|
// Verify second index fell back to in-memory despite size being above file threshold
|
||||||
|
bleveIdx2, ok := index2.(*bleveIndex)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, indexStorageMemory, bleveIdx2.indexStorage)
|
||||||
|
|
||||||
|
// Verify metrics show 1 memory index and 0 file indexes for backend2
|
||||||
|
checkOpenIndexes(t, reg2, 1, 0)
|
||||||
|
|
||||||
|
// Verify the in-memory index works correctly
|
||||||
|
require.Equal(t, 10, docCount(t, index2))
|
||||||
|
|
||||||
|
// Clean up: close first backend to release the file lock
|
||||||
|
backend1.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBleveSkipCleanOldIndexesOnMemoryFallback(t *testing.T) {
|
||||||
|
ns := resource.NamespacedResource{
|
||||||
|
Namespace: "test",
|
||||||
|
Group: "group",
|
||||||
|
Resource: "resource",
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
backend1, _ := setupBleveBackend(t, withRootDir(tmpDir))
|
||||||
|
_, err := backend1.BuildIndex(context.Background(), ns, 100 /* file based */, nil, "test", indexTestDocs(ns, 10, 100), nil, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Now create a second backend using the same directory
|
||||||
|
// This simulates another instance trying to open the same index
|
||||||
|
backend2, _ := setupBleveBackend(t, withRootDir(tmpDir))
|
||||||
|
|
||||||
|
// BuildIndex should detect the file is locked and fallback to memory
|
||||||
|
_, err = backend2.BuildIndex(context.Background(), ns, 100 /* file based */, nil, "test", indexTestDocs(ns, 10, 100), nil, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify that the index directory still exists (i.e., cleanOldIndexes was skipped)
|
||||||
|
verifyDirEntriesCount(t, backend2.getResourceDir(ns), 1)
|
||||||
|
|
||||||
|
// Clean up: close first backend to release the file lock
|
||||||
|
backend1.Stop()
|
||||||
|
}
|
||||||
|
|||||||
@@ -2470,7 +2470,7 @@ var expNonEmailNotifications = map[string][]string{
|
|||||||
"title_link": "http://localhost:3000/alerting/grafana/UID_SlackAlert1/view?orgId=1",
|
"title_link": "http://localhost:3000/alerting/grafana/UID_SlackAlert1/view?orgId=1",
|
||||||
"text": "Integration Test ",
|
"text": "Integration Test ",
|
||||||
"fallback": "Integration Test [FIRING:1] SlackAlert1 (default)",
|
"fallback": "Integration Test [FIRING:1] SlackAlert1 (default)",
|
||||||
"footer": "Grafana v",
|
"footer": "Grafana",
|
||||||
"footer_icon": "https://grafana.com/static/assets/img/fav32.png",
|
"footer_icon": "https://grafana.com/static/assets/img/fav32.png",
|
||||||
"color": "#D63232",
|
"color": "#D63232",
|
||||||
"ts": %s,
|
"ts": %s,
|
||||||
@@ -2490,7 +2490,7 @@ var expNonEmailNotifications = map[string][]string{
|
|||||||
"title_link": "http://localhost:3000/alerting/grafana/UID_SlackAlert2/view?orgId=1",
|
"title_link": "http://localhost:3000/alerting/grafana/UID_SlackAlert2/view?orgId=1",
|
||||||
"text": "**Firing**\n\nValue: A=1\nLabels:\n - alertname = SlackAlert2\n - grafana_folder = default\nAnnotations:\nSource: http://localhost:3000/alerting/grafana/UID_SlackAlert2/view?orgId=1\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=__alert_rule_uid__%%3DUID_SlackAlert2&orgId=1\n",
|
"text": "**Firing**\n\nValue: A=1\nLabels:\n - alertname = SlackAlert2\n - grafana_folder = default\nAnnotations:\nSource: http://localhost:3000/alerting/grafana/UID_SlackAlert2/view?orgId=1\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=__alert_rule_uid__%%3DUID_SlackAlert2&orgId=1\n",
|
||||||
"fallback": "[FIRING:1] SlackAlert2 (default)",
|
"fallback": "[FIRING:1] SlackAlert2 (default)",
|
||||||
"footer": "Grafana v",
|
"footer": "Grafana",
|
||||||
"footer_icon": "https://grafana.com/static/assets/img/fav32.png",
|
"footer_icon": "https://grafana.com/static/assets/img/fav32.png",
|
||||||
"color": "#D63232",
|
"color": "#D63232",
|
||||||
"ts": %s,
|
"ts": %s,
|
||||||
|
|||||||
@@ -2699,6 +2699,24 @@
|
|||||||
"secure": false,
|
"secure": false,
|
||||||
"dependsOn": "",
|
"dependsOn": "",
|
||||||
"subformOptions": null
|
"subformOptions": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"element": "input",
|
||||||
|
"inputType": "text",
|
||||||
|
"label": "Footer",
|
||||||
|
"description": "Templated footer of the slack message",
|
||||||
|
"placeholder": "{{ template \"slack.default.footer\" . }}",
|
||||||
|
"propertyName": "footer",
|
||||||
|
"selectOptions": null,
|
||||||
|
"showWhen": {
|
||||||
|
"field": "",
|
||||||
|
"is": ""
|
||||||
|
},
|
||||||
|
"required": false,
|
||||||
|
"validationRule": "",
|
||||||
|
"secure": false,
|
||||||
|
"dependsOn": "",
|
||||||
|
"subformOptions": null
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7017,6 +7017,24 @@
|
|||||||
"secure": false,
|
"secure": false,
|
||||||
"dependsOn": "",
|
"dependsOn": "",
|
||||||
"subformOptions": null
|
"subformOptions": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"element": "input",
|
||||||
|
"inputType": "text",
|
||||||
|
"label": "Footer",
|
||||||
|
"description": "Templated footer of the slack message",
|
||||||
|
"placeholder": "{{ template \"slack.default.footer\" . }}",
|
||||||
|
"propertyName": "footer",
|
||||||
|
"selectOptions": null,
|
||||||
|
"showWhen": {
|
||||||
|
"field": "",
|
||||||
|
"is": ""
|
||||||
|
},
|
||||||
|
"required": false,
|
||||||
|
"validationRule": "",
|
||||||
|
"secure": false,
|
||||||
|
"dependsOn": "",
|
||||||
|
"subformOptions": null
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -132,53 +132,6 @@ func TestIntegrationDashboardAPIValidation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestIntegrationDashboardAPIAuthorization tests the dashboard K8s API with authorization checks
|
|
||||||
func TestIntegrationDashboardAPIAuthorization(t *testing.T) {
|
|
||||||
testutil.SkipIntegrationTestInShortMode(t)
|
|
||||||
|
|
||||||
dualWriterModes := []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode2, rest.Mode3, rest.Mode4, rest.Mode5}
|
|
||||||
for _, dualWriterMode := range dualWriterModes {
|
|
||||||
t.Run(fmt.Sprintf("DualWriterMode %d", dualWriterMode), func(t *testing.T) {
|
|
||||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
|
||||||
DisableDataMigrations: true,
|
|
||||||
DisableAnonymous: true,
|
|
||||||
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
|
|
||||||
"dashboards.dashboard.grafana.app": {
|
|
||||||
DualWriterMode: dualWriterMode,
|
|
||||||
},
|
|
||||||
"folders.folder.grafana.app": {
|
|
||||||
DualWriterMode: dualWriterMode,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
UnifiedStorageEnableSearch: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
helper.Shutdown()
|
|
||||||
})
|
|
||||||
|
|
||||||
org1Ctx := createTestContext(t, helper, helper.Org1, dualWriterMode)
|
|
||||||
org2Ctx := createTestContext(t, helper, helper.OrgB, dualWriterMode)
|
|
||||||
|
|
||||||
t.Run("Authorization tests for all identity types", func(t *testing.T) {
|
|
||||||
runAuthorizationTests(t, org1Ctx)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Dashboard permission tests", func(t *testing.T) {
|
|
||||||
runDashboardPermissionTests(t, org1Ctx, true)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Cross-organization tests", func(t *testing.T) {
|
|
||||||
runCrossOrgTests(t, org1Ctx, org2Ctx)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Dashboard HTTP API test", func(t *testing.T) {
|
|
||||||
runDashboardHttpTest(t, org1Ctx, org2Ctx)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestIntegrationDashboardAPI tests the dashboard K8s API
|
// TestIntegrationDashboardAPI tests the dashboard K8s API
|
||||||
func TestIntegrationDashboardAPI(t *testing.T) {
|
func TestIntegrationDashboardAPI(t *testing.T) {
|
||||||
testutil.SkipIntegrationTestInShortMode(t)
|
testutil.SkipIntegrationTestInShortMode(t)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"github.com/xlab/treeprint"
|
"github.com/xlab/treeprint"
|
||||||
@@ -31,6 +32,33 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/util/testutil"
|
"github.com/grafana/grafana/pkg/util/testutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestIntegrationFolderTreeZanzana(t *testing.T) {
|
||||||
|
testutil.SkipIntegrationTestInShortMode(t)
|
||||||
|
|
||||||
|
runIntegrationFolderTree(t, testinfra.GrafanaOpts{
|
||||||
|
DisableDataMigrations: true,
|
||||||
|
AppModeProduction: true,
|
||||||
|
DisableAnonymous: true,
|
||||||
|
APIServerStorageType: "unified",
|
||||||
|
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
|
||||||
|
"dashboards.dashboard.grafana.app": {
|
||||||
|
DualWriterMode: grafanarest.Mode5,
|
||||||
|
},
|
||||||
|
folderV1.RESOURCEGROUP: {
|
||||||
|
DualWriterMode: grafanarest.Mode5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
EnableFeatureToggles: []string{
|
||||||
|
"zanzana",
|
||||||
|
"zanzanaNoLegacyClient",
|
||||||
|
"kubernetesAuthzZanzanaSync",
|
||||||
|
},
|
||||||
|
UnifiedStorageEnableSearch: true,
|
||||||
|
ZanzanaReconciliationInterval: 100 * time.Millisecond,
|
||||||
|
DisableZanzanaCache: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestIntegrationFolderTree(t *testing.T) {
|
func TestIntegrationFolderTree(t *testing.T) {
|
||||||
testutil.SkipIntegrationTestInShortMode(t)
|
testutil.SkipIntegrationTestInShortMode(t)
|
||||||
|
|
||||||
@@ -47,7 +75,7 @@ func TestIntegrationFolderTree(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, mode := range modes {
|
for _, mode := range modes {
|
||||||
t.Run(fmt.Sprintf("mode %d", mode), func(t *testing.T) {
|
t.Run(fmt.Sprintf("mode %d", mode), func(t *testing.T) {
|
||||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
runIntegrationFolderTree(t, testinfra.GrafanaOpts{
|
||||||
DisableDataMigrations: true,
|
DisableDataMigrations: true,
|
||||||
AppModeProduction: true,
|
AppModeProduction: true,
|
||||||
DisableAnonymous: true,
|
DisableAnonymous: true,
|
||||||
@@ -62,113 +90,122 @@ func TestIntegrationFolderTree(t *testing.T) {
|
|||||||
},
|
},
|
||||||
UnifiedStorageEnableSearch: mode >= grafanarest.Mode3, // make sure modes 0-3 work without search enabled
|
UnifiedStorageEnableSearch: mode >= grafanarest.Mode3, // make sure modes 0-3 work without search enabled
|
||||||
})
|
})
|
||||||
defer helper.Shutdown()
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tests := []struct {
|
func runIntegrationFolderTree(t *testing.T, opts testinfra.GrafanaOpts) {
|
||||||
Name string
|
if !db.IsTestDbSQLite() {
|
||||||
Definition FolderDefinition
|
t.Skip("test only on sqlite for now")
|
||||||
Expected []ExpectedTree
|
}
|
||||||
}{
|
|
||||||
{
|
helper := apis.NewK8sTestHelper(t, opts)
|
||||||
Name: "admin-only-tree",
|
defer helper.Shutdown()
|
||||||
Definition: FolderDefinition{
|
|
||||||
|
apis.AwaitZanzanaReconcileNext(t, helper)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
Name string
|
||||||
|
Definition FolderDefinition
|
||||||
|
Expected []ExpectedTree
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "admin-only-tree",
|
||||||
|
Definition: FolderDefinition{
|
||||||
|
Children: []FolderDefinition{
|
||||||
|
{Name: "top",
|
||||||
|
Creator: helper.Org1.Admin,
|
||||||
Children: []FolderDefinition{
|
Children: []FolderDefinition{
|
||||||
{Name: "top",
|
{Name: "middle",
|
||||||
Creator: helper.Org1.Admin,
|
Creator: helper.Org1.Admin,
|
||||||
Children: []FolderDefinition{
|
Children: []FolderDefinition{
|
||||||
{Name: "middle",
|
{Name: "child",
|
||||||
Creator: helper.Org1.Admin,
|
Creator: helper.Org1.Admin,
|
||||||
Children: []FolderDefinition{
|
Permissions: []FolderPermission{{
|
||||||
{Name: "child",
|
Permission: "View",
|
||||||
Creator: helper.Org1.Admin,
|
User: helper.Org1.None,
|
||||||
Permissions: []FolderPermission{{
|
}},
|
||||||
Permission: "View",
|
|
||||||
User: helper.Org1.None,
|
|
||||||
}},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Expected: []ExpectedTree{
|
},
|
||||||
{User: helper.Org1.Admin, Listing: `
|
},
|
||||||
|
Expected: []ExpectedTree{
|
||||||
|
{User: helper.Org1.Admin, Listing: `
|
||||||
└── top (admin,edit,save,delete)
|
└── top (admin,edit,save,delete)
|
||||||
....└── middle (admin,edit,save,delete)
|
....└── middle (admin,edit,save,delete)
|
||||||
........└── child (admin,edit,save,delete)`},
|
........└── child (admin,edit,save,delete)`},
|
||||||
{User: helper.Org1.Viewer, Listing: `
|
{User: helper.Org1.Viewer, Listing: `
|
||||||
└── top (view)
|
└── top (view)
|
||||||
....└── middle (view)
|
....└── middle (view)
|
||||||
........└── child (view)`},
|
........└── child (view)`},
|
||||||
{User: helper.Org1.None, Listing: `
|
{User: helper.Org1.None, Listing: `
|
||||||
└── sharedwithme (???)
|
└── sharedwithme (???)
|
||||||
....└── child (view)`,
|
....└── child (view)`,
|
||||||
E403: []string{"top", "middle"},
|
E403: []string{"top", "middle"},
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
var statusCode int
|
var statusCode int
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.Name, func(t *testing.T) {
|
t.Run(tt.Name, func(t *testing.T) {
|
||||||
tt.Definition.RequireUniqueName(t, make(map[string]bool))
|
tt.Definition.RequireUniqueName(t, make(map[string]bool))
|
||||||
|
|
||||||
tt.Definition.CreateWithLegacyAPI(t, helper, "")
|
tt.Definition.CreateWithLegacyAPI(t, helper, "")
|
||||||
// CreateWithLegacyAPI
|
|
||||||
|
|
||||||
for _, expect := range tt.Expected {
|
for _, expect := range tt.Expected {
|
||||||
unstructured, client := getFolderClients(t, expect.User)
|
unstructured, client := getFolderClients(t, expect.User)
|
||||||
t.Run(fmt.Sprintf("query as %s", expect.User.Identity.GetLogin()), func(t *testing.T) {
|
t.Run(fmt.Sprintf("query as %s", expect.User.Identity.GetLogin()), func(t *testing.T) {
|
||||||
legacy := getFoldersFromLegacyAPISearch(t, client)
|
legacy := getFoldersFromLegacyAPISearch(t, client)
|
||||||
legacy.requireEqual(t, expect.Listing, "legacy")
|
legacy.requireEqual(t, expect.Listing, "legacy")
|
||||||
|
|
||||||
listed := getFoldersFromAPIServerList(t, unstructured)
|
listed := getFoldersFromAPIServerList(t, unstructured)
|
||||||
listed.requireEqual(t, expect.Listing, "listed")
|
listed.requireEqual(t, expect.Listing, "listed")
|
||||||
|
|
||||||
search := getFoldersFromDashboardV0Search(t, client, expect.User.Identity.GetNamespace())
|
search := getFoldersFromDashboardV0Search(t, client, expect.User.Identity.GetNamespace())
|
||||||
search.requireEqual(t, expect.Listing, "search")
|
search.requireEqual(t, expect.Listing, "search")
|
||||||
|
|
||||||
// ensure sure GET also works on each folder we can list
|
// ensure sure GET also works on each folder we can list
|
||||||
listed.forEach(func(fv *FolderView) {
|
listed.forEach(func(fv *FolderView) {
|
||||||
if fv.Name == folder.SharedWithMeFolderUID {
|
if fv.Name == folder.SharedWithMeFolderUID {
|
||||||
return // skip it
|
return // skip it
|
||||||
}
|
}
|
||||||
found, err := unstructured.Get(context.Background(), fv.Name, v1.GetOptions{})
|
found, err := unstructured.Get(context.Background(), fv.Name, v1.GetOptions{})
|
||||||
require.NoErrorf(t, err, "getting folder: %s", fv.Name)
|
require.NoErrorf(t, err, "getting folder: %s", fv.Name)
|
||||||
require.Equal(t, found.GetName(), fv.Name)
|
require.Equal(t, found.GetName(), fv.Name)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Forbidden things should really be hidden
|
// Forbidden things should really be hidden
|
||||||
for _, name := range expect.E403 {
|
for _, name := range expect.E403 {
|
||||||
_, err := unstructured.Get(context.Background(), name, v1.GetOptions{})
|
_, err := unstructured.Get(context.Background(), name, v1.GetOptions{})
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
require.Truef(t, apierrors.IsForbidden(err), "error: %w", err) // 404 vs 403 ????
|
require.Truef(t, apierrors.IsForbidden(err), "error: %w", err) // 404 vs 403 ????
|
||||||
|
|
||||||
result := client.Get().AbsPath("api", "folders", name).
|
result := client.Get().AbsPath("api", "folders", name).
|
||||||
Do(context.Background()).
|
Do(context.Background()).
|
||||||
StatusCode(&statusCode)
|
StatusCode(&statusCode)
|
||||||
require.Equal(t, int(http.StatusForbidden), statusCode)
|
require.Equal(t, int(http.StatusForbidden), statusCode)
|
||||||
require.Error(t, result.Error())
|
require.Error(t, result.Error())
|
||||||
|
|
||||||
// Verify sub-resources are hidden
|
// Verify sub-resources are hidden
|
||||||
for _, sub := range []string{"access", "parents", "children", "counts"} {
|
for _, sub := range []string{"access", "parents", "children", "counts"} {
|
||||||
_, err := unstructured.Get(context.Background(), name, v1.GetOptions{}, sub)
|
_, err := unstructured.Get(context.Background(), name, v1.GetOptions{}, sub)
|
||||||
require.Error(t, err, "expect error for subresource", sub)
|
require.Error(t, err, "expect error for subresource", sub)
|
||||||
require.Truef(t, apierrors.IsForbidden(err), "error: %w", err) // 404 vs 403 ????
|
require.Truef(t, apierrors.IsForbidden(err), "error: %w", err) // 404 vs 403 ????
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify legacy API access is also hidden
|
// Verify legacy API access is also hidden
|
||||||
for _, sub := range []string{"permissions", "counts"} {
|
for _, sub := range []string{"permissions", "counts"} {
|
||||||
result := client.Get().AbsPath("api", "folders", name, sub).
|
result := client.Get().AbsPath("api", "folders", name, sub).
|
||||||
Do(context.Background()).
|
Do(context.Background()).
|
||||||
StatusCode(&statusCode)
|
StatusCode(&statusCode)
|
||||||
require.Equalf(t, int(http.StatusForbidden), statusCode, "legacy access to: %s", sub)
|
require.Equalf(t, int(http.StatusForbidden), statusCode, "legacy access to: %s", sub)
|
||||||
require.Error(t, result.Error())
|
require.Error(t, result.Error())
|
||||||
}
|
}
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -212,6 +249,8 @@ func (f *FolderDefinition) CreateWithLegacyAPI(t *testing.T, h *apis.K8sTestHelp
|
|||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
apis.AwaitZanzanaReconcileNext(t, h)
|
||||||
|
|
||||||
var statusCode int
|
var statusCode int
|
||||||
result := client.Post().AbsPath("api", "folders").
|
result := client.Post().AbsPath("api", "folders").
|
||||||
Body(body).
|
Body(body).
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package apis
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
dto "github.com/prometheus/client_model/go"
|
||||||
|
"github.com/prometheus/common/expfmt"
|
||||||
|
"github.com/prometheus/common/model"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
const zanzanaReconcileLastSuccessMetric = "grafana_zanzana_reconcile_last_success_timestamp_seconds"
|
||||||
|
|
||||||
|
// AwaitZanzanaReconcileNext waits for the next Zanzana reconciliation cycle to complete.
|
||||||
|
// It is a no-op unless the `zanzana` feature toggle is enabled for the running test env.
|
||||||
|
func AwaitZanzanaReconcileNext(t *testing.T, helper *K8sTestHelper) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
enabled := false
|
||||||
|
if helper != nil {
|
||||||
|
enabled = helper.GetEnv().FeatureToggles.GetEnabled(context.Background())[featuremgmt.FlagZanzana]
|
||||||
|
}
|
||||||
|
if helper == nil || !enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
prev, ok := getZanzanaReconcileLastSuccessTimestampSeconds(t, helper)
|
||||||
|
if !ok {
|
||||||
|
prev = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
require.EventuallyWithT(t, func(c *assert.CollectT) {
|
||||||
|
ts, ok := getZanzanaReconcileLastSuccessTimestampSeconds(t, helper)
|
||||||
|
assert.True(c, ok, "expected to find %s in /metrics", zanzanaReconcileLastSuccessMetric)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assert.Greater(c, ts, prev, "expected %s (%v) > %v", zanzanaReconcileLastSuccessMetric, ts, prev)
|
||||||
|
}, 30*time.Second, 50*time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getZanzanaReconcileLastSuccessTimestampSeconds(t *testing.T, helper *K8sTestHelper) (float64, bool) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
rsp := DoRequest(helper, RequestParams{
|
||||||
|
User: helper.Org1.Admin,
|
||||||
|
Path: "/metrics",
|
||||||
|
Accept: "text/plain",
|
||||||
|
}, &struct{}{})
|
||||||
|
if rsp.Response == nil || rsp.Response.StatusCode != http.StatusOK {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
parser := expfmt.NewTextParser(model.UTF8Validation)
|
||||||
|
metrics, err := parser.TextToMetricFamilies(bytes.NewReader(rsp.Body))
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
metric := metrics[zanzanaReconcileLastSuccessMetric]
|
||||||
|
if metric == nil || len(metric.Metric) == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
m := metric.Metric[0]
|
||||||
|
switch metric.GetType() {
|
||||||
|
case dto.MetricType_GAUGE:
|
||||||
|
if m.Gauge == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return m.Gauge.GetValue(), true
|
||||||
|
case dto.MetricType_UNTYPED:
|
||||||
|
if m.Untyped == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return m.Untyped.GetValue(), true
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -609,6 +609,20 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if opts.ZanzanaReconciliationInterval != 0 {
|
||||||
|
rbacSect, err := cfg.NewSection("rbac")
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = rbacSect.NewKey("zanzana_reconciliation_interval", opts.ZanzanaReconciliationInterval.String())
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.DisableZanzanaCache {
|
||||||
|
rbacSect, err := cfg.NewSection("rbac")
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = rbacSect.NewKey("disable_zanzana_cache", "true")
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
dashboardsSection, err := getOrCreateSection("dashboards")
|
dashboardsSection, err := getOrCreateSection("dashboards")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
_, err = dashboardsSection.NewKey("min_refresh_interval", "10s")
|
_, err = dashboardsSection.NewKey("min_refresh_interval", "10s")
|
||||||
@@ -687,6 +701,8 @@ type GrafanaOpts struct {
|
|||||||
SecretsManagerEnableDBMigrations bool
|
SecretsManagerEnableDBMigrations bool
|
||||||
OpenFeatureAPIEnabled bool
|
OpenFeatureAPIEnabled bool
|
||||||
DisableAuthZClientCache bool
|
DisableAuthZClientCache bool
|
||||||
|
ZanzanaReconciliationInterval time.Duration
|
||||||
|
DisableZanzanaCache bool
|
||||||
|
|
||||||
// Allow creating grafana dir beforehand
|
// Allow creating grafana dir beforehand
|
||||||
Dir string
|
Dir string
|
||||||
|
|||||||
@@ -33,11 +33,11 @@ func (ds *DataSource) parseResponse(ctx context.Context, metricDataOutputs []*cl
|
|||||||
dataRes := backend.DataResponse{}
|
dataRes := backend.DataResponse{}
|
||||||
|
|
||||||
if response.HasArithmeticError {
|
if response.HasArithmeticError {
|
||||||
dataRes.Error = fmt.Errorf("ArithmeticError in query %q: %s", queryRow.RefId, response.ArithmeticErrorMessage)
|
dataRes.Error = backend.DownstreamErrorf("ArithmeticError in query %q: %s", queryRow.RefId, response.ArithmeticErrorMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
if response.HasPermissionError {
|
if response.HasPermissionError {
|
||||||
dataRes.Error = fmt.Errorf("PermissionError in query %q: %s", queryRow.RefId, response.PermissionErrorMessage)
|
dataRes.Error = backend.DownstreamErrorf("PermissionError in query %q: %s", queryRow.RefId, response.PermissionErrorMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<UPlotConfigOptions> = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
const xField = frame.fields[0];
|
const xField = frame.fields[0];
|
||||||
const xAxisHidden = xField.config.custom.axisPlacement === AxisPlacement.Hidden;
|
const xAxisHidden = xField.config.custom?.axisPlacement === AxisPlacement.Hidden;
|
||||||
|
|
||||||
builder.addAxis({
|
builder.addAxis({
|
||||||
show: !xAxisHidden,
|
show: !xAxisHidden,
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export interface PanelEditorState extends SceneObjectState {
|
|||||||
panelRef: SceneObjectRef<VizPanel>;
|
panelRef: SceneObjectRef<VizPanel>;
|
||||||
showLibraryPanelSaveModal?: boolean;
|
showLibraryPanelSaveModal?: boolean;
|
||||||
showLibraryPanelUnlinkModal?: boolean;
|
showLibraryPanelUnlinkModal?: boolean;
|
||||||
|
editPreview?: VizPanel;
|
||||||
tableView?: VizPanel;
|
tableView?: VizPanel;
|
||||||
pluginLoadErrror?: string;
|
pluginLoadErrror?: string;
|
||||||
/**
|
/**
|
||||||
@@ -150,6 +151,9 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
|
|||||||
const changedState = layoutItem.state;
|
const changedState = layoutItem.state;
|
||||||
const originalState = this._layoutItemState!;
|
const originalState = this._layoutItemState!;
|
||||||
|
|
||||||
|
this.setState({ editPreview: undefined });
|
||||||
|
this.state.optionsPane?.setState({ editPreviewRef: undefined });
|
||||||
|
|
||||||
// Temp fix for old edit mode
|
// Temp fix for old edit mode
|
||||||
if (this._layoutItem instanceof DashboardGridItem && !config.featureToggles.dashboardNewLayouts) {
|
if (this._layoutItem instanceof DashboardGridItem && !config.featureToggles.dashboardNewLayouts) {
|
||||||
this._layoutItem.handleEditChange();
|
this._layoutItem.handleEditChange();
|
||||||
@@ -256,16 +260,40 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Setup options pane
|
// Setup options pane
|
||||||
|
const optionsPane = new PanelOptionsPane({
|
||||||
|
panelRef: this.state.panelRef,
|
||||||
|
editPreviewRef: this.state.editPreview?.getRef(),
|
||||||
|
searchQuery: '',
|
||||||
|
listMode: OptionFilter.All,
|
||||||
|
isVizPickerOpen: isUnconfigured,
|
||||||
|
isNewPanel: this.state.isNewPanel,
|
||||||
|
});
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
optionsPane: new PanelOptionsPane({
|
optionsPane,
|
||||||
panelRef: this.state.panelRef,
|
|
||||||
searchQuery: '',
|
|
||||||
listMode: OptionFilter.All,
|
|
||||||
isVizPickerOpen: isUnconfigured,
|
|
||||||
isNewPanel: this.state.isNewPanel,
|
|
||||||
}),
|
|
||||||
isInitializing: false,
|
isInitializing: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this._subs.add(
|
||||||
|
optionsPane.subscribeToState((newState, oldState) => {
|
||||||
|
if (newState.isVizPickerOpen !== oldState.isVizPickerOpen) {
|
||||||
|
const panel = this.state.panelRef.resolve();
|
||||||
|
let editPreview: VizPanel | undefined;
|
||||||
|
if (newState.isVizPickerOpen) {
|
||||||
|
// we just "pick" timeseries, viz type will likely be overridden by Suggestions.
|
||||||
|
const editPreviewBuilder = PanelBuilders.timeseries()
|
||||||
|
.setTitle(panel.state.title)
|
||||||
|
.setDescription(panel.state.description);
|
||||||
|
if (panel.state.$data) {
|
||||||
|
editPreviewBuilder.setData(new DataProviderSharer({ source: panel.state.$data.getRef() }));
|
||||||
|
}
|
||||||
|
editPreview = editPreviewBuilder.build();
|
||||||
|
}
|
||||||
|
this.setState({ editPreview });
|
||||||
|
optionsPane.setState({ editPreviewRef: editPreview?.getRef() });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// plugin changed after first time initialization
|
// plugin changed after first time initialization
|
||||||
// Just update data pane
|
// Just update data pane
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export function PanelEditorRenderer({ model }: SceneComponentProps<PanelEditor>)
|
|||||||
|
|
||||||
function VizAndDataPane({ model }: SceneComponentProps<PanelEditor>) {
|
function VizAndDataPane({ model }: SceneComponentProps<PanelEditor>) {
|
||||||
const dashboard = getDashboardSceneFor(model);
|
const dashboard = getDashboardSceneFor(model);
|
||||||
const { dataPane, showLibraryPanelSaveModal, showLibraryPanelUnlinkModal, tableView } = model.useState();
|
const { dataPane, showLibraryPanelSaveModal, showLibraryPanelUnlinkModal, tableView, editPreview } = model.useState();
|
||||||
const panel = model.getPanel();
|
const panel = model.getPanel();
|
||||||
const libraryPanel = getLibraryPanelBehavior(panel);
|
const libraryPanel = getLibraryPanelBehavior(panel);
|
||||||
const { controls } = dashboard.useState();
|
const { controls } = dashboard.useState();
|
||||||
@@ -113,7 +113,7 @@ function VizAndDataPane({ model }: SceneComponentProps<PanelEditor>) {
|
|||||||
)}
|
)}
|
||||||
<div {...containerProps}>
|
<div {...containerProps}>
|
||||||
<div {...primaryProps} className={cx(primaryProps.className, isScrollingLayout && styles.fixedSizeViz)}>
|
<div {...primaryProps} className={cx(primaryProps.className, isScrollingLayout && styles.fixedSizeViz)}>
|
||||||
<VizWrapper panel={panel} tableView={tableView} />
|
<VizWrapper panel={editPreview ?? panel} tableView={tableView} />
|
||||||
</div>
|
</div>
|
||||||
{showLibraryPanelSaveModal && libraryPanel && (
|
{showLibraryPanelSaveModal && libraryPanel && (
|
||||||
<SaveLibraryVizPanelModal
|
<SaveLibraryVizPanelModal
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ describe('PanelOptionsPane', () => {
|
|||||||
|
|
||||||
expect(panel.state.pluginId).toBe('timeseries');
|
expect(panel.state.pluginId).toBe('timeseries');
|
||||||
|
|
||||||
optionsPane.onChangePanelPlugin({ pluginId: 'table' });
|
optionsPane.onChangePanel({ pluginId: 'table' });
|
||||||
|
|
||||||
expect(optionsPane['_cachedPluginOptions']['timeseries']?.options).toBe(panel.state.options);
|
expect(optionsPane['_cachedPluginOptions']['timeseries']?.options).toBe(panel.state.options);
|
||||||
expect(optionsPane['_cachedPluginOptions']['timeseries']?.fieldConfig).toBe(panel.state.fieldConfig);
|
expect(optionsPane['_cachedPluginOptions']['timeseries']?.fieldConfig).toBe(panel.state.fieldConfig);
|
||||||
@@ -52,7 +52,7 @@ describe('PanelOptionsPane', () => {
|
|||||||
panel.setState({ $data: undefined });
|
panel.setState({ $data: undefined });
|
||||||
panel.activate();
|
panel.activate();
|
||||||
|
|
||||||
optionsPane.onChangePanelPlugin({
|
optionsPane.onChangePanel({
|
||||||
pluginId: 'table',
|
pluginId: 'table',
|
||||||
options: { showHeader: false },
|
options: { showHeader: false },
|
||||||
fieldConfig: {
|
fieldConfig: {
|
||||||
@@ -114,7 +114,7 @@ describe('PanelOptionsPane', () => {
|
|||||||
expect(panel.state.fieldConfig.overrides[1].properties).toHaveLength(1);
|
expect(panel.state.fieldConfig.overrides[1].properties).toHaveLength(1);
|
||||||
expect(panel.state.fieldConfig.defaults.custom).toHaveProperty('axisBorderShow');
|
expect(panel.state.fieldConfig.defaults.custom).toHaveProperty('axisBorderShow');
|
||||||
|
|
||||||
optionsPane.onChangePanelPlugin({ pluginId: 'table' });
|
optionsPane.onChangePanel({ pluginId: 'table' });
|
||||||
|
|
||||||
expect(mockFn).toHaveBeenCalled();
|
expect(mockFn).toHaveBeenCalled();
|
||||||
expect(mockFn.mock.calls[0][2].defaults.color?.mode).toBe('palette-classic');
|
expect(mockFn.mock.calls[0][2].defaults.color?.mode).toBe('palette-classic');
|
||||||
@@ -146,8 +146,8 @@ describe('PanelOptionsPane', () => {
|
|||||||
const mockOnFieldConfigChange = jest.fn();
|
const mockOnFieldConfigChange = jest.fn();
|
||||||
panel.onFieldConfigChange = mockOnFieldConfigChange;
|
panel.onFieldConfigChange = mockOnFieldConfigChange;
|
||||||
|
|
||||||
// Call onChangePanelPlugin with fieldConfig that has overrides
|
// Call onChangePanel with fieldConfig that has overrides
|
||||||
optionsPane.onChangePanelPlugin({
|
optionsPane.onChangePanel({
|
||||||
pluginId: 'table',
|
pluginId: 'table',
|
||||||
fieldConfig: {
|
fieldConfig: {
|
||||||
defaults: { unit: 'percent' },
|
defaults: { unit: 'percent' },
|
||||||
@@ -178,7 +178,7 @@ describe('PanelOptionsPane', () => {
|
|||||||
panel.onFieldConfigChange = mockOnFieldConfigChange;
|
panel.onFieldConfigChange = mockOnFieldConfigChange;
|
||||||
|
|
||||||
// Call without fieldConfig
|
// Call without fieldConfig
|
||||||
optionsPane.onChangePanelPlugin({
|
optionsPane.onChangePanel({
|
||||||
pluginId: 'table',
|
pluginId: 'table',
|
||||||
options: { showHeader: false },
|
options: { showHeader: false },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export interface PanelOptionsPaneState extends SceneObjectState {
|
|||||||
panelRef: SceneObjectRef<VizPanel>;
|
panelRef: SceneObjectRef<VizPanel>;
|
||||||
isNewPanel?: boolean;
|
isNewPanel?: boolean;
|
||||||
hasPickedViz?: boolean;
|
hasPickedViz?: boolean;
|
||||||
|
editPreviewRef?: SceneObjectRef<VizPanel>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PluginOptionsCache {
|
interface PluginOptionsCache {
|
||||||
@@ -63,8 +64,7 @@ export class PanelOptionsPane extends SceneObjectBase<PanelOptionsPaneState> {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
onChangePanelPlugin = (options: VizTypeChangeDetails) => {
|
onChangePanel = (options: VizTypeChangeDetails, panel = this.state.panelRef.resolve()) => {
|
||||||
const panel = this.state.panelRef.resolve();
|
|
||||||
const { options: prevOptions, fieldConfig: prevFieldConfig, pluginId: prevPluginId } = panel.state;
|
const { options: prevOptions, fieldConfig: prevFieldConfig, pluginId: prevPluginId } = panel.state;
|
||||||
const pluginId = options.pluginId;
|
const pluginId = options.pluginId;
|
||||||
|
|
||||||
@@ -137,8 +137,10 @@ export class PanelOptionsPane extends SceneObjectBase<PanelOptionsPaneState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PanelOptionsPaneComponent({ model }: SceneComponentProps<PanelOptionsPane>) {
|
function PanelOptionsPaneComponent({ model }: SceneComponentProps<PanelOptionsPane>) {
|
||||||
const { isVizPickerOpen, searchQuery, listMode, panelRef, isNewPanel, hasPickedViz } = model.useState();
|
const { isVizPickerOpen, searchQuery, listMode, panelRef, isNewPanel, hasPickedViz, editPreviewRef } =
|
||||||
|
model.useState();
|
||||||
const panel = panelRef.resolve();
|
const panel = panelRef.resolve();
|
||||||
|
const editPreview = editPreviewRef?.resolve() ?? panel; // if something goes wrong, at least update the panel.
|
||||||
const { pluginId } = panel.useState();
|
const { pluginId } = panel.useState();
|
||||||
const { data } = sceneGraph.getData(panel).useState();
|
const { data } = sceneGraph.getData(panel).useState();
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
@@ -229,7 +231,8 @@ function PanelOptionsPaneComponent({ model }: SceneComponentProps<PanelOptionsPa
|
|||||||
{isVizPickerOpen && (
|
{isVizPickerOpen && (
|
||||||
<PanelVizTypePicker
|
<PanelVizTypePicker
|
||||||
panel={panel}
|
panel={panel}
|
||||||
onChange={model.onChangePanelPlugin}
|
editPreview={editPreview}
|
||||||
|
onChange={model.onChangePanel}
|
||||||
onClose={model.onToggleVizPicker}
|
onClose={model.onToggleVizPicker}
|
||||||
data={data}
|
data={data}
|
||||||
showBackButton={config.featureToggles.newVizSuggestions ? hasPickedViz || !isNewPanel : true}
|
showBackButton={config.featureToggles.newVizSuggestions ? hasPickedViz || !isNewPanel : true}
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ export interface Props {
|
|||||||
data?: PanelData;
|
data?: PanelData;
|
||||||
showBackButton?: boolean;
|
showBackButton?: boolean;
|
||||||
panel: VizPanel;
|
panel: VizPanel;
|
||||||
onChange: (options: VizTypeChangeDetails) => void;
|
editPreview: VizPanel;
|
||||||
|
onChange: (options: VizTypeChangeDetails, panel?: VizPanel) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +42,7 @@ const getTabs = (): Array<{ label: string; value: VisualizationSelectPaneTab }>
|
|||||||
: [allVisualizationsTab, suggestionsTab];
|
: [allVisualizationsTab, suggestionsTab];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PanelVizTypePicker({ panel, data, onChange, onClose, showBackButton }: Props) {
|
export function PanelVizTypePicker({ panel, editPreview, data, onChange, onClose, showBackButton }: Props) {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const panelModel = useMemo(() => new PanelModelCompatibilityWrapper(panel), [panel]);
|
const panelModel = useMemo(() => new PanelModelCompatibilityWrapper(panel), [panel]);
|
||||||
const filterId = useId();
|
const filterId = useId();
|
||||||
@@ -97,49 +98,55 @@ export function PanelVizTypePicker({ panel, data, onChange, onClose, showBackBut
|
|||||||
</TabsBar>
|
</TabsBar>
|
||||||
<ScrollContainer>
|
<ScrollContainer>
|
||||||
<TabContent className={styles.tabContent}>
|
<TabContent className={styles.tabContent}>
|
||||||
{listMode === VisualizationSelectPaneTab.Suggestions && (
|
<Stack gap={1} direction="column">
|
||||||
<VisualizationSuggestions onChange={onChange} panel={panelModel} data={data} />
|
<Field
|
||||||
)}
|
tabIndex={0}
|
||||||
{listMode === VisualizationSelectPaneTab.Visualizations && (
|
className={styles.searchField}
|
||||||
<Stack gap={1} direction="column">
|
noMargin
|
||||||
<Field
|
htmlFor={filterId}
|
||||||
tabIndex={0}
|
aria-label={t('dashboard-scene.panel-viz-type-picker.placeholder-search-for', 'Search for...')}
|
||||||
className={styles.searchField}
|
>
|
||||||
noMargin
|
<Stack direction="row" gap={1}>
|
||||||
htmlFor={filterId}
|
{showBackButton && (
|
||||||
aria-label={t('dashboard-scene.panel-viz-type-picker.placeholder-search-for', 'Search for...')}
|
<Button
|
||||||
>
|
aria-label={t('dashboard-scene.panel-viz-type-picker.title-close', 'Close')}
|
||||||
<Stack direction="row" gap={1}>
|
fill="text"
|
||||||
{showBackButton && (
|
variant="secondary"
|
||||||
<Button
|
icon="arrow-left"
|
||||||
aria-label={t('dashboard-scene.panel-viz-type-picker.title-close', 'Close')}
|
data-testid={selectors.components.PanelEditor.toggleVizPicker}
|
||||||
fill="text"
|
onClick={onClose}
|
||||||
variant="secondary"
|
>
|
||||||
icon="arrow-left"
|
<Trans i18nKey="dashboard-scene.panel-viz-type-picker.button.close">Back</Trans>
|
||||||
data-testid={selectors.components.PanelEditor.toggleVizPicker}
|
</Button>
|
||||||
onClick={onClose}
|
)}
|
||||||
>
|
<FilterInput
|
||||||
<Trans i18nKey="dashboard-scene.panel-viz-type-picker.button.close">Back</Trans>
|
id={filterId}
|
||||||
</Button>
|
className={styles.filter}
|
||||||
)}
|
value={searchQuery}
|
||||||
<FilterInput
|
onChange={setSearchQuery}
|
||||||
id={filterId}
|
placeholder={t('dashboard-scene.panel-viz-type-picker.placeholder-search-for', 'Search for...')}
|
||||||
className={styles.filter}
|
/>
|
||||||
value={searchQuery}
|
</Stack>
|
||||||
onChange={setSearchQuery}
|
</Field>
|
||||||
placeholder={t('dashboard-scene.panel-viz-type-picker.placeholder-search-for', 'Search for...')}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
|
{listMode === VisualizationSelectPaneTab.Suggestions && (
|
||||||
|
<VisualizationSuggestions
|
||||||
|
onChange={onChange}
|
||||||
|
panel={panelModel}
|
||||||
|
editPreview={editPreview}
|
||||||
|
data={data}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{listMode === VisualizationSelectPaneTab.Visualizations && (
|
||||||
<VizTypePicker
|
<VizTypePicker
|
||||||
pluginId={panel.state.pluginId}
|
pluginId={panel.state.pluginId}
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
trackSearch={trackSearch}
|
trackSearch={trackSearch}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
)}
|
||||||
)}
|
</Stack>
|
||||||
</TabContent>
|
</TabContent>
|
||||||
</ScrollContainer>
|
</ScrollContainer>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,7 +162,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
|||||||
gap: theme.spacing(2),
|
gap: theme.spacing(2),
|
||||||
}),
|
}),
|
||||||
searchField: css({
|
searchField: css({
|
||||||
marginTop: theme.spacing(0.5), // input glow with the boundary without this
|
margin: theme.spacing(0.5, 0, 1, 0), // input glow with the boundary without this
|
||||||
}),
|
}),
|
||||||
tabs: css({
|
tabs: css({
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ import { DashboardGridItem } from './layout-default/DashboardGridItem';
|
|||||||
import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager';
|
import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager';
|
||||||
import { addNewRowTo } from './layouts-shared/addNew';
|
import { addNewRowTo } from './layouts-shared/addNew';
|
||||||
import { clearClipboard } from './layouts-shared/paste';
|
import { clearClipboard } from './layouts-shared/paste';
|
||||||
import { getIsLazy } from './layouts-shared/utils';
|
|
||||||
import { DashboardLayoutManager } from './types/DashboardLayoutManager';
|
import { DashboardLayoutManager } from './types/DashboardLayoutManager';
|
||||||
import { LayoutParent } from './types/LayoutParent';
|
import { LayoutParent } from './types/LayoutParent';
|
||||||
|
|
||||||
@@ -199,7 +198,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
|
|||||||
meta: {},
|
meta: {},
|
||||||
editable: true,
|
editable: true,
|
||||||
$timeRange: state.$timeRange ?? new SceneTimeRange({}),
|
$timeRange: state.$timeRange ?? new SceneTimeRange({}),
|
||||||
body: state.body ?? DefaultGridLayoutManager.fromVizPanels([], getIsLazy(state.preload)),
|
body: state.body ?? DefaultGridLayoutManager.fromVizPanels([]),
|
||||||
links: state.links ?? [],
|
links: state.links ?? [],
|
||||||
...state,
|
...state,
|
||||||
editPane: new DashboardEditPane(),
|
editPane: new DashboardEditPane(),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useContext, useEffect, useState } from 'react';
|
import React, { useContext, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { Trans } from '@grafana/i18n';
|
import { Trans } from '@grafana/i18n';
|
||||||
import { VizPanel } from '@grafana/scenes';
|
import { LazyLoader, VizPanel } from '@grafana/scenes';
|
||||||
import { Box, Spinner } from '@grafana/ui';
|
import { Box, Spinner } from '@grafana/ui';
|
||||||
|
|
||||||
import { DashboardScene } from './DashboardScene';
|
import { DashboardScene } from './DashboardScene';
|
||||||
@@ -51,11 +51,23 @@ export function useSoloPanelContext() {
|
|||||||
return useContext(SoloPanelContext);
|
return useContext(SoloPanelContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderMatchingSoloPanels(soloPanelContext: SoloPanelContextValue, panels: VizPanel[]) {
|
export function renderMatchingSoloPanels(
|
||||||
|
soloPanelContext: SoloPanelContextValue,
|
||||||
|
panels: VizPanel[],
|
||||||
|
isLazy?: boolean
|
||||||
|
) {
|
||||||
const matches: React.ReactNode[] = [];
|
const matches: React.ReactNode[] = [];
|
||||||
for (const panel of panels) {
|
for (const panel of panels) {
|
||||||
if (soloPanelContext.matches(panel)) {
|
if (soloPanelContext.matches(panel)) {
|
||||||
matches.push(<panel.Component model={panel} key={panel.state.key} />);
|
if (isLazy) {
|
||||||
|
matches.push(
|
||||||
|
<LazyLoader key={panel.state.key!}>
|
||||||
|
<panel.Component model={panel} />
|
||||||
|
</LazyLoader>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
matches.push(<panel.Component model={panel} key={panel.state.key} />);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useStyles2 } from '@grafana/ui';
|
|||||||
import { ConditionalRenderingGroup } from '../../conditional-rendering/group/ConditionalRenderingGroup';
|
import { ConditionalRenderingGroup } from '../../conditional-rendering/group/ConditionalRenderingGroup';
|
||||||
import { useIsConditionallyHidden } from '../../conditional-rendering/hooks/useIsConditionallyHidden';
|
import { useIsConditionallyHidden } from '../../conditional-rendering/hooks/useIsConditionallyHidden';
|
||||||
import { useDashboardState } from '../../utils/utils';
|
import { useDashboardState } from '../../utils/utils';
|
||||||
|
import { SoloPanelContextValueWithSearchStringFilter } from '../PanelSearchLayout';
|
||||||
import { renderMatchingSoloPanels, useSoloPanelContext } from '../SoloPanelContext';
|
import { renderMatchingSoloPanels, useSoloPanelContext } from '../SoloPanelContext';
|
||||||
import { getIsLazy } from '../layouts-shared/utils';
|
import { getIsLazy } from '../layouts-shared/utils';
|
||||||
|
|
||||||
@@ -89,7 +90,11 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps<AutoGridItem
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (soloPanelContext) {
|
if (soloPanelContext) {
|
||||||
return renderMatchingSoloPanels(soloPanelContext, [body, ...repeatedPanels]);
|
// Use lazy loading only for panel search layout (SoloPanelContextValueWithSearchStringFilter)
|
||||||
|
// as it renders multiple panels in a grid. Skip lazy loading for viewPanel URL param
|
||||||
|
// (SoloPanelContextWithPathIdFilter) since single panels should render immediately.
|
||||||
|
const useLazyForSoloPanel = isLazy && soloPanelContext instanceof SoloPanelContextValueWithSearchStringFilter;
|
||||||
|
return renderMatchingSoloPanels(soloPanelContext, [body, ...repeatedPanels], useLazyForSoloPanel);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDragging = !!draggingKey;
|
const isDragging = !!draggingKey;
|
||||||
|
|||||||
+36
-14
@@ -1,17 +1,43 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { useMemo } from 'react';
|
import { RefObject, useMemo } from 'react';
|
||||||
|
|
||||||
import { config } from '@grafana/runtime';
|
import { config } from '@grafana/runtime';
|
||||||
import { SceneComponentProps } from '@grafana/scenes';
|
import { LazyLoader, SceneComponentProps, VizPanel } from '@grafana/scenes';
|
||||||
import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN } from 'app/core/constants';
|
import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN } from 'app/core/constants';
|
||||||
|
|
||||||
|
import { useDashboardState } from '../../utils/utils';
|
||||||
|
import { SoloPanelContextValueWithSearchStringFilter } from '../PanelSearchLayout';
|
||||||
import { renderMatchingSoloPanels, useSoloPanelContext } from '../SoloPanelContext';
|
import { renderMatchingSoloPanels, useSoloPanelContext } from '../SoloPanelContext';
|
||||||
|
import { getIsLazy } from '../layouts-shared/utils';
|
||||||
|
|
||||||
import { DashboardGridItem, RepeatDirection } from './DashboardGridItem';
|
import { DashboardGridItem, RepeatDirection } from './DashboardGridItem';
|
||||||
|
|
||||||
|
interface PanelWrapperProps {
|
||||||
|
panel: VizPanel;
|
||||||
|
isLazy: boolean;
|
||||||
|
containerRef?: RefObject<HTMLDivElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PanelWrapper({ panel, isLazy, containerRef }: PanelWrapperProps) {
|
||||||
|
if (isLazy) {
|
||||||
|
return (
|
||||||
|
<LazyLoader key={panel.state.key!} ref={containerRef} className={panelWrapper}>
|
||||||
|
<panel.Component model={panel} />
|
||||||
|
</LazyLoader>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={panelWrapper} ref={containerRef}>
|
||||||
|
<panel.Component model={panel} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function DashboardGridItemRenderer({ model }: SceneComponentProps<DashboardGridItem>) {
|
export function DashboardGridItemRenderer({ model }: SceneComponentProps<DashboardGridItem>) {
|
||||||
const { repeatedPanels = [], itemHeight, variableName, body } = model.useState();
|
const { repeatedPanels = [], itemHeight, variableName, body } = model.useState();
|
||||||
const soloPanelContext = useSoloPanelContext();
|
const soloPanelContext = useSoloPanelContext();
|
||||||
|
const { preload } = useDashboardState(model);
|
||||||
|
const isLazy = useMemo(() => getIsLazy(preload), [preload]);
|
||||||
const layoutStyle = useLayoutStyle(
|
const layoutStyle = useLayoutStyle(
|
||||||
model.getRepeatDirection(),
|
model.getRepeatDirection(),
|
||||||
model.getChildCount(),
|
model.getChildCount(),
|
||||||
@@ -20,26 +46,22 @@ export function DashboardGridItemRenderer({ model }: SceneComponentProps<Dashboa
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (soloPanelContext) {
|
if (soloPanelContext) {
|
||||||
return renderMatchingSoloPanels(soloPanelContext, [body, ...repeatedPanels]);
|
// Use lazy loading only for panel search layout (SoloPanelContextValueWithSearchStringFilter)
|
||||||
|
// as it renders multiple panels in a grid. Skip lazy loading for viewPanel URL param
|
||||||
|
// (SoloPanelContextWithPathIdFilter) since single panels should render immediately.
|
||||||
|
const useLazyForSoloPanel = isLazy && soloPanelContext instanceof SoloPanelContextValueWithSearchStringFilter;
|
||||||
|
return renderMatchingSoloPanels(soloPanelContext, [body, ...repeatedPanels], useLazyForSoloPanel);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!variableName) {
|
if (!variableName) {
|
||||||
return (
|
return <PanelWrapper panel={body} isLazy={isLazy} containerRef={model.containerRef} />;
|
||||||
<div className={panelWrapper} ref={model.containerRef}>
|
|
||||||
<body.Component model={body} key={body.state.key} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={layoutStyle} ref={model.containerRef}>
|
<div className={layoutStyle} ref={model.containerRef}>
|
||||||
<div className={panelWrapper} key={body.state.key}>
|
<PanelWrapper panel={body} isLazy={isLazy} />
|
||||||
<body.Component model={body} key={body.state.key} />
|
|
||||||
</div>
|
|
||||||
{repeatedPanels.map((panel) => (
|
{repeatedPanels.map((panel) => (
|
||||||
<div className={panelWrapper} key={panel.state.key}>
|
<PanelWrapper key={panel.state.key!} panel={panel} isLazy={isLazy} />
|
||||||
<panel.Component model={panel} key={panel.state.key} />
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+3
-8
@@ -47,7 +47,6 @@ import { AutoGridItem } from '../layout-auto-grid/AutoGridItem';
|
|||||||
import { CanvasGridAddActions } from '../layouts-shared/CanvasGridAddActions';
|
import { CanvasGridAddActions } from '../layouts-shared/CanvasGridAddActions';
|
||||||
import { clearClipboard, getDashboardGridItemFromClipboard } from '../layouts-shared/paste';
|
import { clearClipboard, getDashboardGridItemFromClipboard } from '../layouts-shared/paste';
|
||||||
import { dashboardCanvasAddButtonHoverStyles } from '../layouts-shared/styles';
|
import { dashboardCanvasAddButtonHoverStyles } from '../layouts-shared/styles';
|
||||||
import { getIsLazy } from '../layouts-shared/utils';
|
|
||||||
import { DashboardLayoutGrid } from '../types/DashboardLayoutGrid';
|
import { DashboardLayoutGrid } from '../types/DashboardLayoutGrid';
|
||||||
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
|
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
|
||||||
import { LayoutRegistryItem } from '../types/LayoutRegistryItem';
|
import { LayoutRegistryItem } from '../types/LayoutRegistryItem';
|
||||||
@@ -565,11 +564,10 @@ export class DefaultGridLayoutManager
|
|||||||
|
|
||||||
public static createFromLayout(currentLayout: DashboardLayoutManager): DefaultGridLayoutManager {
|
public static createFromLayout(currentLayout: DashboardLayoutManager): DefaultGridLayoutManager {
|
||||||
const panels = currentLayout.getVizPanels();
|
const panels = currentLayout.getVizPanels();
|
||||||
const isLazy = getIsLazy(getDashboardSceneFor(currentLayout).state.preload)!;
|
return DefaultGridLayoutManager.fromVizPanels(panels);
|
||||||
return DefaultGridLayoutManager.fromVizPanels(panels, isLazy);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static fromVizPanels(panels: VizPanel[] = [], isLazy?: boolean | undefined): DefaultGridLayoutManager {
|
public static fromVizPanels(panels: VizPanel[] = []): DefaultGridLayoutManager {
|
||||||
const children: DashboardGridItem[] = [];
|
const children: DashboardGridItem[] = [];
|
||||||
const panelHeight = 10;
|
const panelHeight = 10;
|
||||||
const panelWidth = GRID_COLUMN_COUNT / 3;
|
const panelWidth = GRID_COLUMN_COUNT / 3;
|
||||||
@@ -607,7 +605,6 @@ export class DefaultGridLayoutManager
|
|||||||
children: children,
|
children: children,
|
||||||
isDraggable: true,
|
isDraggable: true,
|
||||||
isResizable: true,
|
isResizable: true,
|
||||||
isLazy,
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -615,8 +612,7 @@ export class DefaultGridLayoutManager
|
|||||||
public static fromGridItems(
|
public static fromGridItems(
|
||||||
gridItems: SceneGridItemLike[],
|
gridItems: SceneGridItemLike[],
|
||||||
isDraggable?: boolean,
|
isDraggable?: boolean,
|
||||||
isResizable?: boolean,
|
isResizable?: boolean
|
||||||
isLazy?: boolean | undefined
|
|
||||||
): DefaultGridLayoutManager {
|
): DefaultGridLayoutManager {
|
||||||
const children = gridItems.reduce<SceneGridItemLike[]>((acc, gridItem) => {
|
const children = gridItems.reduce<SceneGridItemLike[]>((acc, gridItem) => {
|
||||||
gridItem.clearParent();
|
gridItem.clearParent();
|
||||||
@@ -630,7 +626,6 @@ export class DefaultGridLayoutManager
|
|||||||
children,
|
children,
|
||||||
isDraggable,
|
isDraggable,
|
||||||
isResizable,
|
isResizable,
|
||||||
isLazy,
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -358,8 +358,7 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
|
|||||||
layout: DefaultGridLayoutManager.fromGridItems(
|
layout: DefaultGridLayoutManager.fromGridItems(
|
||||||
rowConfig.children,
|
rowConfig.children,
|
||||||
rowConfig.isDraggable ?? layout.state.grid.state.isDraggable,
|
rowConfig.isDraggable ?? layout.state.grid.state.isDraggable,
|
||||||
rowConfig.isResizable ?? layout.state.grid.state.isResizable,
|
rowConfig.isResizable ?? layout.state.grid.state.isResizable
|
||||||
layout.state.grid.state.isLazy
|
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import {
|
|||||||
PanelPluginMeta,
|
PanelPluginMeta,
|
||||||
PanelPluginVisualizationSuggestion,
|
PanelPluginVisualizationSuggestion,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
import { Trans, t } from '@grafana/i18n';
|
import { Trans, t } from '@grafana/i18n';
|
||||||
import { config } from '@grafana/runtime';
|
import { config } from '@grafana/runtime';
|
||||||
|
import { VizPanel } from '@grafana/scenes';
|
||||||
import { Alert, Button, Icon, Spinner, Text, useStyles2 } from '@grafana/ui';
|
import { Alert, Button, Icon, Spinner, Text, useStyles2 } from '@grafana/ui';
|
||||||
import { UNCONFIGURED_PANEL_PLUGIN_ID } from 'app/features/dashboard-scene/scene/UnconfiguredPanel';
|
import { UNCONFIGURED_PANEL_PLUGIN_ID } from 'app/features/dashboard-scene/scene/UnconfiguredPanel';
|
||||||
|
|
||||||
@@ -23,25 +25,47 @@ import { VisualizationSuggestionCard } from './VisualizationSuggestionCard';
|
|||||||
import { VizTypeChangeDetails } from './types';
|
import { VizTypeChangeDetails } from './types';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
onChange: (options: VizTypeChangeDetails) => void;
|
onChange: (options: VizTypeChangeDetails, panel?: VizPanel) => void;
|
||||||
|
editPreview?: VizPanel;
|
||||||
data?: PanelData;
|
data?: PanelData;
|
||||||
panel?: PanelModel;
|
panel?: PanelModel;
|
||||||
|
searchQuery?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const useSuggestions = (data: PanelData | undefined) => {
|
const useSuggestions = (data: PanelData | undefined, searchQuery: string | undefined) => {
|
||||||
const [hasFetched, setHasFetched] = useState(false);
|
const [hasFetched, setHasFetched] = useState(false);
|
||||||
const { value, loading, error, retry } = useAsyncRetry(async () => {
|
const { value, loading, error, retry } = useAsyncRetry(async () => {
|
||||||
await new Promise((resolve) => setTimeout(resolve, hasFetched ? 75 : 0));
|
await new Promise((resolve) => setTimeout(resolve, hasFetched ? 75 : 0));
|
||||||
setHasFetched(true);
|
setHasFetched(true);
|
||||||
return await getAllSuggestions(data);
|
return await getAllSuggestions(data);
|
||||||
}, [hasFetched, data]);
|
}, [hasFetched, data]);
|
||||||
return { value, loading, error, retry };
|
|
||||||
|
const filteredValue = useMemo(() => {
|
||||||
|
if (!value || !searchQuery) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowerCaseQuery = searchQuery.toLowerCase();
|
||||||
|
const filteredSuggestions = value.suggestions.filter(
|
||||||
|
(suggestion) =>
|
||||||
|
suggestion.name.toLowerCase().includes(lowerCaseQuery) ||
|
||||||
|
suggestion.pluginId.toLowerCase().includes(lowerCaseQuery) ||
|
||||||
|
suggestion.description?.toLowerCase().includes(lowerCaseQuery)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...value,
|
||||||
|
suggestions: filteredSuggestions,
|
||||||
|
};
|
||||||
|
}, [value, searchQuery]);
|
||||||
|
|
||||||
|
return { value: filteredValue, loading, error, retry };
|
||||||
};
|
};
|
||||||
|
|
||||||
export function VisualizationSuggestions({ onChange, data, panel }: Props) {
|
export function VisualizationSuggestions({ onChange, editPreview, data, panel, searchQuery }: Props) {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
const { value: result, loading, error, retry } = useSuggestions(data);
|
const { value: result, loading, error, retry } = useSuggestions(data, searchQuery);
|
||||||
|
|
||||||
const suggestions = result?.suggestions;
|
const suggestions = result?.suggestions;
|
||||||
const hasLoadingErrors = result?.hasErrors ?? false;
|
const hasLoadingErrors = result?.hasErrors ?? false;
|
||||||
@@ -73,18 +97,21 @@ export function VisualizationSuggestions({ onChange, data, panel }: Props) {
|
|||||||
|
|
||||||
const applySuggestion = useCallback(
|
const applySuggestion = useCallback(
|
||||||
(suggestion: PanelPluginVisualizationSuggestion, isPreview?: boolean) => {
|
(suggestion: PanelPluginVisualizationSuggestion, isPreview?: boolean) => {
|
||||||
onChange({
|
onChange(
|
||||||
pluginId: suggestion.pluginId,
|
{
|
||||||
options: suggestion.options,
|
pluginId: suggestion.pluginId,
|
||||||
fieldConfig: suggestion.fieldConfig,
|
options: suggestion.options,
|
||||||
withModKey: isPreview,
|
fieldConfig: suggestion.fieldConfig,
|
||||||
});
|
withModKey: isPreview,
|
||||||
|
},
|
||||||
|
isPreview ? editPreview : undefined
|
||||||
|
);
|
||||||
|
|
||||||
if (isPreview) {
|
if (isPreview) {
|
||||||
setSuggestionHash(suggestion.hash);
|
setSuggestionHash(suggestion.hash);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onChange]
|
[onChange, editPreview]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -185,17 +212,13 @@ export function VisualizationSuggestions({ onChange, data, panel }: Props) {
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
size={'md'}
|
size={'md'}
|
||||||
className={styles.applySuggestionButton}
|
className={styles.applySuggestionButton}
|
||||||
|
data-testid={selectors.components.VisualizationPreview.confirm(suggestion.name)}
|
||||||
aria-label={t(
|
aria-label={t(
|
||||||
'panel.visualization-suggestions.apply-suggestion-aria-label',
|
'panel.visualization-suggestions.apply-suggestion-aria-label',
|
||||||
'Apply {{suggestionName}} visualization',
|
'Apply {{suggestionName}} visualization',
|
||||||
{ suggestionName: suggestion.name }
|
{ suggestionName: suggestion.name }
|
||||||
)}
|
)}
|
||||||
onClick={() =>
|
onClick={() => applySuggestion(suggestion, false)}
|
||||||
onChange({
|
|
||||||
pluginId: suggestion.pluginId,
|
|
||||||
withModKey: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{t('panel.visualization-suggestions.use-this-suggestion', 'Use this suggestion')}
|
{t('panel.visualization-suggestions.use-this-suggestion', 'Use this suggestion')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
Reference in New Issue
Block a user