Compare commits
20 Commits
check-var-
...
njvrzm/err
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79a61a2b63 | ||
|
|
dc4c106e91 | ||
|
|
33a1c60433 | ||
|
|
521670981a | ||
|
|
79ca4e5aec | ||
|
|
e3bc61e7d2 | ||
|
|
cc6a75d021 | ||
|
|
6d0f7f3567 | ||
|
|
913c0ba3c5 | ||
|
|
552b6aa717 | ||
|
|
2ddb4049c6 | ||
|
|
318a0ebb36 | ||
|
|
bba5c44dc4 | ||
|
|
44e6ea3d8b | ||
|
|
014d4758c6 | ||
|
|
82b4ce0ece | ||
|
|
52698cf0da | ||
|
|
d291dfb35b | ||
|
|
9c6feb8de5 | ||
|
|
e7625186af |
3
.github/CODEOWNERS
vendored
3
.github/CODEOWNERS
vendored
@@ -501,7 +501,6 @@ i18next.config.ts @grafana/grafana-frontend-platform
|
||||
/e2e-playwright/various-suite/filter-annotations.spec.ts @grafana/dashboards-squad
|
||||
/e2e-playwright/various-suite/frontend-sandbox-app.spec.ts @grafana/plugins-platform-frontend
|
||||
/e2e-playwright/various-suite/frontend-sandbox-datasource.spec.ts @grafana/plugins-platform-frontend
|
||||
/e2e-playwright/various-suite/gauge.spec.ts @grafana/dataviz-squad
|
||||
/e2e-playwright/various-suite/grafana-datasource-random-walk.spec.ts @grafana/grafana-frontend-platform
|
||||
/e2e-playwright/various-suite/graph-auto-migrate.spec.ts @grafana/dataviz-squad
|
||||
/e2e-playwright/various-suite/inspect-drawer.spec.ts @grafana/dashboards-squad
|
||||
@@ -520,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/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/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
|
||||
|
||||
# Packages
|
||||
|
||||
@@ -157,7 +157,7 @@ require (
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/google/uuid v1.6.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/dataplane/sdata v0.0.9 // indirect
|
||||
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 // indirect
|
||||
|
||||
@@ -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/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/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ=
|
||||
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
|
||||
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts=
|
||||
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/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg=
|
||||
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY=
|
||||
|
||||
@@ -4,7 +4,7 @@ go 1.25.5
|
||||
|
||||
require (
|
||||
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/grafana-app-sdk 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/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/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ=
|
||||
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
|
||||
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts=
|
||||
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/go.mod h1:VahT+GtfQIM+o8ht2StR6J9g+Ef+C2Vokh5uuSmOD/4=
|
||||
github.com/grafana/grafana-app-sdk v0.48.7 h1:9mF7nqkqP0QUYYDlznoOt+GIyjzj45wGfUHB32u2ZMo=
|
||||
|
||||
@@ -180,12 +180,15 @@ func countAnnotationsV0V1(spec map[string]interface{}) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
annotationList, ok := annotations["list"].([]interface{})
|
||||
if !ok {
|
||||
return 0
|
||||
// Handle both []interface{} (from JSON unmarshaling) and []map[string]interface{} (from programmatic creation)
|
||||
if annotationList, ok := annotations["list"].([]interface{}); ok {
|
||||
return len(annotationList)
|
||||
}
|
||||
if annotationList, ok := annotations["list"].([]map[string]interface{}); ok {
|
||||
return len(annotationList)
|
||||
}
|
||||
|
||||
return len(annotationList)
|
||||
return 0
|
||||
}
|
||||
|
||||
// countLinksV0V1 counts dashboard links in v0alpha1 or v1beta1 dashboard spec
|
||||
@@ -194,12 +197,15 @@ func countLinksV0V1(spec map[string]interface{}) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
links, ok := spec["links"].([]interface{})
|
||||
if !ok {
|
||||
return 0
|
||||
// Handle both []interface{} (from JSON unmarshaling) and []map[string]interface{} (from programmatic creation)
|
||||
if links, ok := spec["links"].([]interface{}); ok {
|
||||
return len(links)
|
||||
}
|
||||
if links, ok := spec["links"].([]map[string]interface{}); ok {
|
||||
return len(links)
|
||||
}
|
||||
|
||||
return len(links)
|
||||
return 0
|
||||
}
|
||||
|
||||
// countVariablesV0V1 counts template variables in v0alpha1 or v1beta1 dashboard spec
|
||||
@@ -213,12 +219,15 @@ func countVariablesV0V1(spec map[string]interface{}) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
variableList, ok := templating["list"].([]interface{})
|
||||
if !ok {
|
||||
return 0
|
||||
// Handle both []interface{} (from JSON unmarshaling) and []map[string]interface{} (from programmatic creation)
|
||||
if variableList, ok := templating["list"].([]interface{}); ok {
|
||||
return len(variableList)
|
||||
}
|
||||
if variableList, ok := templating["list"].([]map[string]interface{}); ok {
|
||||
return len(variableList)
|
||||
}
|
||||
|
||||
return len(variableList)
|
||||
return 0
|
||||
}
|
||||
|
||||
// collectStatsV0V1 collects statistics from v0alpha1 or v1beta1 dashboard
|
||||
|
||||
@@ -628,6 +628,20 @@
|
||||
}
|
||||
],
|
||||
"title": "Only nulls and no user set min \u0026 max",
|
||||
"transformations": [
|
||||
{
|
||||
"id": "convertFieldType",
|
||||
"options": {
|
||||
"conversions": [
|
||||
{
|
||||
"destinationType": "number",
|
||||
"targetField": "A-series"
|
||||
}
|
||||
],
|
||||
"fields": {}
|
||||
}
|
||||
}
|
||||
],
|
||||
"type": "gauge"
|
||||
},
|
||||
{
|
||||
@@ -1179,4 +1193,4 @@
|
||||
"title": "Panel Tests - Gauge",
|
||||
"uid": "_5rDmaQiz",
|
||||
"weekStart": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1760,6 +1760,22 @@
|
||||
"startValue": 0
|
||||
}
|
||||
],
|
||||
"transformations": [
|
||||
{
|
||||
"id": "calculateField",
|
||||
"options": {
|
||||
"mode": "unary",
|
||||
"reduce": {
|
||||
"reducer": "sum"
|
||||
},
|
||||
"replaceFields": true,
|
||||
"unary": {
|
||||
"operator": "round",
|
||||
"fieldName": "A-series"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"title": "Active gateways",
|
||||
"type": "radialbar"
|
||||
},
|
||||
@@ -1843,6 +1859,22 @@
|
||||
"startValue": 0
|
||||
}
|
||||
],
|
||||
"transformations": [
|
||||
{
|
||||
"id": "calculateField",
|
||||
"options": {
|
||||
"mode": "unary",
|
||||
"reduce": {
|
||||
"reducer": "sum"
|
||||
},
|
||||
"replaceFields": true,
|
||||
"unary": {
|
||||
"operator": "round",
|
||||
"fieldName": "A-series"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"title": "Active pods",
|
||||
"type": "radialbar"
|
||||
},
|
||||
|
||||
@@ -485,6 +485,7 @@
|
||||
},
|
||||
"id": 12,
|
||||
"options": {
|
||||
"displayName": "My gauge",
|
||||
"minVizHeight": 75,
|
||||
"minVizWidth": 75,
|
||||
"orientation": "auto",
|
||||
|
||||
@@ -223,7 +223,7 @@ require (
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.15.0 // 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/types v0.0.0-20251119142549-be091cf2f4d4 // indirect
|
||||
github.com/grafana/dataplane/sdata v0.0.9 // indirect
|
||||
|
||||
@@ -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/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/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ=
|
||||
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
|
||||
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts=
|
||||
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/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg=
|
||||
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY=
|
||||
|
||||
@@ -90,7 +90,7 @@ require (
|
||||
github.com/google/gnostic-models v0.7.1 // indirect
|
||||
github.com/google/go-cmp v0.7.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/types v0.0.0-20251119142549-be091cf2f4d4 // indirect
|
||||
github.com/grafana/dataplane/sdata v0.0.9 // indirect
|
||||
|
||||
@@ -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/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/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ=
|
||||
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
|
||||
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts=
|
||||
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/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg=
|
||||
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY=
|
||||
|
||||
@@ -600,6 +600,20 @@
|
||||
"stringInput": "null,null"
|
||||
}
|
||||
],
|
||||
"transformations": [
|
||||
{
|
||||
"id": "convertFieldType",
|
||||
"options": {
|
||||
"fields": {},
|
||||
"conversions": [
|
||||
{
|
||||
"targetField": "A-series",
|
||||
"destinationType": "number"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"title": "Only nulls and no user set min & max",
|
||||
"type": "gauge"
|
||||
},
|
||||
|
||||
@@ -1718,6 +1718,22 @@
|
||||
"startValue": 0
|
||||
}
|
||||
],
|
||||
"transformations": [
|
||||
{
|
||||
"id": "calculateField",
|
||||
"options": {
|
||||
"mode": "unary",
|
||||
"reduce": {
|
||||
"reducer": "sum"
|
||||
},
|
||||
"replaceFields": true,
|
||||
"unary": {
|
||||
"operator": "round",
|
||||
"fieldName": "A-series"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"title": "Active gateways",
|
||||
"type": "radialbar"
|
||||
},
|
||||
@@ -1799,6 +1815,22 @@
|
||||
"startValue": 0
|
||||
}
|
||||
],
|
||||
"transformations": [
|
||||
{
|
||||
"id": "calculateField",
|
||||
"options": {
|
||||
"mode": "unary",
|
||||
"reduce": {
|
||||
"reducer": "sum"
|
||||
},
|
||||
"replaceFields": true,
|
||||
"unary": {
|
||||
"operator": "round",
|
||||
"fieldName": "A-series"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"title": "Active pods",
|
||||
"type": "radialbar"
|
||||
},
|
||||
|
||||
@@ -474,6 +474,7 @@
|
||||
},
|
||||
"id": 12,
|
||||
"options": {
|
||||
"displayName": "My gauge",
|
||||
"minVizHeight": 75,
|
||||
"minVizWidth": 75,
|
||||
"orientation": "auto",
|
||||
|
||||
@@ -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.
|
||||
|
||||
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**.
|
||||
|
||||
|
||||
101
e2e-playwright/panels-suite/gauge.spec.ts
Normal file
101
e2e-playwright/panels-suite/gauge.spec.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { test, expect } from '@grafana/plugin-e2e';
|
||||
|
||||
// this test requires a larger viewport so all gauge panels load properly
|
||||
test.use({
|
||||
featureToggles: { newGauge: true },
|
||||
viewport: { width: 1280, height: 3000 },
|
||||
});
|
||||
|
||||
const OLD_GAUGES_DASHBOARD_UID = '_5rDmaQiz';
|
||||
const NEW_GAUGES_DASHBOARD_UID = 'panel-tests-gauge-new';
|
||||
|
||||
test.describe(
|
||||
'Gauge Panel',
|
||||
{
|
||||
tag: ['@panels', '@gauge'],
|
||||
},
|
||||
() => {
|
||||
test('successfully migrates all gauge panels', async ({ gotoDashboardPage, selectors }) => {
|
||||
const dashboardPage = await gotoDashboardPage({ uid: OLD_GAUGES_DASHBOARD_UID });
|
||||
|
||||
// check that gauges are rendered
|
||||
const gaugeElements = dashboardPage.getByGrafanaSelector(
|
||||
selectors.components.Panels.Visualization.Gauge.Container
|
||||
);
|
||||
await expect(gaugeElements).toHaveCount(16);
|
||||
|
||||
// check that no panel errors exist
|
||||
const errorInfo = dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.headerCornerInfo('error'));
|
||||
await expect(errorInfo).toBeHidden();
|
||||
});
|
||||
|
||||
test('renders new gauge panels', async ({ gotoDashboardPage, selectors }) => {
|
||||
// open Panel Tests - Gauge
|
||||
const dashboardPage = await gotoDashboardPage({ uid: NEW_GAUGES_DASHBOARD_UID });
|
||||
|
||||
// check that gauges are rendered
|
||||
const gaugeElements = dashboardPage.getByGrafanaSelector(
|
||||
selectors.components.Panels.Visualization.Gauge.Container
|
||||
);
|
||||
await expect(gaugeElements).toHaveCount(32);
|
||||
|
||||
// check that no panel errors exist
|
||||
const errorInfo = dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.headerCornerInfo('error'));
|
||||
await expect(errorInfo).toBeHidden();
|
||||
});
|
||||
|
||||
test('renders sparklines in gauge panels', async ({ gotoDashboardPage, page }) => {
|
||||
await gotoDashboardPage({
|
||||
uid: NEW_GAUGES_DASHBOARD_UID,
|
||||
queryParams: new URLSearchParams({ editPanel: '11' }),
|
||||
});
|
||||
|
||||
await expect(page.locator('.uplot')).toHaveCount(5);
|
||||
});
|
||||
|
||||
test('"no data"', async ({ gotoDashboardPage, selectors }) => {
|
||||
const dashboardPage = await gotoDashboardPage({
|
||||
uid: NEW_GAUGES_DASHBOARD_UID,
|
||||
queryParams: new URLSearchParams({ editPanel: '36' }),
|
||||
});
|
||||
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Visualization.Gauge.Container),
|
||||
'that the gauge does not appear'
|
||||
).toBeHidden();
|
||||
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.PanelDataErrorMessage),
|
||||
'that the empty text appears'
|
||||
).toHaveText('No data');
|
||||
|
||||
// update the "No value" option and see if the panel updates
|
||||
const noValueOption = dashboardPage
|
||||
.getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.fieldLabel('Standard options No value'))
|
||||
.locator('input');
|
||||
|
||||
await noValueOption.fill('My empty value');
|
||||
await noValueOption.blur();
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Visualization.Gauge.Container),
|
||||
'that the empty text shows up in an empty gauge'
|
||||
).toHaveText('My empty value');
|
||||
|
||||
// test the "no numeric fields" message on the next panel
|
||||
const dashboardPage2 = await gotoDashboardPage({
|
||||
uid: NEW_GAUGES_DASHBOARD_UID,
|
||||
queryParams: new URLSearchParams({ editPanel: '37' }),
|
||||
});
|
||||
|
||||
await expect(
|
||||
dashboardPage2.getByGrafanaSelector(selectors.components.Panels.Visualization.Gauge.Container),
|
||||
'that the gauge does not appear'
|
||||
).toBeHidden();
|
||||
|
||||
await expect(
|
||||
dashboardPage2.getByGrafanaSelector(selectors.components.Panels.Panel.PanelDataErrorMessage),
|
||||
'that the empty text appears'
|
||||
).toHaveText('Data is missing a number field');
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BootData } from '@grafana/data';
|
||||
import { BootData, PanelPluginMeta } from '@grafana/data';
|
||||
import { test, expect } from '@grafana/plugin-e2e';
|
||||
|
||||
test.describe(
|
||||
@@ -22,7 +22,7 @@ test.describe(
|
||||
await dashboardPage.addPanel();
|
||||
|
||||
// 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
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const win = window as typeof window & { grafanaBootData: BootData };
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { test, expect } from '@grafana/plugin-e2e';
|
||||
|
||||
// this test requires a larger viewport so all gauge panels load properly
|
||||
test.use({
|
||||
viewport: { width: 1280, height: 1080 },
|
||||
});
|
||||
|
||||
test.describe(
|
||||
'Gauge Panel',
|
||||
{
|
||||
tag: ['@various'],
|
||||
},
|
||||
() => {
|
||||
test('Gauge rendering e2e tests', async ({ gotoDashboardPage, selectors, page }) => {
|
||||
// open Panel Tests - Gauge
|
||||
const dashboardPage = await gotoDashboardPage({ uid: '_5rDmaQiz' });
|
||||
|
||||
// check that gauges are rendered
|
||||
const gaugeElements = page.locator('.flot-base');
|
||||
await expect(gaugeElements).toHaveCount(16);
|
||||
|
||||
// check that no panel errors exist
|
||||
const errorInfo = dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.headerCornerInfo('error'));
|
||||
await expect(errorInfo).toBeHidden();
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -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(
|
||||
'Visualization suggestions',
|
||||
{
|
||||
tag: ['@various'],
|
||||
tag: ['@various', '@suggestions'],
|
||||
},
|
||||
() => {
|
||||
test('Should be shown and clickable', async ({ page, selectors, gotoPanelEditPage }) => {
|
||||
|
||||
4
go.mod
4
go.mod
@@ -87,7 +87,7 @@ require (
|
||||
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/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/types v0.0.0-20251119142549-be091cf2f4d4 // @grafana/identity-access-team
|
||||
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/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // @grafana/grafana-operator-experience-squad
|
||||
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/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
|
||||
@@ -603,7 +604,6 @@ require (
|
||||
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||
github.com/zclconf/go-cty v1.16.3 // 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/client/pkg/v3 v3.6.6 // indirect
|
||||
go.etcd.io/etcd/client/v3 v3.6.6 // indirect
|
||||
|
||||
4
go.sum
4
go.sum
@@ -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/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/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ=
|
||||
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
|
||||
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts=
|
||||
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/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg=
|
||||
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY=
|
||||
|
||||
@@ -535,6 +535,11 @@ export const versionedComponents = {
|
||||
'12.3.0': 'data-testid viz-tooltip-wrapper',
|
||||
},
|
||||
},
|
||||
Gauge: {
|
||||
Container: {
|
||||
'12.4.0': 'data-testid gauge container',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
VizLegend: {
|
||||
@@ -1288,6 +1293,9 @@ export const versionedComponents = {
|
||||
card: {
|
||||
[MIN_GRAFANA_VERSION]: (name: string) => `data-testid suggestion-${name}`,
|
||||
},
|
||||
confirm: {
|
||||
'12.4.0': (name: string) => `data-testid suggestion-${name} confirm button`,
|
||||
},
|
||||
},
|
||||
ColorSwatch: {
|
||||
name: {
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface Options extends common.SingleStatBaseOptions {
|
||||
showThresholdLabels: boolean;
|
||||
showThresholdMarkers: boolean;
|
||||
sparkline?: boolean;
|
||||
textMode?: ('auto' | 'value_and_name' | 'value' | 'name' | 'none');
|
||||
}
|
||||
|
||||
export const defaultOptions: Partial<Options> = {
|
||||
@@ -48,4 +49,5 @@ export const defaultOptions: Partial<Options> = {
|
||||
showThresholdLabels: false,
|
||||
showThresholdMarkers: true,
|
||||
sparkline: true,
|
||||
textMode: 'auto',
|
||||
};
|
||||
|
||||
@@ -248,15 +248,17 @@ export function PanelChrome({
|
||||
|
||||
const onContentPointerDown = React.useCallback(
|
||||
(evt: React.PointerEvent) => {
|
||||
// Ignore clicks inside buttons, links, canvas and svg elments
|
||||
// When selected, ignore clicks inside buttons, links, canvas and svg elments
|
||||
// This does prevent a clicks inside a graphs from selecting panel as there is normal div above the canvas element that intercepts the click
|
||||
if (evt.target instanceof Element && evt.target.closest('button,a,canvas,svg')) {
|
||||
if (isSelected && evt.target instanceof Element && evt.target.closest('button,a,canvas,svg')) {
|
||||
// Stop propagation otherwise row config editor will get selected
|
||||
evt.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
onSelect?.(evt);
|
||||
},
|
||||
[onSelect]
|
||||
[isSelected, onSelect]
|
||||
);
|
||||
|
||||
const headerContent = (
|
||||
|
||||
@@ -32,24 +32,6 @@ const meta: Meta<StoryProps> = {
|
||||
controls: {
|
||||
exclude: ['theme', 'values', 'vizCount'],
|
||||
},
|
||||
a11y: {
|
||||
config: {
|
||||
rules: [
|
||||
{
|
||||
id: 'scrollable-region-focusable',
|
||||
selector: 'body',
|
||||
enabled: false,
|
||||
},
|
||||
// NOTE: this is necessary due to a false positive with the filered svg glow in one of the examples.
|
||||
// The color-contrast in this component should be accessible!
|
||||
{
|
||||
id: 'color-contrast',
|
||||
selector: 'text',
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
args: {
|
||||
barWidthFactor: 0.2,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { css, cx } from '@emotion/css';
|
||||
import { useId } from 'react';
|
||||
|
||||
import { DisplayValueAlignmentFactors, FALLBACK_COLOR, FieldDisplay, GrafanaTheme2, TimeRange } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { t } from '@grafana/i18n';
|
||||
|
||||
import { useStyles2, useTheme2 } from '../../themes/ThemeContext';
|
||||
@@ -275,7 +276,11 @@ export function RadialGauge(props: RadialGaugeProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.vizWrapper} style={{ width, height }}>
|
||||
<div
|
||||
data-testid={selectors.components.Panels.Visualization.Gauge.Container}
|
||||
className={styles.vizWrapper}
|
||||
style={{ width, height }}
|
||||
>
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { colorManipulator, GrafanaTheme2 } from '@grafana/data';
|
||||
|
||||
import { RadialGaugeDimensions } from './types';
|
||||
|
||||
@@ -25,13 +25,14 @@ export function GlowGradient({ id, barWidth }: GlowGradientProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const CENTER_GLOW_OPACITY = 0.15;
|
||||
const CENTER_GLOW_OPACITY = 0.25;
|
||||
|
||||
export function CenterGlowGradient({ gaugeId, color }: { gaugeId: string; color: string }) {
|
||||
const transparentColor = colorManipulator.alpha(color, CENTER_GLOW_OPACITY);
|
||||
return (
|
||||
<radialGradient id={`circle-glow-${gaugeId}`} r="50%" fr="0%">
|
||||
<stop offset="0%" stopColor={color} stopOpacity={CENTER_GLOW_OPACITY} />
|
||||
<stop offset="90%" stopColor={color} stopOpacity={0} />
|
||||
<stop offset="0%" stopColor={transparentColor} />
|
||||
<stop offset="90%" stopColor={'#ffffff00'} />
|
||||
</radialGradient>
|
||||
);
|
||||
}
|
||||
@@ -44,13 +45,14 @@ export interface CenterGlowProps {
|
||||
|
||||
export function MiddleCircleGlow({ dimensions, gaugeId, color }: CenterGlowProps) {
|
||||
const gradientId = `circle-glow-${gaugeId}`;
|
||||
const transparentColor = color ? colorManipulator.alpha(color, CENTER_GLOW_OPACITY) : color;
|
||||
|
||||
return (
|
||||
<>
|
||||
<defs>
|
||||
<radialGradient id={gradientId} r="50%" fr="0%">
|
||||
<stop offset="0%" stopColor={color} stopOpacity={CENTER_GLOW_OPACITY} />
|
||||
<stop offset="90%" stopColor={color} stopOpacity={0} />
|
||||
<stop offset="0%" stopColor={transparentColor} />
|
||||
<stop offset="90%" stopColor="#ffffff00" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<g>
|
||||
@@ -86,9 +88,9 @@ export function SpotlightGradient({
|
||||
|
||||
return (
|
||||
<linearGradient x1={x1} y1={y1} x2={x2} y2={y2} id={id} gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stopColor={'white'} stopOpacity={0.0} />
|
||||
<stop offset="95%" stopColor={'white'} stopOpacity={0.5} />
|
||||
{roundedBars && <stop offset="100%" stopColor={'white'} stopOpacity={roundedBars ? 0.7 : 1} />}
|
||||
<stop offset="0%" stopColor="#ffffff00" />
|
||||
<stop offset="95%" stopColor="#ffffff88" />
|
||||
{roundedBars && <stop offset="100%" stopColor={roundedBars ? '#ffffffbb' : 'white'} />}
|
||||
</linearGradient>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,8 +17,9 @@ export interface SparklineProps extends Themeable2 {
|
||||
showHighlights?: boolean;
|
||||
}
|
||||
|
||||
export const SparklineFn: React.FC<SparklineProps> = memo((props) => {
|
||||
export const Sparkline: React.FC<SparklineProps> = memo((props) => {
|
||||
const { sparkline, config: fieldConfig, theme, width, height, showHighlights } = props;
|
||||
|
||||
const { frame: alignedDataFrame, warning } = prepareSeries(sparkline, theme, fieldConfig, showHighlights);
|
||||
if (warning) {
|
||||
return null;
|
||||
@@ -30,14 +31,4 @@ export const SparklineFn: React.FC<SparklineProps> = memo((props) => {
|
||||
return <UPlotChart data={data} config={configBuilder} width={width} height={height} />;
|
||||
});
|
||||
|
||||
SparklineFn.displayName = 'Sparkline';
|
||||
|
||||
// we converted to function component above, but some apps extend Sparkline, so we need
|
||||
// to keep exporting a class component until those apps are all rolled out.
|
||||
// see https://github.com/grafana/app-observability-plugin/pull/2079
|
||||
// eslint-disable-next-line react-prefer-function-component/react-prefer-function-component
|
||||
export class Sparkline extends React.PureComponent<SparklineProps> {
|
||||
render() {
|
||||
return <SparklineFn {...this.props} />;
|
||||
}
|
||||
}
|
||||
Sparkline.displayName = 'Sparkline';
|
||||
|
||||
@@ -167,7 +167,8 @@ export class VizRepeater<V, D = {}> extends PureComponent<PropsWithDefaults<V, D
|
||||
|
||||
const repeaterStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
overflow: `${minVizWidth ? 'auto' : 'hidden'} ${minVizHeight ? 'auto' : 'hidden'}`,
|
||||
overflowX: `${minVizWidth ? 'auto' : 'hidden'}`,
|
||||
overflowY: `${minVizHeight ? 'auto' : 'hidden'}`,
|
||||
};
|
||||
|
||||
let vizHeight = height;
|
||||
|
||||
4
pkg/server/wire_gen.go
generated
4
pkg/server/wire_gen.go
generated
@@ -847,7 +847,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
|
||||
if err != nil {
|
||||
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)
|
||||
appregistryService, err := appregistry.ProvideBuilderRunners(apiserverService, eventualRestConfigProvider, featureToggles, investigationsAppProvider, cfg)
|
||||
if err != nil {
|
||||
@@ -1509,7 +1509,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
|
||||
if err != nil {
|
||||
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)
|
||||
appregistryService, err := appregistry.ProvideBuilderRunners(apiserverService, eventualRestConfigProvider, featureToggles, investigationsAppProvider, cfg)
|
||||
if err != nil {
|
||||
|
||||
44
pkg/services/accesscontrol/acimpl/basic_role_db_seed.go
Normal file
44
pkg/services/accesscontrol/acimpl/basic_role_db_seed.go
Normal file
@@ -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
|
||||
}
|
||||
128
pkg/services/accesscontrol/acimpl/basic_role_db_seed_test.go
Normal file
128
pkg/services/accesscontrol/acimpl/basic_role_db_seed_test.go
Normal file
@@ -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/permreg"
|
||||
"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/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
@@ -96,6 +97,12 @@ func ProvideOSSService(
|
||||
roles: accesscontrol.BuildBasicRoleDefinitions(),
|
||||
store: store,
|
||||
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
|
||||
@@ -112,8 +119,11 @@ type Service struct {
|
||||
rolesMu sync.RWMutex
|
||||
roles map[string]*accesscontrol.RoleDTO
|
||||
store accesscontrol.Store
|
||||
seeder *seeding.Seeder
|
||||
permRegistry permreg.PermissionRegistry
|
||||
isInitialized bool
|
||||
sql db.DB
|
||||
serverLock *serverlock.ServerLockService
|
||||
}
|
||||
|
||||
func (s *Service) GetUsageStats(_ context.Context) map[string]any {
|
||||
@@ -431,17 +441,54 @@ func (s *Service) RegisterFixedRoles(ctx context.Context) error {
|
||||
defer span.End()
|
||||
|
||||
s.rolesMu.Lock()
|
||||
defer s.rolesMu.Unlock()
|
||||
|
||||
registrations := s.registrations.Slice()
|
||||
s.registrations.Range(func(registration accesscontrol.RoleRegistration) bool {
|
||||
s.registerRolesLocked(registration)
|
||||
return 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
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Must be called with s.rolesMu locked.
|
||||
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()
|
||||
|
||||
acRegs := pluginutils.ToRegistrations(ID, name, regs)
|
||||
updatedBasicRoles := false
|
||||
for _, r := range acRegs {
|
||||
if err := pluginutils.ValidatePluginRole(ID, r.Role); err != nil {
|
||||
return err
|
||||
@@ -500,11 +548,23 @@ func (s *Service) DeclarePluginRoles(ctx context.Context, ID, name string, regs
|
||||
if initialized {
|
||||
s.rolesMu.Lock()
|
||||
s.registerRolesLocked(r)
|
||||
updatedBasicRoles = true
|
||||
s.rolesMu.Unlock()
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
623
pkg/services/accesscontrol/database/seeder.go
Normal file
623
pkg/services/accesscontrol/database/seeder.go
Normal file
@@ -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"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"go.opentelemetry.io/otel"
|
||||
|
||||
claims "github.com/grafana/authlib/types"
|
||||
@@ -13,6 +15,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"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/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
@@ -33,12 +36,15 @@ type ZanzanaReconciler struct {
|
||||
store db.DB
|
||||
client zanzana.Client
|
||||
lock *serverlock.ServerLockService
|
||||
metrics struct {
|
||||
lastSuccess prometheus.Gauge
|
||||
}
|
||||
// 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.
|
||||
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{
|
||||
cfg: cfg,
|
||||
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 {
|
||||
zanzanaReconciler.reconcilers = append(zanzanaReconciler.reconcilers,
|
||||
newResourceReconciler(
|
||||
@@ -118,6 +131,9 @@ func (r *ZanzanaReconciler) Run(ctx context.Context) error {
|
||||
// Reconcile schedules as job that will run and reconcile resources between
|
||||
// legacy access control and zanzana.
|
||||
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)
|
||||
|
||||
// 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) {
|
||||
run := func(ctx context.Context, namespace string) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
if r.metrics.lastSuccess != nil {
|
||||
r.metrics.lastSuccess.SetToCurrentTime()
|
||||
}
|
||||
r.log.Debug("Finished reconciliation", "elapsed", time.Since(now))
|
||||
}
|
||||
|
||||
|
||||
67
pkg/services/accesscontrol/dualwrite/reconciler_test.go
Normal file
67
pkg/services/accesscontrol/dualwrite/reconciler_test.go
Normal file
@@ -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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -594,3 +595,18 @@ type QueryWithOrg struct {
|
||||
OrgId *int64 `json:"orgId"`
|
||||
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
|
||||
}
|
||||
|
||||
451
pkg/services/accesscontrol/seeding/seeder.go
Normal file
451
pkg/services/accesscontrol/seeding/seeder.go
Normal file
@@ -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]{
|
||||
Rest: authnlib.AccessTokenClaims{
|
||||
Namespace: "*",
|
||||
Permissions: []string{
|
||||
zanzana.TokenPermissionUpdate,
|
||||
},
|
||||
},
|
||||
}))
|
||||
return ctx, nil
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/authz/zanzana"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"golang.org/x/exp/slices"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
@@ -30,3 +32,20 @@ func authorize(ctx context.Context, namespace string, ss setting.ZanzanaServerSe
|
||||
}
|
||||
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))
|
||||
|
||||
// Get store info
|
||||
ctx := newContextWithNamespace()
|
||||
ctx := newContextWithZanzanaUpdatePermission()
|
||||
storeInf, err := srv.getStoreInfo(ctx, benchNamespace)
|
||||
require.NoError(b, err)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
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 {
|
||||
span.RecordError(err)
|
||||
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())
|
||||
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) {
|
||||
if err := authorize(ctx, req.GetNamespace(), s.cfg); err != nil {
|
||||
if err := authorizeWrite(ctx, req.GetNamespace(), s.cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ func testMutateFolders(t *testing.T, srv *Server) {
|
||||
setupMutateFolders(t, srv)
|
||||
|
||||
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",
|
||||
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) {
|
||||
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
|
||||
_, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{
|
||||
Namespace: "default",
|
||||
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) {
|
||||
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
|
||||
_, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{
|
||||
Namespace: "default",
|
||||
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) {
|
||||
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
|
||||
_, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{
|
||||
Namespace: "default",
|
||||
Operations: []*v1.MutateOperation{
|
||||
{
|
||||
|
||||
@@ -25,7 +25,7 @@ func testMutateOrgRoles(t *testing.T, srv *Server) {
|
||||
setupMutateOrgRoles(t, srv)
|
||||
|
||||
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",
|
||||
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) {
|
||||
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
|
||||
_, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{
|
||||
Namespace: "default",
|
||||
Operations: []*v1.MutateOperation{
|
||||
{
|
||||
|
||||
@@ -28,7 +28,7 @@ func testMutateResourcePermissions(t *testing.T, srv *Server) {
|
||||
setupMutateResourcePermissions(t, srv)
|
||||
|
||||
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",
|
||||
Operations: []*v1.MutateOperation{
|
||||
{
|
||||
@@ -76,7 +76,7 @@ func testMutateResourcePermissions(t *testing.T, srv *Server) {
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Tuples, 2)
|
||||
|
||||
_, err = srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
|
||||
_, err = srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{
|
||||
Namespace: "default",
|
||||
Operations: []*v1.MutateOperation{
|
||||
{
|
||||
|
||||
@@ -25,7 +25,7 @@ func testMutateRoleBindings(t *testing.T, srv *Server) {
|
||||
setupMutateRoleBindings(t, srv)
|
||||
|
||||
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",
|
||||
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) {
|
||||
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
|
||||
_, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{
|
||||
Namespace: "default",
|
||||
Operations: []*v1.MutateOperation{
|
||||
{
|
||||
|
||||
@@ -25,7 +25,7 @@ func testMutateRoles(t *testing.T, srv *Server) {
|
||||
setupMutateRoles(t, srv)
|
||||
|
||||
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",
|
||||
Operations: []*v1.MutateOperation{
|
||||
{
|
||||
|
||||
@@ -25,7 +25,7 @@ func testMutateTeamBindings(t *testing.T, srv *Server) {
|
||||
setupMutateTeamBindings(t, srv)
|
||||
|
||||
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",
|
||||
Operations: []*v1.MutateOperation{
|
||||
{
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
|
||||
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)
|
||||
|
||||
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",
|
||||
Operations: []*v1.MutateOperation{
|
||||
{
|
||||
@@ -133,6 +135,25 @@ func testMutate(t *testing.T, srv *Server) {
|
||||
require.NoError(t, err)
|
||||
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) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"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/store"
|
||||
"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 {
|
||||
return newContextWithNamespaceAndPermissions()
|
||||
}
|
||||
|
||||
func newContextWithNamespaceAndPermissions(perms ...string) context.Context {
|
||||
ctx := context.Background()
|
||||
ctx = claims.WithAuthInfo(ctx, authnlib.NewAccessTokenAuthInfo(authnlib.Claims[authnlib.AccessTokenClaims]{
|
||||
Rest: authnlib.AccessTokenClaims{
|
||||
Namespace: "*",
|
||||
Namespace: "*",
|
||||
Permissions: perms,
|
||||
DelegatedPermissions: perms,
|
||||
},
|
||||
}))
|
||||
return ctx
|
||||
}
|
||||
|
||||
func newContextWithZanzanaUpdatePermission() context.Context {
|
||||
return newContextWithNamespaceAndPermissions(zanzana.TokenPermissionUpdate)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||
"go.opentelemetry.io/otel/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"
|
||||
@@ -25,6 +26,9 @@ func (s *Server) Write(ctx context.Context, req *authzextv1.WriteRequest) (*auth
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
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())
|
||||
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) {
|
||||
if err := authorize(ctx, req.GetNamespace(), s.cfg); err != nil {
|
||||
if err := authorizeWrite(ctx, req.GetNamespace(), s.cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
46
pkg/services/authz/zanzana/server/server_write_test.go
Normal file
46
pkg/services/authz/zanzana/server/server_write_test.go
Normal file
@@ -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
|
||||
)
|
||||
|
||||
// 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 (
|
||||
RelationTeamMember = common.RelationTeamMember
|
||||
RelationTeamAdmin = common.RelationTeamAdmin
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
SELECT
|
||||
{{ .Ident "created" }},
|
||||
{{ .Ident "created_by" }},
|
||||
{{ .Ident "version" }},
|
||||
{{ .Ident "active" }},
|
||||
{{ .Ident "namespace" }},
|
||||
{{ .Ident "name" }}
|
||||
FROM
|
||||
{{ .Ident "secret_secure_value" }}
|
||||
WHERE
|
||||
WHERE
|
||||
{{ .Ident "namespace" }} = {{ .Arg .Namespace }} AND
|
||||
{{ .Ident "name" }} = {{ .Arg .Name }}
|
||||
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.
|
||||
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, "")
|
||||
if err != nil {
|
||||
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.Created = createdAt
|
||||
row.CreatedBy = actorUID
|
||||
row.CreatedBy = createdBy
|
||||
row.Updated = updatedAt
|
||||
row.UpdatedBy = actorUID
|
||||
row.UpdatedBy = updatedBy
|
||||
|
||||
return row, nil
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ func (s *secureValueMetadataStorage) Create(ctx context.Context, keeper string,
|
||||
var row *secureValueDB
|
||||
|
||||
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 {
|
||||
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
|
||||
|
||||
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 {
|
||||
return fmt.Errorf("to create row: %w", err)
|
||||
}
|
||||
@@ -161,13 +167,14 @@ func (s *secureValueMetadataStorage) Create(ctx context.Context, keeper string,
|
||||
return createdSecureValue, nil
|
||||
}
|
||||
|
||||
type versionAndCreatedAt struct {
|
||||
type versionAndCreated struct {
|
||||
createdAt int64
|
||||
createdBy string
|
||||
version int64
|
||||
}
|
||||
|
||||
func (s *secureValueMetadataStorage) getLatestVersionAndCreatedAt(ctx context.Context, namespace xkube.Namespace, name string) (versionAndCreatedAt, error) {
|
||||
ctx, span := s.tracer.Start(ctx, "SecureValueMetadataStorage.getLatestVersionAndCreatedAt", trace.WithAttributes(
|
||||
func (s *secureValueMetadataStorage) getLatestVersionAndCreated(ctx context.Context, namespace xkube.Namespace, name string) (versionAndCreated, error) {
|
||||
ctx, span := s.tracer.Start(ctx, "SecureValueMetadataStorage.getLatestVersionAndCreated", trace.WithAttributes(
|
||||
attribute.String("name", name),
|
||||
attribute.String("namespace", namespace.String()),
|
||||
))
|
||||
@@ -181,45 +188,48 @@ func (s *secureValueMetadataStorage) getLatestVersionAndCreatedAt(ctx context.Co
|
||||
|
||||
q, err := sqltemplate.Execute(sqlGetLatestSecureValueVersionAndCreatedAt, req)
|
||||
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()...)
|
||||
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() }()
|
||||
|
||||
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() {
|
||||
return versionAndCreatedAt{}, nil
|
||||
return versionAndCreated{}, nil
|
||||
}
|
||||
|
||||
var (
|
||||
createdAt int64
|
||||
createdBy string
|
||||
version int64
|
||||
active bool
|
||||
namespaceFromDB string
|
||||
nameFromDB string
|
||||
)
|
||||
if err := rows.Scan(&createdAt, &version, &active, &namespaceFromDB, &nameFromDB); err != nil {
|
||||
return versionAndCreatedAt{}, fmt.Errorf("scanning version from returned rows: %w", err)
|
||||
if err := rows.Scan(&createdAt, &createdBy, &version, &active, &namespaceFromDB, &nameFromDB); err != nil {
|
||||
return versionAndCreated{}, fmt.Errorf("scanning version and created from returned rows: %w", err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
if !active {
|
||||
createdAt = 0
|
||||
createdBy = ""
|
||||
}
|
||||
|
||||
return versionAndCreatedAt{
|
||||
return versionAndCreated{
|
||||
createdAt: createdAt,
|
||||
createdBy: createdBy,
|
||||
version: version,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
SELECT
|
||||
`created`,
|
||||
`created_by`,
|
||||
`version`,
|
||||
`active`,
|
||||
`namespace`,
|
||||
`name`
|
||||
FROM
|
||||
`secret_secure_value`
|
||||
WHERE
|
||||
WHERE
|
||||
`namespace` = 'ns' AND
|
||||
`name` = 'name'
|
||||
ORDER BY `version` DESC
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
SELECT
|
||||
"created",
|
||||
"created_by",
|
||||
"version",
|
||||
"active",
|
||||
"namespace",
|
||||
"name"
|
||||
FROM
|
||||
"secret_secure_value"
|
||||
WHERE
|
||||
WHERE
|
||||
"namespace" = 'ns' AND
|
||||
"name" = 'name'
|
||||
ORDER BY "version" DESC
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
SELECT
|
||||
"created",
|
||||
"created_by",
|
||||
"version",
|
||||
"active",
|
||||
"namespace",
|
||||
"name"
|
||||
FROM
|
||||
"secret_secure_value"
|
||||
WHERE
|
||||
WHERE
|
||||
"namespace" = 'ns' AND
|
||||
"name" = 'name'
|
||||
ORDER BY "version" DESC
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
bleveSearch "github.com/blevesearch/bleve/v2/search/searcher"
|
||||
index "github.com/blevesearch/bleve_index_api"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
bolterrors "go.etcd.io/bbolt/errors"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.uber.org/atomic"
|
||||
@@ -44,6 +45,7 @@ import (
|
||||
const (
|
||||
indexStorageMemory = "memory"
|
||||
indexStorageFile = "file"
|
||||
boltTimeout = "500ms"
|
||||
)
|
||||
|
||||
// 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)
|
||||
// If we do have an unexpired cached index already, we always build a new index from scratch.
|
||||
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
|
||||
logWithDetails.Debug("Existing index found on filesystem", "indexRV", indexRV, "directory", filepath.Join(resourceDir, fileIndexName))
|
||||
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
|
||||
// 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)
|
||||
defer closeIndexOnExit(index, indexDir) // Close index, and delete new index directory.
|
||||
}
|
||||
} else {
|
||||
}
|
||||
|
||||
if newIndexType == indexStorageMemory {
|
||||
index, err = newBleveIndex("", mapper, time.Now(), b.opts.BuildVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating new in-memory bleve index: %w", err)
|
||||
@@ -552,30 +567,30 @@ func cleanFileSegment(input string) string {
|
||||
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.
|
||||
func (b *bleveBackend) cleanOldIndexes(dir string, skipName string) {
|
||||
files, err := os.ReadDir(dir)
|
||||
func (b *bleveBackend) cleanOldIndexes(resourceDir string, skipName string) {
|
||||
entries, err := os.ReadDir(resourceDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return
|
||||
}
|
||||
b.log.Warn("error cleaning folders from", "directory", dir, "error", err)
|
||||
b.log.Warn("error cleaning folders from", "directory", resourceDir, "error", err)
|
||||
return
|
||||
}
|
||||
for _, file := range files {
|
||||
if file.IsDir() && file.Name() != skipName {
|
||||
fpath := filepath.Join(dir, file.Name())
|
||||
if !isPathWithinRoot(fpath, b.opts.Root) {
|
||||
b.log.Warn("Skipping cleanup of directory", "directory", fpath)
|
||||
for _, ent := range entries {
|
||||
if ent.IsDir() && ent.Name() != skipName {
|
||||
indexDir := filepath.Join(resourceDir, ent.Name())
|
||||
if !isPathWithinRoot(indexDir, b.opts.Root) {
|
||||
b.log.Warn("Skipping cleanup of directory", "directory", indexDir)
|
||||
continue
|
||||
}
|
||||
|
||||
err = os.RemoveAll(fpath)
|
||||
err = os.RemoveAll(indexDir)
|
||||
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 {
|
||||
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")
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, "", 0
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, ent := range entries {
|
||||
@@ -635,8 +657,13 @@ func (b *bleveBackend) findPreviousFileBasedIndex(resourceDir string) (bleve.Ind
|
||||
|
||||
indexName := ent.Name()
|
||||
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 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)
|
||||
continue
|
||||
}
|
||||
@@ -648,10 +675,14 @@ func (b *bleveBackend) findPreviousFileBasedIndex(resourceDir string) (bleve.Ind
|
||||
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.
|
||||
|
||||
@@ -1583,3 +1583,76 @@ func docCount(t *testing.T, idx resource.ResourceIndex) int {
|
||||
require.NoError(t, err)
|
||||
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",
|
||||
"text": "Integration Test ",
|
||||
"fallback": "Integration Test [FIRING:1] SlackAlert1 (default)",
|
||||
"footer": "Grafana v",
|
||||
"footer": "Grafana",
|
||||
"footer_icon": "https://grafana.com/static/assets/img/fav32.png",
|
||||
"color": "#D63232",
|
||||
"ts": %s,
|
||||
@@ -2490,7 +2490,7 @@ var expNonEmailNotifications = map[string][]string{
|
||||
"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",
|
||||
"fallback": "[FIRING:1] SlackAlert2 (default)",
|
||||
"footer": "Grafana v",
|
||||
"footer": "Grafana",
|
||||
"footer_icon": "https://grafana.com/static/assets/img/fav32.png",
|
||||
"color": "#D63232",
|
||||
"ts": %s,
|
||||
|
||||
@@ -2699,6 +2699,24 @@
|
||||
"secure": false,
|
||||
"dependsOn": "",
|
||||
"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,
|
||||
"dependsOn": "",
|
||||
"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
|
||||
func TestIntegrationDashboardAPI(t *testing.T) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/xlab/treeprint"
|
||||
@@ -31,6 +32,33 @@ import (
|
||||
"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) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
@@ -47,7 +75,7 @@ func TestIntegrationFolderTree(t *testing.T) {
|
||||
}
|
||||
for _, mode := range modes {
|
||||
t.Run(fmt.Sprintf("mode %d", mode), func(t *testing.T) {
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
runIntegrationFolderTree(t, testinfra.GrafanaOpts{
|
||||
DisableDataMigrations: true,
|
||||
AppModeProduction: 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
|
||||
})
|
||||
defer helper.Shutdown()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
Name string
|
||||
Definition FolderDefinition
|
||||
Expected []ExpectedTree
|
||||
}{
|
||||
{
|
||||
Name: "admin-only-tree",
|
||||
Definition: FolderDefinition{
|
||||
func runIntegrationFolderTree(t *testing.T, opts testinfra.GrafanaOpts) {
|
||||
if !db.IsTestDbSQLite() {
|
||||
t.Skip("test only on sqlite for now")
|
||||
}
|
||||
|
||||
helper := apis.NewK8sTestHelper(t, opts)
|
||||
defer helper.Shutdown()
|
||||
|
||||
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{
|
||||
{Name: "top",
|
||||
{Name: "middle",
|
||||
Creator: helper.Org1.Admin,
|
||||
Children: []FolderDefinition{
|
||||
{Name: "middle",
|
||||
{Name: "child",
|
||||
Creator: helper.Org1.Admin,
|
||||
Children: []FolderDefinition{
|
||||
{Name: "child",
|
||||
Creator: helper.Org1.Admin,
|
||||
Permissions: []FolderPermission{{
|
||||
Permission: "View",
|
||||
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)
|
||||
....└── middle (admin,edit,save,delete)
|
||||
........└── child (admin,edit,save,delete)`},
|
||||
{User: helper.Org1.Viewer, Listing: `
|
||||
{User: helper.Org1.Viewer, Listing: `
|
||||
└── top (view)
|
||||
....└── middle (view)
|
||||
........└── child (view)`},
|
||||
{User: helper.Org1.None, Listing: `
|
||||
{User: helper.Org1.None, Listing: `
|
||||
└── sharedwithme (???)
|
||||
....└── child (view)`,
|
||||
E403: []string{"top", "middle"},
|
||||
},
|
||||
},
|
||||
E403: []string{"top", "middle"},
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var statusCode int
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.Name, func(t *testing.T) {
|
||||
tt.Definition.RequireUniqueName(t, make(map[string]bool))
|
||||
var statusCode int
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.Name, func(t *testing.T) {
|
||||
tt.Definition.RequireUniqueName(t, make(map[string]bool))
|
||||
|
||||
tt.Definition.CreateWithLegacyAPI(t, helper, "")
|
||||
// CreateWithLegacyAPI
|
||||
tt.Definition.CreateWithLegacyAPI(t, helper, "")
|
||||
|
||||
for _, expect := range tt.Expected {
|
||||
unstructured, client := getFolderClients(t, expect.User)
|
||||
t.Run(fmt.Sprintf("query as %s", expect.User.Identity.GetLogin()), func(t *testing.T) {
|
||||
legacy := getFoldersFromLegacyAPISearch(t, client)
|
||||
legacy.requireEqual(t, expect.Listing, "legacy")
|
||||
for _, expect := range tt.Expected {
|
||||
unstructured, client := getFolderClients(t, expect.User)
|
||||
t.Run(fmt.Sprintf("query as %s", expect.User.Identity.GetLogin()), func(t *testing.T) {
|
||||
legacy := getFoldersFromLegacyAPISearch(t, client)
|
||||
legacy.requireEqual(t, expect.Listing, "legacy")
|
||||
|
||||
listed := getFoldersFromAPIServerList(t, unstructured)
|
||||
listed.requireEqual(t, expect.Listing, "listed")
|
||||
listed := getFoldersFromAPIServerList(t, unstructured)
|
||||
listed.requireEqual(t, expect.Listing, "listed")
|
||||
|
||||
search := getFoldersFromDashboardV0Search(t, client, expect.User.Identity.GetNamespace())
|
||||
search.requireEqual(t, expect.Listing, "search")
|
||||
search := getFoldersFromDashboardV0Search(t, client, expect.User.Identity.GetNamespace())
|
||||
search.requireEqual(t, expect.Listing, "search")
|
||||
|
||||
// ensure sure GET also works on each folder we can list
|
||||
listed.forEach(func(fv *FolderView) {
|
||||
if fv.Name == folder.SharedWithMeFolderUID {
|
||||
return // skip it
|
||||
}
|
||||
found, err := unstructured.Get(context.Background(), fv.Name, v1.GetOptions{})
|
||||
require.NoErrorf(t, err, "getting folder: %s", fv.Name)
|
||||
require.Equal(t, found.GetName(), fv.Name)
|
||||
})
|
||||
// ensure sure GET also works on each folder we can list
|
||||
listed.forEach(func(fv *FolderView) {
|
||||
if fv.Name == folder.SharedWithMeFolderUID {
|
||||
return // skip it
|
||||
}
|
||||
found, err := unstructured.Get(context.Background(), fv.Name, v1.GetOptions{})
|
||||
require.NoErrorf(t, err, "getting folder: %s", fv.Name)
|
||||
require.Equal(t, found.GetName(), fv.Name)
|
||||
})
|
||||
|
||||
// Forbidden things should really be hidden
|
||||
for _, name := range expect.E403 {
|
||||
_, err := unstructured.Get(context.Background(), name, v1.GetOptions{})
|
||||
require.Error(t, err)
|
||||
require.Truef(t, apierrors.IsForbidden(err), "error: %w", err) // 404 vs 403 ????
|
||||
// Forbidden things should really be hidden
|
||||
for _, name := range expect.E403 {
|
||||
_, err := unstructured.Get(context.Background(), name, v1.GetOptions{})
|
||||
require.Error(t, err)
|
||||
require.Truef(t, apierrors.IsForbidden(err), "error: %w", err) // 404 vs 403 ????
|
||||
|
||||
result := client.Get().AbsPath("api", "folders", name).
|
||||
Do(context.Background()).
|
||||
StatusCode(&statusCode)
|
||||
require.Equal(t, int(http.StatusForbidden), statusCode)
|
||||
require.Error(t, result.Error())
|
||||
result := client.Get().AbsPath("api", "folders", name).
|
||||
Do(context.Background()).
|
||||
StatusCode(&statusCode)
|
||||
require.Equal(t, int(http.StatusForbidden), statusCode)
|
||||
require.Error(t, result.Error())
|
||||
|
||||
// Verify sub-resources are hidden
|
||||
for _, sub := range []string{"access", "parents", "children", "counts"} {
|
||||
_, err := unstructured.Get(context.Background(), name, v1.GetOptions{}, sub)
|
||||
require.Error(t, err, "expect error for subresource", sub)
|
||||
require.Truef(t, apierrors.IsForbidden(err), "error: %w", err) // 404 vs 403 ????
|
||||
}
|
||||
// Verify sub-resources are hidden
|
||||
for _, sub := range []string{"access", "parents", "children", "counts"} {
|
||||
_, err := unstructured.Get(context.Background(), name, v1.GetOptions{}, sub)
|
||||
require.Error(t, err, "expect error for subresource", sub)
|
||||
require.Truef(t, apierrors.IsForbidden(err), "error: %w", err) // 404 vs 403 ????
|
||||
}
|
||||
|
||||
// Verify legacy API access is also hidden
|
||||
for _, sub := range []string{"permissions", "counts"} {
|
||||
result := client.Get().AbsPath("api", "folders", name, sub).
|
||||
Do(context.Background()).
|
||||
StatusCode(&statusCode)
|
||||
require.Equalf(t, int(http.StatusForbidden), statusCode, "legacy access to: %s", sub)
|
||||
require.Error(t, result.Error())
|
||||
}
|
||||
}
|
||||
})
|
||||
// Verify legacy API access is also hidden
|
||||
for _, sub := range []string{"permissions", "counts"} {
|
||||
result := client.Get().AbsPath("api", "folders", name, sub).
|
||||
Do(context.Background()).
|
||||
StatusCode(&statusCode)
|
||||
require.Equalf(t, int(http.StatusForbidden), statusCode, "legacy access to: %s", sub)
|
||||
require.Error(t, result.Error())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -212,6 +249,8 @@ func (f *FolderDefinition) CreateWithLegacyAPI(t *testing.T, h *apis.K8sTestHelp
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
apis.AwaitZanzanaReconcileNext(t, h)
|
||||
|
||||
var statusCode int
|
||||
result := client.Post().AbsPath("api", "folders").
|
||||
Body(body).
|
||||
|
||||
87
pkg/tests/apis/zanzana_reconcile.go
Normal file
87
pkg/tests/apis/zanzana_reconcile.go
Normal file
@@ -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)
|
||||
}
|
||||
|
||||
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")
|
||||
require.NoError(t, err)
|
||||
_, err = dashboardsSection.NewKey("min_refresh_interval", "10s")
|
||||
@@ -687,6 +701,8 @@ type GrafanaOpts struct {
|
||||
SecretsManagerEnableDBMigrations bool
|
||||
OpenFeatureAPIEnabled bool
|
||||
DisableAuthZClientCache bool
|
||||
ZanzanaReconciliationInterval time.Duration
|
||||
DisableZanzanaCache bool
|
||||
|
||||
// Allow creating grafana dir beforehand
|
||||
Dir string
|
||||
|
||||
@@ -33,11 +33,11 @@ func (ds *DataSource) parseResponse(ctx context.Context, metricDataOutputs []*cl
|
||||
dataRes := backend.DataResponse{}
|
||||
|
||||
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 {
|
||||
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
|
||||
|
||||
@@ -24,7 +24,7 @@ func (e *elasticsearchDataQuery) processQuery(q *Query, ms *es.MultiSearchReques
|
||||
filters.AddDateRangeFilter(defaultTimeField, to, from, es.DateFormatEpochMS)
|
||||
filters.AddQueryStringFilter(q.RawQuery, true)
|
||||
|
||||
if q.EditorType != nil && *q.EditorType == "code" && q.RawDSLQuery != "" {
|
||||
if q.EditorType != nil && *q.EditorType == "code" {
|
||||
cfg := backend.GrafanaConfigFromContext(e.ctx)
|
||||
if !cfg.FeatureToggles().IsEnabled("elasticsearchRawDSLQuery") {
|
||||
return backend.DownstreamError(fmt.Errorf("raw DSL query feature is disabled. Enable the elasticsearchRawDSLQuery feature toggle to use this query type"))
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
// isQueryWithError validates the query and returns an error if invalid
|
||||
func isQueryWithError(query *Query) error {
|
||||
// Skip validation for raw DSL queries because no easy way to see it is valid without just running it
|
||||
if query.EditorType != nil && *query.EditorType == "code" && query.RawDSLQuery != "" {
|
||||
if query.EditorType != nil && *query.EditorType == "code" {
|
||||
return nil
|
||||
}
|
||||
if len(query.BucketAggs) == 0 {
|
||||
|
||||
@@ -192,7 +192,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<UPlotConfigOptions> = (
|
||||
});
|
||||
|
||||
const xField = frame.fields[0];
|
||||
const xAxisHidden = xField.config.custom.axisPlacement === AxisPlacement.Hidden;
|
||||
const xAxisHidden = xField.config.custom?.axisPlacement === AxisPlacement.Hidden;
|
||||
|
||||
builder.addAxis({
|
||||
show: !xAxisHidden,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { DataFrame, DataTransformerID, standardTransformersRegistry, Transformer
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { t, Trans } from '@grafana/i18n';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { Box, Button, Grid, Stack, Text } from '@grafana/ui';
|
||||
import { Box, Button, Stack, Text } from '@grafana/ui';
|
||||
import config from 'app/core/config';
|
||||
|
||||
import { SqlExpressionCard } from '../../../dashboard/components/TransformationsEditor/SqlExpressionCard';
|
||||
@@ -26,9 +26,6 @@ const TRANSFORMATION_IDS = [
|
||||
DataTransformerID.filterByValue,
|
||||
];
|
||||
|
||||
const GRID_COLUMNS_WITH_SQL = 5;
|
||||
const GRID_COLUMNS_WITHOUT_SQL = 4;
|
||||
|
||||
export function LegacyEmptyTransformationsMessage({ onShowPicker }: { onShowPicker: () => void }) {
|
||||
return (
|
||||
<Box alignItems="center" padding={4}>
|
||||
@@ -94,13 +91,25 @@ export function NewEmptyTransformationsMessage(props: EmptyTransformationsProps)
|
||||
};
|
||||
|
||||
const showSqlCard = hasGoToQueries && config.featureToggles.sqlExpressions;
|
||||
const gridColumns = showSqlCard ? GRID_COLUMNS_WITH_SQL : GRID_COLUMNS_WITHOUT_SQL;
|
||||
|
||||
return (
|
||||
<Box alignItems="center" padding={4}>
|
||||
<Stack direction="column" alignItems="center" gap={4}>
|
||||
<Box padding={2}>
|
||||
<Stack direction="column" alignItems="start" gap={2}>
|
||||
<Stack direction="column" alignItems="start" gap={1}>
|
||||
<Text element="h3" textAlignment="start">
|
||||
<Trans i18nKey="transformations.empty.add-transformation-header">Add a Transformation</Trans>
|
||||
</Text>
|
||||
<Text element="p" textAlignment="start" color="secondary">
|
||||
<Trans i18nKey="transformations.empty.add-transformation-body">
|
||||
Transformations allow data to be changed in various ways before your visualization is shown.
|
||||
<br />
|
||||
This includes joining data together, renaming fields, making calculations, formatting data for display,
|
||||
and more.
|
||||
</Trans>
|
||||
</Text>
|
||||
</Stack>
|
||||
{(hasAddTransformation || hasGoToQueries) && (
|
||||
<Grid columns={gridColumns} gap={1}>
|
||||
<Stack direction="row" gap={1} wrap>
|
||||
{showSqlCard && (
|
||||
<SqlExpressionCard
|
||||
name={t('dashboard-scene.empty-transformations-message.sql-name', 'Transform with SQL')}
|
||||
@@ -125,19 +134,17 @@ export function NewEmptyTransformationsMessage(props: EmptyTransformationsProps)
|
||||
data={props.data}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
</Stack>
|
||||
)}
|
||||
<Stack direction="row" gap={2}>
|
||||
<Button
|
||||
icon="plus"
|
||||
variant="primary"
|
||||
size="md"
|
||||
onClick={handleShowMoreClick}
|
||||
data-testid={selectors.components.Transforms.addTransformationButton}
|
||||
>
|
||||
<Trans i18nKey="dashboard-scene.empty-transformations-message.show-more">Show more</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
<Button
|
||||
icon="plus"
|
||||
variant="primary"
|
||||
size="md"
|
||||
onClick={handleShowMoreClick}
|
||||
data-testid={selectors.components.Transforms.addTransformationButton}
|
||||
>
|
||||
<Trans i18nKey="dashboard-scene.empty-transformations-message.show-more">Show more</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -112,6 +112,37 @@ describe('PanelEditor', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Entering panel edit', () => {
|
||||
it('should clear edit pane selection', () => {
|
||||
pluginPromise = Promise.resolve(getPanelPlugin({ id: 'text', skipDataQuery: true }));
|
||||
|
||||
const panel = new VizPanel({
|
||||
key: 'panel-1',
|
||||
pluginId: 'text',
|
||||
title: 'original title',
|
||||
});
|
||||
const gridItem = new DashboardGridItem({ body: panel });
|
||||
const panelEditor = buildPanelEditScene(panel);
|
||||
const dashboard = new DashboardScene({
|
||||
editPanel: panelEditor,
|
||||
isEditing: true,
|
||||
$timeRange: new SceneTimeRange({ from: 'now-6h', to: 'now' }),
|
||||
body: new DefaultGridLayoutManager({
|
||||
grid: new SceneGridLayout({
|
||||
children: [gridItem],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
dashboard.state.editPane.selectObject(panel, panel.state.key!, { force: true });
|
||||
expect(dashboard.state.editPane.getSelection()).toBe(panel);
|
||||
|
||||
deactivate = activateFullSceneTree(dashboard);
|
||||
|
||||
expect(dashboard.state.editPane.getSelection()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('When discarding', () => {
|
||||
it('should discard changes revert all changes', async () => {
|
||||
const { panelEditor, panel, dashboard } = await setup();
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface PanelEditorState extends SceneObjectState {
|
||||
panelRef: SceneObjectRef<VizPanel>;
|
||||
showLibraryPanelSaveModal?: boolean;
|
||||
showLibraryPanelUnlinkModal?: boolean;
|
||||
editPreview?: VizPanel;
|
||||
tableView?: VizPanel;
|
||||
pluginLoadErrror?: string;
|
||||
/**
|
||||
@@ -84,6 +85,11 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
|
||||
|
||||
private _activationHandler() {
|
||||
const panel = this.state.panelRef.resolve();
|
||||
const dashboard = getDashboardSceneFor(this);
|
||||
|
||||
// Clear any panel selection when entering panel edit mode.
|
||||
// Need to clear selection here since selection is activated when panel edit mode is entered through the panel actions menu. This causes sidebar panel editor to be open when exiting panel edit mode
|
||||
dashboard.state.editPane.clearSelection();
|
||||
|
||||
if (panel.state.pluginId === UNCONFIGURED_PANEL_PLUGIN_ID) {
|
||||
if (config.featureToggles.newVizSuggestions) {
|
||||
@@ -145,6 +151,9 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
|
||||
const changedState = layoutItem.state;
|
||||
const originalState = this._layoutItemState!;
|
||||
|
||||
this.setState({ editPreview: undefined });
|
||||
this.state.optionsPane?.setState({ editPreviewRef: undefined });
|
||||
|
||||
// Temp fix for old edit mode
|
||||
if (this._layoutItem instanceof DashboardGridItem && !config.featureToggles.dashboardNewLayouts) {
|
||||
this._layoutItem.handleEditChange();
|
||||
@@ -251,16 +260,40 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
|
||||
);
|
||||
|
||||
// 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({
|
||||
optionsPane: new PanelOptionsPane({
|
||||
panelRef: this.state.panelRef,
|
||||
searchQuery: '',
|
||||
listMode: OptionFilter.All,
|
||||
isVizPickerOpen: isUnconfigured,
|
||||
isNewPanel: this.state.isNewPanel,
|
||||
}),
|
||||
optionsPane,
|
||||
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 {
|
||||
// plugin changed after first time initialization
|
||||
// Just update data pane
|
||||
|
||||
@@ -81,7 +81,7 @@ export function PanelEditorRenderer({ model }: SceneComponentProps<PanelEditor>)
|
||||
|
||||
function VizAndDataPane({ model }: SceneComponentProps<PanelEditor>) {
|
||||
const dashboard = getDashboardSceneFor(model);
|
||||
const { dataPane, showLibraryPanelSaveModal, showLibraryPanelUnlinkModal, tableView } = model.useState();
|
||||
const { dataPane, showLibraryPanelSaveModal, showLibraryPanelUnlinkModal, tableView, editPreview } = model.useState();
|
||||
const panel = model.getPanel();
|
||||
const libraryPanel = getLibraryPanelBehavior(panel);
|
||||
const { controls } = dashboard.useState();
|
||||
@@ -113,7 +113,7 @@ function VizAndDataPane({ model }: SceneComponentProps<PanelEditor>) {
|
||||
)}
|
||||
<div {...containerProps}>
|
||||
<div {...primaryProps} className={cx(primaryProps.className, isScrollingLayout && styles.fixedSizeViz)}>
|
||||
<VizWrapper panel={panel} tableView={tableView} />
|
||||
<VizWrapper panel={editPreview ?? panel} tableView={tableView} />
|
||||
</div>
|
||||
{showLibraryPanelSaveModal && libraryPanel && (
|
||||
<SaveLibraryVizPanelModal
|
||||
|
||||
@@ -27,7 +27,7 @@ describe('PanelOptionsPane', () => {
|
||||
|
||||
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']?.fieldConfig).toBe(panel.state.fieldConfig);
|
||||
@@ -52,7 +52,7 @@ describe('PanelOptionsPane', () => {
|
||||
panel.setState({ $data: undefined });
|
||||
panel.activate();
|
||||
|
||||
optionsPane.onChangePanelPlugin({
|
||||
optionsPane.onChangePanel({
|
||||
pluginId: 'table',
|
||||
options: { showHeader: false },
|
||||
fieldConfig: {
|
||||
@@ -114,7 +114,7 @@ describe('PanelOptionsPane', () => {
|
||||
expect(panel.state.fieldConfig.overrides[1].properties).toHaveLength(1);
|
||||
expect(panel.state.fieldConfig.defaults.custom).toHaveProperty('axisBorderShow');
|
||||
|
||||
optionsPane.onChangePanelPlugin({ pluginId: 'table' });
|
||||
optionsPane.onChangePanel({ pluginId: 'table' });
|
||||
|
||||
expect(mockFn).toHaveBeenCalled();
|
||||
expect(mockFn.mock.calls[0][2].defaults.color?.mode).toBe('palette-classic');
|
||||
@@ -146,8 +146,8 @@ describe('PanelOptionsPane', () => {
|
||||
const mockOnFieldConfigChange = jest.fn();
|
||||
panel.onFieldConfigChange = mockOnFieldConfigChange;
|
||||
|
||||
// Call onChangePanelPlugin with fieldConfig that has overrides
|
||||
optionsPane.onChangePanelPlugin({
|
||||
// Call onChangePanel with fieldConfig that has overrides
|
||||
optionsPane.onChangePanel({
|
||||
pluginId: 'table',
|
||||
fieldConfig: {
|
||||
defaults: { unit: 'percent' },
|
||||
@@ -178,7 +178,7 @@ describe('PanelOptionsPane', () => {
|
||||
panel.onFieldConfigChange = mockOnFieldConfigChange;
|
||||
|
||||
// Call without fieldConfig
|
||||
optionsPane.onChangePanelPlugin({
|
||||
optionsPane.onChangePanel({
|
||||
pluginId: 'table',
|
||||
options: { showHeader: false },
|
||||
});
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface PanelOptionsPaneState extends SceneObjectState {
|
||||
panelRef: SceneObjectRef<VizPanel>;
|
||||
isNewPanel?: boolean;
|
||||
hasPickedViz?: boolean;
|
||||
editPreviewRef?: SceneObjectRef<VizPanel>;
|
||||
}
|
||||
|
||||
interface PluginOptionsCache {
|
||||
@@ -63,8 +64,7 @@ export class PanelOptionsPane extends SceneObjectBase<PanelOptionsPaneState> {
|
||||
});
|
||||
};
|
||||
|
||||
onChangePanelPlugin = (options: VizTypeChangeDetails) => {
|
||||
const panel = this.state.panelRef.resolve();
|
||||
onChangePanel = (options: VizTypeChangeDetails, panel = this.state.panelRef.resolve()) => {
|
||||
const { options: prevOptions, fieldConfig: prevFieldConfig, pluginId: prevPluginId } = panel.state;
|
||||
const pluginId = options.pluginId;
|
||||
|
||||
@@ -137,8 +137,10 @@ export class PanelOptionsPane extends SceneObjectBase<PanelOptionsPaneState> {
|
||||
}
|
||||
|
||||
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 editPreview = editPreviewRef?.resolve() ?? panel; // if something goes wrong, at least update the panel.
|
||||
const { pluginId } = panel.useState();
|
||||
const { data } = sceneGraph.getData(panel).useState();
|
||||
const styles = useStyles2(getStyles);
|
||||
@@ -229,7 +231,8 @@ function PanelOptionsPaneComponent({ model }: SceneComponentProps<PanelOptionsPa
|
||||
{isVizPickerOpen && (
|
||||
<PanelVizTypePicker
|
||||
panel={panel}
|
||||
onChange={model.onChangePanelPlugin}
|
||||
editPreview={editPreview}
|
||||
onChange={model.onChangePanel}
|
||||
onClose={model.onToggleVizPicker}
|
||||
data={data}
|
||||
showBackButton={config.featureToggles.newVizSuggestions ? hasPickedViz || !isNewPanel : true}
|
||||
|
||||
@@ -23,7 +23,8 @@ export interface Props {
|
||||
data?: PanelData;
|
||||
showBackButton?: boolean;
|
||||
panel: VizPanel;
|
||||
onChange: (options: VizTypeChangeDetails) => void;
|
||||
editPreview: VizPanel;
|
||||
onChange: (options: VizTypeChangeDetails, panel?: VizPanel) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -41,7 +42,7 @@ const getTabs = (): Array<{ label: string; value: VisualizationSelectPaneTab }>
|
||||
: [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 panelModel = useMemo(() => new PanelModelCompatibilityWrapper(panel), [panel]);
|
||||
const filterId = useId();
|
||||
@@ -97,49 +98,55 @@ export function PanelVizTypePicker({ panel, data, onChange, onClose, showBackBut
|
||||
</TabsBar>
|
||||
<ScrollContainer>
|
||||
<TabContent className={styles.tabContent}>
|
||||
{listMode === VisualizationSelectPaneTab.Suggestions && (
|
||||
<VisualizationSuggestions onChange={onChange} panel={panelModel} data={data} />
|
||||
)}
|
||||
{listMode === VisualizationSelectPaneTab.Visualizations && (
|
||||
<Stack gap={1} direction="column">
|
||||
<Field
|
||||
tabIndex={0}
|
||||
className={styles.searchField}
|
||||
noMargin
|
||||
htmlFor={filterId}
|
||||
aria-label={t('dashboard-scene.panel-viz-type-picker.placeholder-search-for', 'Search for...')}
|
||||
>
|
||||
<Stack direction="row" gap={1}>
|
||||
{showBackButton && (
|
||||
<Button
|
||||
aria-label={t('dashboard-scene.panel-viz-type-picker.title-close', 'Close')}
|
||||
fill="text"
|
||||
variant="secondary"
|
||||
icon="arrow-left"
|
||||
data-testid={selectors.components.PanelEditor.toggleVizPicker}
|
||||
onClick={onClose}
|
||||
>
|
||||
<Trans i18nKey="dashboard-scene.panel-viz-type-picker.button.close">Back</Trans>
|
||||
</Button>
|
||||
)}
|
||||
<FilterInput
|
||||
id={filterId}
|
||||
className={styles.filter}
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder={t('dashboard-scene.panel-viz-type-picker.placeholder-search-for', 'Search for...')}
|
||||
/>
|
||||
</Stack>
|
||||
</Field>
|
||||
<Stack gap={1} direction="column">
|
||||
<Field
|
||||
tabIndex={0}
|
||||
className={styles.searchField}
|
||||
noMargin
|
||||
htmlFor={filterId}
|
||||
aria-label={t('dashboard-scene.panel-viz-type-picker.placeholder-search-for', 'Search for...')}
|
||||
>
|
||||
<Stack direction="row" gap={1}>
|
||||
{showBackButton && (
|
||||
<Button
|
||||
aria-label={t('dashboard-scene.panel-viz-type-picker.title-close', 'Close')}
|
||||
fill="text"
|
||||
variant="secondary"
|
||||
icon="arrow-left"
|
||||
data-testid={selectors.components.PanelEditor.toggleVizPicker}
|
||||
onClick={onClose}
|
||||
>
|
||||
<Trans i18nKey="dashboard-scene.panel-viz-type-picker.button.close">Back</Trans>
|
||||
</Button>
|
||||
)}
|
||||
<FilterInput
|
||||
id={filterId}
|
||||
className={styles.filter}
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
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
|
||||
pluginId={panel.state.pluginId}
|
||||
searchQuery={searchQuery}
|
||||
trackSearch={trackSearch}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
)}
|
||||
</Stack>
|
||||
</TabContent>
|
||||
</ScrollContainer>
|
||||
</div>
|
||||
@@ -155,7 +162,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
gap: theme.spacing(2),
|
||||
}),
|
||||
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({
|
||||
width: '100%',
|
||||
|
||||
@@ -90,7 +90,6 @@ import { DashboardGridItem } from './layout-default/DashboardGridItem';
|
||||
import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager';
|
||||
import { addNewRowTo } from './layouts-shared/addNew';
|
||||
import { clearClipboard } from './layouts-shared/paste';
|
||||
import { getIsLazy } from './layouts-shared/utils';
|
||||
import { DashboardLayoutManager } from './types/DashboardLayoutManager';
|
||||
import { LayoutParent } from './types/LayoutParent';
|
||||
|
||||
@@ -199,7 +198,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
|
||||
meta: {},
|
||||
editable: true,
|
||||
$timeRange: state.$timeRange ?? new SceneTimeRange({}),
|
||||
body: state.body ?? DefaultGridLayoutManager.fromVizPanels([], getIsLazy(state.preload)),
|
||||
body: state.body ?? DefaultGridLayoutManager.fromVizPanels([]),
|
||||
links: state.links ?? [],
|
||||
...state,
|
||||
editPane: new DashboardEditPane(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
|
||||
import { Trans } from '@grafana/i18n';
|
||||
import { VizPanel } from '@grafana/scenes';
|
||||
import { LazyLoader, VizPanel } from '@grafana/scenes';
|
||||
import { Box, Spinner } from '@grafana/ui';
|
||||
|
||||
import { DashboardScene } from './DashboardScene';
|
||||
@@ -51,11 +51,23 @@ export function useSoloPanelContext() {
|
||||
return useContext(SoloPanelContext);
|
||||
}
|
||||
|
||||
export function renderMatchingSoloPanels(soloPanelContext: SoloPanelContextValue, panels: VizPanel[]) {
|
||||
export function renderMatchingSoloPanels(
|
||||
soloPanelContext: SoloPanelContextValue,
|
||||
panels: VizPanel[],
|
||||
isLazy?: boolean
|
||||
) {
|
||||
const matches: React.ReactNode[] = [];
|
||||
for (const panel of panels) {
|
||||
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 { useIsConditionallyHidden } from '../../conditional-rendering/hooks/useIsConditionallyHidden';
|
||||
import { useDashboardState } from '../../utils/utils';
|
||||
import { SoloPanelContextValueWithSearchStringFilter } from '../PanelSearchLayout';
|
||||
import { renderMatchingSoloPanels, useSoloPanelContext } from '../SoloPanelContext';
|
||||
import { getIsLazy } from '../layouts-shared/utils';
|
||||
|
||||
@@ -89,7 +90,11 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps<AutoGridItem
|
||||
);
|
||||
|
||||
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;
|
||||
|
||||
@@ -1,17 +1,43 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useMemo } from 'react';
|
||||
import { RefObject, useMemo } from 'react';
|
||||
|
||||
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 { useDashboardState } from '../../utils/utils';
|
||||
import { SoloPanelContextValueWithSearchStringFilter } from '../PanelSearchLayout';
|
||||
import { renderMatchingSoloPanels, useSoloPanelContext } from '../SoloPanelContext';
|
||||
import { getIsLazy } from '../layouts-shared/utils';
|
||||
|
||||
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>) {
|
||||
const { repeatedPanels = [], itemHeight, variableName, body } = model.useState();
|
||||
const soloPanelContext = useSoloPanelContext();
|
||||
const { preload } = useDashboardState(model);
|
||||
const isLazy = useMemo(() => getIsLazy(preload), [preload]);
|
||||
const layoutStyle = useLayoutStyle(
|
||||
model.getRepeatDirection(),
|
||||
model.getChildCount(),
|
||||
@@ -20,26 +46,22 @@ export function DashboardGridItemRenderer({ model }: SceneComponentProps<Dashboa
|
||||
);
|
||||
|
||||
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) {
|
||||
return (
|
||||
<div className={panelWrapper} ref={model.containerRef}>
|
||||
<body.Component model={body} key={body.state.key} />
|
||||
</div>
|
||||
);
|
||||
return <PanelWrapper panel={body} isLazy={isLazy} containerRef={model.containerRef} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={layoutStyle} ref={model.containerRef}>
|
||||
<div className={panelWrapper} key={body.state.key}>
|
||||
<body.Component model={body} key={body.state.key} />
|
||||
</div>
|
||||
<PanelWrapper panel={body} isLazy={isLazy} />
|
||||
{repeatedPanels.map((panel) => (
|
||||
<div className={panelWrapper} key={panel.state.key}>
|
||||
<panel.Component model={panel} key={panel.state.key} />
|
||||
</div>
|
||||
<PanelWrapper key={panel.state.key!} panel={panel} isLazy={isLazy} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -47,7 +47,6 @@ import { AutoGridItem } from '../layout-auto-grid/AutoGridItem';
|
||||
import { CanvasGridAddActions } from '../layouts-shared/CanvasGridAddActions';
|
||||
import { clearClipboard, getDashboardGridItemFromClipboard } from '../layouts-shared/paste';
|
||||
import { dashboardCanvasAddButtonHoverStyles } from '../layouts-shared/styles';
|
||||
import { getIsLazy } from '../layouts-shared/utils';
|
||||
import { DashboardLayoutGrid } from '../types/DashboardLayoutGrid';
|
||||
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
|
||||
import { LayoutRegistryItem } from '../types/LayoutRegistryItem';
|
||||
@@ -565,11 +564,10 @@ export class DefaultGridLayoutManager
|
||||
|
||||
public static createFromLayout(currentLayout: DashboardLayoutManager): DefaultGridLayoutManager {
|
||||
const panels = currentLayout.getVizPanels();
|
||||
const isLazy = getIsLazy(getDashboardSceneFor(currentLayout).state.preload)!;
|
||||
return DefaultGridLayoutManager.fromVizPanels(panels, isLazy);
|
||||
return DefaultGridLayoutManager.fromVizPanels(panels);
|
||||
}
|
||||
|
||||
public static fromVizPanels(panels: VizPanel[] = [], isLazy?: boolean | undefined): DefaultGridLayoutManager {
|
||||
public static fromVizPanels(panels: VizPanel[] = []): DefaultGridLayoutManager {
|
||||
const children: DashboardGridItem[] = [];
|
||||
const panelHeight = 10;
|
||||
const panelWidth = GRID_COLUMN_COUNT / 3;
|
||||
@@ -607,7 +605,6 @@ export class DefaultGridLayoutManager
|
||||
children: children,
|
||||
isDraggable: true,
|
||||
isResizable: true,
|
||||
isLazy,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -615,8 +612,7 @@ export class DefaultGridLayoutManager
|
||||
public static fromGridItems(
|
||||
gridItems: SceneGridItemLike[],
|
||||
isDraggable?: boolean,
|
||||
isResizable?: boolean,
|
||||
isLazy?: boolean | undefined
|
||||
isResizable?: boolean
|
||||
): DefaultGridLayoutManager {
|
||||
const children = gridItems.reduce<SceneGridItemLike[]>((acc, gridItem) => {
|
||||
gridItem.clearParent();
|
||||
@@ -630,7 +626,6 @@ export class DefaultGridLayoutManager
|
||||
children,
|
||||
isDraggable,
|
||||
isResizable,
|
||||
isLazy,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -358,8 +358,7 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
|
||||
layout: DefaultGridLayoutManager.fromGridItems(
|
||||
rowConfig.children,
|
||||
rowConfig.isDraggable ?? layout.state.grid.state.isDraggable,
|
||||
rowConfig.isResizable ?? layout.state.grid.state.isResizable,
|
||||
layout.state.grid.state.isLazy
|
||||
rowConfig.isResizable ?? layout.state.grid.state.isResizable
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -59,7 +59,11 @@ export function CanvasGridAddActions({ layoutManager }: Props) {
|
||||
}, [layoutManager]);
|
||||
|
||||
return (
|
||||
<div className={cx(styles.addAction, 'dashboard-canvas-add-button')}>
|
||||
<div
|
||||
className={cx(styles.addAction, 'dashboard-canvas-add-button')}
|
||||
onPointerUp={(evt) => evt.stopPropagation()}
|
||||
onPointerDown={(evt) => evt.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
fill="text"
|
||||
@@ -189,7 +193,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
height: theme.spacing(5),
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
opacity: 0,
|
||||
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
|
||||
transition: theme.transitions.create('opacity'),
|
||||
|
||||
@@ -345,12 +345,10 @@ export class V2DashboardSerializer
|
||||
// initialize autossigned variable ds references map
|
||||
if (saveModel?.variables) {
|
||||
for (const variable of saveModel.variables) {
|
||||
if (variable) {
|
||||
// for query variables that dont have a ds defined add them to the list
|
||||
if (variable.kind === 'QueryVariable' && !variable.spec.query.datasource?.name) {
|
||||
const datasourceType = variable.spec.query.group || undefined;
|
||||
this.defaultDsReferencesMap.variables.set(variable.spec.name, datasourceType);
|
||||
}
|
||||
// for query variables that dont have a ds defined add them to the list
|
||||
if (variable.kind === 'QueryVariable' && !variable.spec.query.datasource?.name) {
|
||||
const datasourceType = variable.spec.query.group || undefined;
|
||||
this.defaultDsReferencesMap.variables.set(variable.spec.name, datasourceType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { Card, Text, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Card, useStyles2 } from '@grafana/ui';
|
||||
import { getCardStyles } from './getCardStyles';
|
||||
|
||||
export interface SqlExpressionCardProps {
|
||||
name: string;
|
||||
@@ -12,60 +11,15 @@ export interface SqlExpressionCardProps {
|
||||
}
|
||||
|
||||
export function SqlExpressionCard({ name, description, imageUrl, onClick, testId }: SqlExpressionCardProps) {
|
||||
const styles = useStyles2(getSqlExpressionCardStyles);
|
||||
const styles = useStyles2(getCardStyles);
|
||||
|
||||
return (
|
||||
<Card className={styles.card} data-testid={testId} onClick={onClick} noMargin>
|
||||
<Card.Heading className={styles.heading}>
|
||||
<div className={styles.titleRow}>
|
||||
<span>{name}</span>
|
||||
</div>
|
||||
</Card.Heading>
|
||||
<Card.Description className={styles.description}>
|
||||
<span>{description}</span>
|
||||
{imageUrl && (
|
||||
<span>
|
||||
<img className={styles.image} src={imageUrl} alt={name} />
|
||||
</span>
|
||||
)}
|
||||
<Card className={styles.baseCard} data-testid={testId} onClick={onClick} noMargin>
|
||||
<Card.Heading>{name}</Card.Heading>
|
||||
<Card.Description>
|
||||
<Text variant="bodySmall">{description}</Text>
|
||||
{imageUrl && <img className={styles.image} src={imageUrl} alt={name} />}
|
||||
</Card.Description>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function getSqlExpressionCardStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
card: css({
|
||||
gridTemplateRows: 'min-content 0 1fr 0',
|
||||
marginBottom: 0,
|
||||
}),
|
||||
heading: css({
|
||||
fontWeight: 400,
|
||||
'> button': {
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
gap: theme.spacing(1),
|
||||
},
|
||||
}),
|
||||
titleRow: css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'nowrap',
|
||||
width: '100%',
|
||||
}),
|
||||
description: css({
|
||||
fontSize: theme.typography.bodySmall.fontSize,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
}),
|
||||
image: css({
|
||||
display: 'block',
|
||||
maxWidth: '100%',
|
||||
marginTop: theme.spacing(2),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,35 +1,38 @@
|
||||
import { cx, css } from '@emotion/css';
|
||||
import { cx } from '@emotion/css';
|
||||
|
||||
import {
|
||||
DataFrame,
|
||||
GrafanaTheme2,
|
||||
TransformerRegistryItem,
|
||||
TransformationApplicabilityLevels,
|
||||
standardTransformersRegistry,
|
||||
} from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Badge, Card, IconButton, useStyles2, useTheme2 } from '@grafana/ui';
|
||||
import { Badge, Card, IconButton, Stack, Text, useStyles2, useTheme2 } from '@grafana/ui';
|
||||
import { PluginStateInfo } from 'app/features/plugins/components/PluginStateInfo';
|
||||
|
||||
import { getCardStyles } from './getCardStyles';
|
||||
|
||||
export interface TransformationCardProps {
|
||||
transform: TransformerRegistryItem;
|
||||
data?: DataFrame[];
|
||||
fullWidth?: boolean;
|
||||
onClick: (id: string) => void;
|
||||
showIllustrations?: boolean;
|
||||
data?: DataFrame[];
|
||||
showPluginState?: boolean;
|
||||
showTags?: boolean;
|
||||
transform: TransformerRegistryItem;
|
||||
}
|
||||
|
||||
export function TransformationCard({
|
||||
transform,
|
||||
showIllustrations,
|
||||
onClick,
|
||||
data = [],
|
||||
fullWidth = false,
|
||||
onClick,
|
||||
showIllustrations,
|
||||
showPluginState = true,
|
||||
showTags = true,
|
||||
transform,
|
||||
}: TransformationCardProps) {
|
||||
const theme = useTheme2();
|
||||
const styles = useStyles2(getTransformationCardStyles);
|
||||
const styles = useStyles2(getCardStyles, fullWidth);
|
||||
|
||||
// Check to see if the transform is applicable to the given data
|
||||
let applicabilityScore = TransformationApplicabilityLevels.Applicable;
|
||||
@@ -47,7 +50,7 @@ export function TransformationCard({
|
||||
}
|
||||
}
|
||||
|
||||
const cardClasses = !isApplicable && data.length > 0 ? cx(styles.newCard, styles.cardDisabled) : styles.newCard;
|
||||
const cardClasses = cx(styles.baseCard, { [styles.cardDisabled]: !isApplicable });
|
||||
const imageUrl = theme.isDark ? transform.imageDark : transform.imageLight;
|
||||
const description = standardTransformersRegistry.getIfExists(transform.id)?.description;
|
||||
|
||||
@@ -58,15 +61,11 @@ export function TransformationCard({
|
||||
onClick={() => onClick(transform.id)}
|
||||
noMargin
|
||||
>
|
||||
<Card.Heading className={styles.heading}>
|
||||
<div className={styles.titleRow}>
|
||||
<span>{transform.name}</span>
|
||||
{showPluginState && (
|
||||
<span className={styles.pluginStateInfoWrapper}>
|
||||
<PluginStateInfo state={transform.state} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Card.Heading>
|
||||
<Stack alignItems="center" justifyContent="space-between">
|
||||
{transform.name}
|
||||
{showPluginState && <PluginStateInfo state={transform.state} />}
|
||||
</Stack>
|
||||
{showTags && transform.tags && transform.tags.size > 0 && (
|
||||
<div className={styles.tagsWrapper}>
|
||||
{Array.from(transform.tags).map((tag) => (
|
||||
@@ -75,74 +74,13 @@ export function TransformationCard({
|
||||
</div>
|
||||
)}
|
||||
</Card.Heading>
|
||||
<Card.Description className={styles.description}>
|
||||
<span>{description}</span>
|
||||
{showIllustrations && imageUrl && (
|
||||
<span>
|
||||
<img className={styles.image} src={imageUrl} alt={transform.name} />
|
||||
</span>
|
||||
)}
|
||||
<Card.Description>
|
||||
<Text variant="bodySmall">{description || ''}</Text>
|
||||
{showIllustrations && imageUrl && <img className={styles.image} src={imageUrl} alt={transform.name} />}
|
||||
{!isApplicable && applicabilityDescription !== null && (
|
||||
<IconButton className={styles.cardApplicableInfo} name="info-circle" tooltip={applicabilityDescription} />
|
||||
<IconButton className={styles.applicableInfoButton} name="info-circle" tooltip={applicabilityDescription} />
|
||||
)}
|
||||
</Card.Description>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function getTransformationCardStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
heading: css({
|
||||
fontWeight: 400,
|
||||
'> button': {
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
gap: theme.spacing(1),
|
||||
},
|
||||
}),
|
||||
titleRow: css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'nowrap',
|
||||
width: '100%',
|
||||
}),
|
||||
description: css({
|
||||
fontSize: theme.typography.bodySmall.fontSize,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
}),
|
||||
image: css({
|
||||
display: 'block',
|
||||
maxWidth: '100%',
|
||||
marginTop: theme.spacing(2),
|
||||
}),
|
||||
cardDisabled: css({
|
||||
backgroundColor: theme.colors.action.disabledBackground,
|
||||
img: {
|
||||
filter: 'grayscale(100%)',
|
||||
opacity: 0.33,
|
||||
},
|
||||
}),
|
||||
cardApplicableInfo: css({
|
||||
position: 'absolute',
|
||||
bottom: theme.spacing(1),
|
||||
right: theme.spacing(1),
|
||||
}),
|
||||
newCard: css({
|
||||
gridTemplateRows: 'min-content 0 1fr 0',
|
||||
marginBottom: 0,
|
||||
}),
|
||||
pluginStateInfoWrapper: css({
|
||||
marginLeft: theme.spacing(0.5),
|
||||
}),
|
||||
tagsWrapper: css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: theme.spacing(0.5),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -165,11 +165,12 @@ function TransformationsGrid({ showIllustrations, transformations, onClick, data
|
||||
<Grid columns={3} gap={1}>
|
||||
{transformations.map((transform) => (
|
||||
<TransformationCard
|
||||
key={transform.id}
|
||||
transform={transform}
|
||||
showIllustrations={showIllustrations}
|
||||
onClick={onClick}
|
||||
data={data}
|
||||
fullWidth
|
||||
key={transform.id}
|
||||
onClick={onClick}
|
||||
showIllustrations={showIllustrations}
|
||||
transform={transform}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
|
||||
export const getCardStyles = (theme: GrafanaTheme2, fullWidth?: boolean) => ({
|
||||
baseCard: css({
|
||||
maxWidth: fullWidth ? 'none' : '200px',
|
||||
width: fullWidth ? '100%' : 'auto',
|
||||
marginBottom: 0,
|
||||
}),
|
||||
image: css({
|
||||
display: 'block',
|
||||
maxWidth: '100%',
|
||||
marginTop: theme.spacing(2),
|
||||
}),
|
||||
cardDisabled: css({
|
||||
backgroundColor: theme.colors.action.disabledBackground,
|
||||
img: {
|
||||
filter: 'grayscale(100%)',
|
||||
opacity: 0.33,
|
||||
},
|
||||
}),
|
||||
applicableInfoButton: css({
|
||||
position: 'absolute',
|
||||
bottom: theme.spacing(1),
|
||||
right: theme.spacing(1),
|
||||
}),
|
||||
tagsWrapper: css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: theme.spacing(0.5),
|
||||
marginTop: theme.spacing(0.5),
|
||||
}),
|
||||
});
|
||||
@@ -9,8 +9,10 @@ import {
|
||||
PanelPluginMeta,
|
||||
PanelPluginVisualizationSuggestion,
|
||||
} from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { VizPanel } from '@grafana/scenes';
|
||||
import { Alert, Button, Icon, Spinner, Text, useStyles2 } from '@grafana/ui';
|
||||
import { UNCONFIGURED_PANEL_PLUGIN_ID } from 'app/features/dashboard-scene/scene/UnconfiguredPanel';
|
||||
|
||||
@@ -23,25 +25,47 @@ import { VisualizationSuggestionCard } from './VisualizationSuggestionCard';
|
||||
import { VizTypeChangeDetails } from './types';
|
||||
|
||||
export interface Props {
|
||||
onChange: (options: VizTypeChangeDetails) => void;
|
||||
onChange: (options: VizTypeChangeDetails, panel?: VizPanel) => void;
|
||||
editPreview?: VizPanel;
|
||||
data?: PanelData;
|
||||
panel?: PanelModel;
|
||||
searchQuery?: string;
|
||||
}
|
||||
|
||||
const useSuggestions = (data: PanelData | undefined) => {
|
||||
const useSuggestions = (data: PanelData | undefined, searchQuery: string | undefined) => {
|
||||
const [hasFetched, setHasFetched] = useState(false);
|
||||
const { value, loading, error, retry } = useAsyncRetry(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, hasFetched ? 75 : 0));
|
||||
setHasFetched(true);
|
||||
return await getAllSuggestions(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 { value: result, loading, error, retry } = useSuggestions(data);
|
||||
const { value: result, loading, error, retry } = useSuggestions(data, searchQuery);
|
||||
|
||||
const suggestions = result?.suggestions;
|
||||
const hasLoadingErrors = result?.hasErrors ?? false;
|
||||
@@ -73,18 +97,21 @@ export function VisualizationSuggestions({ onChange, data, panel }: Props) {
|
||||
|
||||
const applySuggestion = useCallback(
|
||||
(suggestion: PanelPluginVisualizationSuggestion, isPreview?: boolean) => {
|
||||
onChange({
|
||||
pluginId: suggestion.pluginId,
|
||||
options: suggestion.options,
|
||||
fieldConfig: suggestion.fieldConfig,
|
||||
withModKey: isPreview,
|
||||
});
|
||||
onChange(
|
||||
{
|
||||
pluginId: suggestion.pluginId,
|
||||
options: suggestion.options,
|
||||
fieldConfig: suggestion.fieldConfig,
|
||||
withModKey: isPreview,
|
||||
},
|
||||
isPreview ? editPreview : undefined
|
||||
);
|
||||
|
||||
if (isPreview) {
|
||||
setSuggestionHash(suggestion.hash);
|
||||
}
|
||||
},
|
||||
[onChange]
|
||||
[onChange, editPreview]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -185,17 +212,13 @@ export function VisualizationSuggestions({ onChange, data, panel }: Props) {
|
||||
variant="primary"
|
||||
size={'md'}
|
||||
className={styles.applySuggestionButton}
|
||||
data-testid={selectors.components.VisualizationPreview.confirm(suggestion.name)}
|
||||
aria-label={t(
|
||||
'panel.visualization-suggestions.apply-suggestion-aria-label',
|
||||
'Apply {{suggestionName}} visualization',
|
||||
{ suggestionName: suggestion.name }
|
||||
)}
|
||||
onClick={() =>
|
||||
onChange({
|
||||
pluginId: suggestion.pluginId,
|
||||
withModKey: false,
|
||||
})
|
||||
}
|
||||
onClick={() => applySuggestion(suggestion, false)}
|
||||
>
|
||||
{t('panel.visualization-suggestions.use-this-suggestion', 'Use this suggestion')}
|
||||
</Button>
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import { defaultBucketAgg } from '../../../../queryDef';
|
||||
import { reducerTester } from '../../../reducerTester';
|
||||
import { changeMetricType } from '../../MetricAggregationsEditor/state/actions';
|
||||
import { initQuery } from '../../state';
|
||||
import { changeEditorTypeAndResetQuery, initQuery } from '../../state';
|
||||
import { bucketAggregationConfig } from '../utils';
|
||||
|
||||
import {
|
||||
@@ -180,4 +180,27 @@ describe('Bucket Aggregations Reducer', () => {
|
||||
.thenStateShouldEqual([bucketAgg]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('When switching editor type', () => {
|
||||
it('Should reset bucket aggregations to default when switching editor types', () => {
|
||||
const defaultTimeField = '@timestamp';
|
||||
const initialState: BucketAggregation[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'date_histogram',
|
||||
field: '@timestamp',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'terms',
|
||||
field: 'status',
|
||||
},
|
||||
];
|
||||
|
||||
reducerTester<ElasticsearchDataQuery['bucketAggs']>()
|
||||
.givenReducer(createReducer(defaultTimeField), initialState)
|
||||
.whenActionIsDispatched(changeEditorTypeAndResetQuery('code'))
|
||||
.thenStateShouldEqual([{ ...defaultBucketAgg('2'), field: defaultTimeField }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { defaultBucketAgg } from '../../../../queryDef';
|
||||
import { removeEmpty } from '../../../../utils';
|
||||
import { changeMetricType } from '../../MetricAggregationsEditor/state/actions';
|
||||
import { metricAggregationConfig } from '../../MetricAggregationsEditor/utils';
|
||||
import { initQuery } from '../../state';
|
||||
import { changeEditorTypeAndResetQuery, initQuery } from '../../state';
|
||||
import { bucketAggregationConfig } from '../utils';
|
||||
|
||||
import {
|
||||
@@ -87,6 +87,11 @@ export const createReducer =
|
||||
return state;
|
||||
}
|
||||
|
||||
if (changeEditorTypeAndResetQuery.match(action)) {
|
||||
// Returns the default bucket agg. We will always want to set the default when switching types
|
||||
return [{ ...defaultBucketAgg('2'), field: defaultTimeField }];
|
||||
}
|
||||
|
||||
if (changeBucketAggregationSetting.match(action)) {
|
||||
return state!.map((bucketAgg) => {
|
||||
if (bucketAgg.id !== action.payload.bucketAgg.id) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
|
||||
import { defaultMetricAgg } from '../../../../queryDef';
|
||||
import { reducerTester } from '../../../reducerTester';
|
||||
import { initQuery } from '../../state';
|
||||
import { changeEditorTypeAndResetQuery, initQuery } from '../../state';
|
||||
import { metricAggregationConfig } from '../utils';
|
||||
|
||||
import {
|
||||
@@ -248,4 +248,26 @@ describe('Metric Aggregations Reducer', () => {
|
||||
.whenActionIsDispatched(initQuery())
|
||||
.thenStateShouldEqual([defaultMetricAgg('1')]);
|
||||
});
|
||||
|
||||
describe('When switching editor type', () => {
|
||||
it('Should reset to single default metric when switching to code editor', () => {
|
||||
const initialState: MetricAggregation[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'avg',
|
||||
field: 'value',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'max',
|
||||
field: 'value',
|
||||
},
|
||||
];
|
||||
|
||||
reducerTester<ElasticsearchDataQuery['metrics']>()
|
||||
.givenReducer(reducer, initialState)
|
||||
.whenActionIsDispatched(changeEditorTypeAndResetQuery('code'))
|
||||
.thenStateShouldEqual([defaultMetricAgg('1')]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ElasticsearchDataQuery, MetricAggregation } from 'app/plugins/datasourc
|
||||
|
||||
import { defaultMetricAgg, queryTypeToMetricType } from '../../../../queryDef';
|
||||
import { removeEmpty } from '../../../../utils';
|
||||
import { initQuery } from '../../state';
|
||||
import { changeEditorTypeAndResetQuery, initQuery } from '../../state';
|
||||
import { isMetricAggregationWithMeta, isMetricAggregationWithSettings, isPipelineAggregation } from '../aggregations';
|
||||
import { getChildren, metricAggregationConfig } from '../utils';
|
||||
|
||||
@@ -65,6 +65,11 @@ export const reducer = (
|
||||
});
|
||||
}
|
||||
|
||||
if (changeEditorTypeAndResetQuery.match(action)) {
|
||||
// Reset to default metric when switching to editor types
|
||||
return [defaultMetricAgg('1')];
|
||||
}
|
||||
|
||||
if (changeMetricField.match(action)) {
|
||||
return state!.map((metric) => {
|
||||
if (metric.id !== action.payload.id) {
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { ElasticsearchDataQuery } from '../../dataquery.gen';
|
||||
import { reducerTester } from '../reducerTester';
|
||||
|
||||
import { aliasPatternReducer, changeAliasPattern, changeQuery, initQuery, queryReducer } from './state';
|
||||
import {
|
||||
aliasPatternReducer,
|
||||
changeAliasPattern,
|
||||
changeEditorTypeAndResetQuery,
|
||||
changeQuery,
|
||||
initQuery,
|
||||
queryReducer,
|
||||
rawDSLQueryReducer,
|
||||
} from './state';
|
||||
|
||||
describe('Query Reducer', () => {
|
||||
describe('On Init', () => {
|
||||
@@ -42,6 +50,17 @@ describe('Query Reducer', () => {
|
||||
.whenActionIsDispatched({ type: 'THIS ACTION SHOULD NOT HAVE ANY EFFECT IN THIS REDUCER' })
|
||||
.thenStateShouldEqual(initialState);
|
||||
});
|
||||
|
||||
describe('When switching editor type', () => {
|
||||
it('Should clear query when switching editor types', () => {
|
||||
const initialQuery: ElasticsearchDataQuery['query'] = 'Some lucene query';
|
||||
|
||||
reducerTester<ElasticsearchDataQuery['query']>()
|
||||
.givenReducer(queryReducer, initialQuery)
|
||||
.whenActionIsDispatched(changeEditorTypeAndResetQuery('code'))
|
||||
.thenStateShouldEqual('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Alias Pattern Reducer', () => {
|
||||
@@ -62,4 +81,26 @@ describe('Alias Pattern Reducer', () => {
|
||||
.whenActionIsDispatched({ type: 'THIS ACTION SHOULD NOT HAVE ANY EFFECT IN THIS REDUCER' })
|
||||
.thenStateShouldEqual(initialState);
|
||||
});
|
||||
|
||||
describe('When switching editor type', () => {
|
||||
it('Should clear alias when switching editor types', () => {
|
||||
const initialAlias: ElasticsearchDataQuery['alias'] = 'Some alias pattern';
|
||||
|
||||
reducerTester<ElasticsearchDataQuery['alias']>()
|
||||
.givenReducer(aliasPatternReducer, initialAlias)
|
||||
.whenActionIsDispatched(changeEditorTypeAndResetQuery('code'))
|
||||
.thenStateShouldEqual('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Raw DSL Query Reducer', () => {
|
||||
it('Should clear raw DSL query when switching editor types', () => {
|
||||
const initialRawQuery: ElasticsearchDataQuery['rawDSLQuery'] = '{"query": {"match_all": {}}}';
|
||||
|
||||
reducerTester<ElasticsearchDataQuery['rawDSLQuery']>()
|
||||
.givenReducer(rawDSLQueryReducer, initialRawQuery)
|
||||
.whenActionIsDispatched(changeEditorTypeAndResetQuery('builder'))
|
||||
.thenStateShouldEqual('');
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user