Compare commits
33 Commits
index-owne
...
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 | ||
|
|
75b2c905cd | ||
|
|
45fc95cfc9 | ||
|
|
9c3cdd4814 | ||
|
|
2dad8b7b5b | ||
|
|
9a831ab4e1 | ||
|
|
759035a465 | ||
|
|
6e155523a3 | ||
|
|
5c0ee2d746 | ||
|
|
0c6b97bee2 | ||
|
|
4c79775b57 | ||
|
|
e088c9aac9 | ||
|
|
7182511bcf | ||
|
|
3023a72175 |
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/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-app.spec.ts @grafana/plugins-platform-frontend
|
||||||
/e2e-playwright/various-suite/frontend-sandbox-datasource.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/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/graph-auto-migrate.spec.ts @grafana/dataviz-squad
|
||||||
/e2e-playwright/various-suite/inspect-drawer.spec.ts @grafana/dashboards-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/solo-route.spec.ts @grafana/dashboards-squad
|
||||||
/e2e-playwright/various-suite/trace-view-scrolling.spec.ts @grafana/observability-traces-and-profiling
|
/e2e-playwright/various-suite/trace-view-scrolling.spec.ts @grafana/observability-traces-and-profiling
|
||||||
/e2e-playwright/various-suite/verify-i18n.spec.ts @grafana/grafana-frontend-platform
|
/e2e-playwright/various-suite/verify-i18n.spec.ts @grafana/grafana-frontend-platform
|
||||||
/e2e-playwright/various-suite/visualization-suggestions.spec.ts @grafana/dataviz-squad
|
/e2e-playwright/various-suite/visualization-suggestions*.spec.ts @grafana/dataviz-squad
|
||||||
/e2e-playwright/various-suite/perf-test.spec.ts @grafana/grafana-frontend-platform
|
/e2e-playwright/various-suite/perf-test.spec.ts @grafana/grafana-frontend-platform
|
||||||
|
|
||||||
# Packages
|
# Packages
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ require (
|
|||||||
github.com/google/go-querystring v1.1.0 // indirect
|
github.com/google/go-querystring v1.1.0 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/google/wire v0.7.0 // indirect
|
github.com/google/wire v0.7.0 // indirect
|
||||||
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 // indirect
|
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f // indirect
|
||||||
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect
|
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect
|
||||||
github.com/grafana/dataplane/sdata v0.0.9 // indirect
|
github.com/grafana/dataplane/sdata v0.0.9 // indirect
|
||||||
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 // indirect
|
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 // indirect
|
||||||
|
|||||||
@@ -619,8 +619,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
|||||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
||||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
||||||
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ=
|
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts=
|
||||||
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
|
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
|
||||||
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o=
|
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o=
|
||||||
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg=
|
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg=
|
||||||
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY=
|
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY=
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ go 1.25.5
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/go-kit/log v0.2.1
|
github.com/go-kit/log v0.2.1
|
||||||
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196
|
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f
|
||||||
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4
|
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4
|
||||||
github.com/grafana/grafana-app-sdk v0.48.7
|
github.com/grafana/grafana-app-sdk v0.48.7
|
||||||
github.com/grafana/grafana-app-sdk/logging v0.48.7
|
github.com/grafana/grafana-app-sdk/logging v0.48.7
|
||||||
|
|||||||
@@ -243,8 +243,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||||
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ=
|
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts=
|
||||||
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
|
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
|
||||||
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 h1:jSojuc7njleS3UOz223WDlXOinmuLAIPI0z2vtq8EgI=
|
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 h1:jSojuc7njleS3UOz223WDlXOinmuLAIPI0z2vtq8EgI=
|
||||||
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4/go.mod h1:VahT+GtfQIM+o8ht2StR6J9g+Ef+C2Vokh5uuSmOD/4=
|
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4/go.mod h1:VahT+GtfQIM+o8ht2StR6J9g+Ef+C2Vokh5uuSmOD/4=
|
||||||
github.com/grafana/grafana-app-sdk v0.48.7 h1:9mF7nqkqP0QUYYDlznoOt+GIyjzj45wGfUHB32u2ZMo=
|
github.com/grafana/grafana-app-sdk v0.48.7 h1:9mF7nqkqP0QUYYDlznoOt+GIyjzj45wGfUHB32u2ZMo=
|
||||||
|
|||||||
@@ -180,12 +180,15 @@ func countAnnotationsV0V1(spec map[string]interface{}) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
annotationList, ok := annotations["list"].([]interface{})
|
// Handle both []interface{} (from JSON unmarshaling) and []map[string]interface{} (from programmatic creation)
|
||||||
if !ok {
|
if annotationList, ok := annotations["list"].([]interface{}); ok {
|
||||||
return 0
|
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
|
// countLinksV0V1 counts dashboard links in v0alpha1 or v1beta1 dashboard spec
|
||||||
@@ -194,12 +197,15 @@ func countLinksV0V1(spec map[string]interface{}) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
links, ok := spec["links"].([]interface{})
|
// Handle both []interface{} (from JSON unmarshaling) and []map[string]interface{} (from programmatic creation)
|
||||||
if !ok {
|
if links, ok := spec["links"].([]interface{}); ok {
|
||||||
return 0
|
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
|
// countVariablesV0V1 counts template variables in v0alpha1 or v1beta1 dashboard spec
|
||||||
@@ -213,12 +219,15 @@ func countVariablesV0V1(spec map[string]interface{}) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
variableList, ok := templating["list"].([]interface{})
|
// Handle both []interface{} (from JSON unmarshaling) and []map[string]interface{} (from programmatic creation)
|
||||||
if !ok {
|
if variableList, ok := templating["list"].([]interface{}); ok {
|
||||||
return 0
|
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
|
// collectStatsV0V1 collects statistics from v0alpha1 or v1beta1 dashboard
|
||||||
|
|||||||
@@ -628,6 +628,20 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Only nulls and no user set min \u0026 max",
|
"title": "Only nulls and no user set min \u0026 max",
|
||||||
|
"transformations": [
|
||||||
|
{
|
||||||
|
"id": "convertFieldType",
|
||||||
|
"options": {
|
||||||
|
"conversions": [
|
||||||
|
{
|
||||||
|
"destinationType": "number",
|
||||||
|
"targetField": "A-series"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fields": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"type": "gauge"
|
"type": "gauge"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1760,6 +1760,22 @@
|
|||||||
"startValue": 0
|
"startValue": 0
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"transformations": [
|
||||||
|
{
|
||||||
|
"id": "calculateField",
|
||||||
|
"options": {
|
||||||
|
"mode": "unary",
|
||||||
|
"reduce": {
|
||||||
|
"reducer": "sum"
|
||||||
|
},
|
||||||
|
"replaceFields": true,
|
||||||
|
"unary": {
|
||||||
|
"operator": "round",
|
||||||
|
"fieldName": "A-series"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"title": "Active gateways",
|
"title": "Active gateways",
|
||||||
"type": "radialbar"
|
"type": "radialbar"
|
||||||
},
|
},
|
||||||
@@ -1843,6 +1859,22 @@
|
|||||||
"startValue": 0
|
"startValue": 0
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"transformations": [
|
||||||
|
{
|
||||||
|
"id": "calculateField",
|
||||||
|
"options": {
|
||||||
|
"mode": "unary",
|
||||||
|
"reduce": {
|
||||||
|
"reducer": "sum"
|
||||||
|
},
|
||||||
|
"replaceFields": true,
|
||||||
|
"unary": {
|
||||||
|
"operator": "round",
|
||||||
|
"fieldName": "A-series"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"title": "Active pods",
|
"title": "Active pods",
|
||||||
"type": "radialbar"
|
"type": "radialbar"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -485,6 +485,7 @@
|
|||||||
},
|
},
|
||||||
"id": 12,
|
"id": 12,
|
||||||
"options": {
|
"options": {
|
||||||
|
"displayName": "My gauge",
|
||||||
"minVizHeight": 75,
|
"minVizHeight": 75,
|
||||||
"minVizWidth": 75,
|
"minVizWidth": 75,
|
||||||
"orientation": "auto",
|
"orientation": "auto",
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ require (
|
|||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
|
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
|
||||||
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
|
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
|
||||||
github.com/gorilla/mux v1.8.1 // indirect
|
github.com/gorilla/mux v1.8.1 // indirect
|
||||||
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 // indirect
|
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f // indirect
|
||||||
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect
|
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect
|
||||||
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // indirect
|
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // indirect
|
||||||
github.com/grafana/dataplane/sdata v0.0.9 // indirect
|
github.com/grafana/dataplane/sdata v0.0.9 // indirect
|
||||||
|
|||||||
@@ -827,8 +827,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
|||||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
||||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
||||||
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ=
|
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts=
|
||||||
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
|
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
|
||||||
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o=
|
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o=
|
||||||
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg=
|
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg=
|
||||||
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY=
|
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY=
|
||||||
|
|||||||
20
apps/plugins/README.md
Normal file
20
apps/plugins/README.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Plugins App
|
||||||
|
|
||||||
|
API documentation is available at http://localhost:3000/swagger?api=plugins.grafana.app-v0alpha1
|
||||||
|
|
||||||
|
## Codegen
|
||||||
|
|
||||||
|
- Go: `make generate`
|
||||||
|
- Frontend: Follow instructions in this [README](../..//packages/grafana-api-clients/README.md)
|
||||||
|
|
||||||
|
## Plugin sync
|
||||||
|
|
||||||
|
The plugin sync pushes the plugins loaded from disk to the plugins API.
|
||||||
|
|
||||||
|
To enable, add these feature toggles in your `custom.ini`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[feature_toggles]
|
||||||
|
pluginInstallAPISync = true
|
||||||
|
pluginStoreServiceLoading = true
|
||||||
|
```
|
||||||
@@ -90,7 +90,7 @@ require (
|
|||||||
github.com/google/gnostic-models v0.7.1 // indirect
|
github.com/google/gnostic-models v0.7.1 // indirect
|
||||||
github.com/google/go-cmp v0.7.0 // indirect
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 // indirect
|
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f // indirect
|
||||||
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect
|
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect
|
||||||
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // indirect
|
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // indirect
|
||||||
github.com/grafana/dataplane/sdata v0.0.9 // indirect
|
github.com/grafana/dataplane/sdata v0.0.9 // indirect
|
||||||
|
|||||||
@@ -213,8 +213,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
||||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
||||||
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ=
|
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts=
|
||||||
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
|
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
|
||||||
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o=
|
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o=
|
||||||
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg=
|
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg=
|
||||||
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY=
|
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY=
|
||||||
|
|||||||
@@ -600,6 +600,20 @@
|
|||||||
"stringInput": "null,null"
|
"stringInput": "null,null"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"transformations": [
|
||||||
|
{
|
||||||
|
"id": "convertFieldType",
|
||||||
|
"options": {
|
||||||
|
"fields": {},
|
||||||
|
"conversions": [
|
||||||
|
{
|
||||||
|
"targetField": "A-series",
|
||||||
|
"destinationType": "number"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"title": "Only nulls and no user set min & max",
|
"title": "Only nulls and no user set min & max",
|
||||||
"type": "gauge"
|
"type": "gauge"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1718,6 +1718,22 @@
|
|||||||
"startValue": 0
|
"startValue": 0
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"transformations": [
|
||||||
|
{
|
||||||
|
"id": "calculateField",
|
||||||
|
"options": {
|
||||||
|
"mode": "unary",
|
||||||
|
"reduce": {
|
||||||
|
"reducer": "sum"
|
||||||
|
},
|
||||||
|
"replaceFields": true,
|
||||||
|
"unary": {
|
||||||
|
"operator": "round",
|
||||||
|
"fieldName": "A-series"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"title": "Active gateways",
|
"title": "Active gateways",
|
||||||
"type": "radialbar"
|
"type": "radialbar"
|
||||||
},
|
},
|
||||||
@@ -1799,6 +1815,22 @@
|
|||||||
"startValue": 0
|
"startValue": 0
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"transformations": [
|
||||||
|
{
|
||||||
|
"id": "calculateField",
|
||||||
|
"options": {
|
||||||
|
"mode": "unary",
|
||||||
|
"reduce": {
|
||||||
|
"reducer": "sum"
|
||||||
|
},
|
||||||
|
"replaceFields": true,
|
||||||
|
"unary": {
|
||||||
|
"operator": "round",
|
||||||
|
"fieldName": "A-series"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"title": "Active pods",
|
"title": "Active pods",
|
||||||
"type": "radialbar"
|
"type": "radialbar"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -474,6 +474,7 @@
|
|||||||
},
|
},
|
||||||
"id": 12,
|
"id": 12,
|
||||||
"options": {
|
"options": {
|
||||||
|
"displayName": "My gauge",
|
||||||
"minVizHeight": 75,
|
"minVizHeight": 75,
|
||||||
"minVizWidth": 75,
|
"minVizWidth": 75,
|
||||||
"orientation": "auto",
|
"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.
|
Pausing stops alert rule evaluation behavior for the newly created Grafana-managed alert rules.
|
||||||
|
|
||||||
9. (Optional) In the **Target data source** of the **Recording rules** section, you can select the data source that the imported recording rules will query. By default, it is the data source selected in the **Data source** dropdown.
|
9. (Optional) In the **Target data source** of the **Recording rules** section, you can select the data source to which the imported recording rules will write metrics. By default, it is the data source selected in the **Data source** dropdown.
|
||||||
|
|
||||||
10. Click **Import**.
|
10. Click **Import**.
|
||||||
|
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ You can share dashboards in the following ways:
|
|||||||
- [As a report](#schedule-a-report)
|
- [As a report](#schedule-a-report)
|
||||||
- [As a snapshot](#share-a-snapshot)
|
- [As a snapshot](#share-a-snapshot)
|
||||||
- [As a PDF export](#export-a-dashboard-as-pdf)
|
- [As a PDF export](#export-a-dashboard-as-pdf)
|
||||||
- [As a JSON file export](#export-a-dashboard-as-json)
|
- [As a JSON file export](#export-a-dashboard-as-code)
|
||||||
- [As an image export](#export-a-dashboard-as-an-image)
|
- [As an image export](#export-a-dashboard-as-an-image)
|
||||||
|
|
||||||
When you share a dashboard externally as a link or by email, those dashboards are included in a list of your shared dashboards. To view the list and manage these dashboards, navigate to **Dashboards > Shared dashboards**.
|
When you share a dashboard externally as a link or by email, those dashboards are included in a list of your shared dashboards. To view the list and manage these dashboards, navigate to **Dashboards > Shared dashboards**.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const NUM_NESTED_DASHBOARDS = 60;
|
|||||||
test.use({
|
test.use({
|
||||||
featureToggles: {
|
featureToggles: {
|
||||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import testDashboard from '../dashboards/TestDashboard.json';
|
|||||||
test.use({
|
test.use({
|
||||||
featureToggles: {
|
featureToggles: {
|
||||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ test.use({
|
|||||||
scenes: true,
|
scenes: true,
|
||||||
sharingDashboardImage: true, // Enable the export image feature
|
sharingDashboardImage: true, // Enable the export image feature
|
||||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { test, expect } from '@grafana/plugin-e2e';
|
|||||||
test.use({
|
test.use({
|
||||||
featureToggles: {
|
featureToggles: {
|
||||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { test, expect } from '@grafana/plugin-e2e';
|
|||||||
test.use({
|
test.use({
|
||||||
featureToggles: {
|
featureToggles: {
|
||||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import testDashboard from '../dashboards/DataLinkWithoutSlugTest.json';
|
|||||||
test.use({
|
test.use({
|
||||||
featureToggles: {
|
featureToggles: {
|
||||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import testDashboard from '../dashboards/DashboardLiveTest.json';
|
|||||||
test.use({
|
test.use({
|
||||||
featureToggles: {
|
featureToggles: {
|
||||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { test, expect } from '@grafana/plugin-e2e';
|
|||||||
test.use({
|
test.use({
|
||||||
featureToggles: {
|
featureToggles: {
|
||||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
dashboardScene: false, // this test is for the old sharing modal only used when scenes is turned off
|
dashboardScene: false, // this test is for the old sharing modal only used when scenes is turned off
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { test, expect } from '@grafana/plugin-e2e';
|
|||||||
test.use({
|
test.use({
|
||||||
featureToggles: {
|
featureToggles: {
|
||||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
dashboardScene: false, // this test is for the old sharing modal only used when scenes is turned off
|
dashboardScene: false, // this test is for the old sharing modal only used when scenes is turned off
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ test.use({
|
|||||||
featureToggles: {
|
featureToggles: {
|
||||||
scenes: true,
|
scenes: true,
|
||||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ test.use({
|
|||||||
featureToggles: {
|
featureToggles: {
|
||||||
scenes: true,
|
scenes: true,
|
||||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ test.use({
|
|||||||
featureToggles: {
|
featureToggles: {
|
||||||
scenes: true,
|
scenes: true,
|
||||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ test.use({
|
|||||||
timezoneId: 'Pacific/Easter',
|
timezoneId: 'Pacific/Easter',
|
||||||
featureToggles: {
|
featureToggles: {
|
||||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const TIMEZONE_DASHBOARD_UID = 'd41dbaa2-a39e-4536-ab2b-caca52f1a9c8';
|
|||||||
test.use({
|
test.use({
|
||||||
featureToggles: {
|
featureToggles: {
|
||||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ test.use({
|
|||||||
},
|
},
|
||||||
featureToggles: {
|
featureToggles: {
|
||||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { test, expect } from '@grafana/plugin-e2e';
|
|||||||
test.use({
|
test.use({
|
||||||
featureToggles: {
|
featureToggles: {
|
||||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const PAGE_UNDER_TEST = 'edediimbjhdz4b/a-tall-dashboard';
|
|||||||
test.use({
|
test.use({
|
||||||
featureToggles: {
|
featureToggles: {
|
||||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import testDashboard from '../dashboards/TestDashboard.json';
|
|||||||
test.use({
|
test.use({
|
||||||
featureToggles: {
|
featureToggles: {
|
||||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const PAGE_UNDER_TEST = '-Y-tnEDWk/templating-nested-template-variables';
|
|||||||
test.use({
|
test.use({
|
||||||
featureToggles: {
|
featureToggles: {
|
||||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const DASHBOARD_NAME = 'Test variable output';
|
|||||||
test.use({
|
test.use({
|
||||||
featureToggles: {
|
featureToggles: {
|
||||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ async function assertPreviewValues(
|
|||||||
test.use({
|
test.use({
|
||||||
featureToggles: {
|
featureToggles: {
|
||||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const DASHBOARD_NAME = 'Test variable output';
|
|||||||
test.use({
|
test.use({
|
||||||
featureToggles: {
|
featureToggles: {
|
||||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ async function assertPreviewValues(
|
|||||||
test.use({
|
test.use({
|
||||||
featureToggles: {
|
featureToggles: {
|
||||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const DASHBOARD_NAME = 'Templating - Nested Template Variables';
|
|||||||
test.use({
|
test.use({
|
||||||
featureToggles: {
|
featureToggles: {
|
||||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const DASHBOARD_NAME = 'Test variable output';
|
|||||||
test.use({
|
test.use({
|
||||||
featureToggles: {
|
featureToggles: {
|
||||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const PAGE_UNDER_TEST = 'WVpf2jp7z/repeating-a-panel-horizontally';
|
|||||||
test.use({
|
test.use({
|
||||||
featureToggles: {
|
featureToggles: {
|
||||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const PAGE_UNDER_TEST = 'OY8Ghjt7k/repeating-a-panel-vertically';
|
|||||||
test.use({
|
test.use({
|
||||||
featureToggles: {
|
featureToggles: {
|
||||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const PAGE_UNDER_TEST = 'dtpl2Ctnk/repeating-an-empty-row';
|
|||||||
test.use({
|
test.use({
|
||||||
featureToggles: {
|
featureToggles: {
|
||||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const PAGE_UNDER_TEST = '-Y-tnEDWk/templating-nested-template-variables';
|
|||||||
test.use({
|
test.use({
|
||||||
featureToggles: {
|
featureToggles: {
|
||||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const DASHBOARD_UID = 'ZqZnVvFZz';
|
|||||||
test.use({
|
test.use({
|
||||||
featureToggles: {
|
featureToggles: {
|
||||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
dashboardScene: false, // this test is for the old sharing modal only used when scenes is turned off
|
dashboardScene: false, // this test is for the old sharing modal only used when scenes is turned off
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const DASHBOARD_UID = 'yBCC3aKGk';
|
|||||||
test.use({
|
test.use({
|
||||||
featureToggles: {
|
featureToggles: {
|
||||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const PAGE_UNDER_TEST = 'AejrN1AMz';
|
|||||||
test.use({
|
test.use({
|
||||||
featureToggles: {
|
featureToggles: {
|
||||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,18 +2,16 @@ import { Locator } from '@playwright/test';
|
|||||||
|
|
||||||
import { test, expect } from '@grafana/plugin-e2e';
|
import { test, expect } from '@grafana/plugin-e2e';
|
||||||
|
|
||||||
import { setVisualization } from './vizpicker-utils';
|
|
||||||
|
|
||||||
test.use({
|
test.use({
|
||||||
featureToggles: {
|
featureToggles: {
|
||||||
canvasPanelPanZoom: true,
|
canvasPanelPanZoom: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
test.describe('Canvas Panel - Scene Tests', () => {
|
test.describe('Canvas Panel - Scene Tests', () => {
|
||||||
test.beforeEach(async ({ page, gotoDashboardPage, selectors }) => {
|
test.beforeEach(async ({ page, gotoDashboardPage }) => {
|
||||||
const dashboardPage = await gotoDashboardPage({});
|
const dashboardPage = await gotoDashboardPage({});
|
||||||
const panelEditPage = await dashboardPage.addPanel();
|
const panelEditPage = await dashboardPage.addPanel();
|
||||||
await setVisualization(panelEditPage, 'Canvas', selectors);
|
await panelEditPage.setVisualization('Canvas');
|
||||||
|
|
||||||
// Wait for canvas panel to load
|
// Wait for canvas panel to load
|
||||||
await page.waitForSelector('[data-testid="canvas-scene-pan-zoom"]', { timeout: 10000 });
|
await page.waitForSelector('[data-testid="canvas-scene-pan-zoom"]', { timeout: 10000 });
|
||||||
|
|||||||
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,24 +0,0 @@
|
|||||||
import { expect, E2ESelectorGroups, PanelEditPage } from '@grafana/plugin-e2e';
|
|
||||||
|
|
||||||
// this replaces the panelEditPage.setVisualization method used previously in tests, since it
|
|
||||||
// does not know how to use the updated 12.4 viz picker UI to set the visualization
|
|
||||||
export const setVisualization = async (panelEditPage: PanelEditPage, vizName: string, selectors: E2ESelectorGroups) => {
|
|
||||||
const vizPicker = panelEditPage.getByGrafanaSelector(selectors.components.PanelEditor.toggleVizPicker);
|
|
||||||
await expect(vizPicker, '"Change" button should be visible').toBeVisible();
|
|
||||||
await vizPicker.click();
|
|
||||||
|
|
||||||
const allVizTabBtn = panelEditPage.getByGrafanaSelector(selectors.components.Tab.title('All visualizations'));
|
|
||||||
await expect(allVizTabBtn, '"All visualiations" button should be visible').toBeVisible();
|
|
||||||
await allVizTabBtn.click();
|
|
||||||
|
|
||||||
const vizItem = panelEditPage.getByGrafanaSelector(selectors.components.PluginVisualization.item(vizName));
|
|
||||||
await expect(vizItem, `"${vizName}" item should be visible`).toBeVisible();
|
|
||||||
await vizItem.scrollIntoViewIfNeeded();
|
|
||||||
await vizItem.click();
|
|
||||||
|
|
||||||
await expect(vizPicker, '"Change" button should be visible again').toBeVisible();
|
|
||||||
await expect(
|
|
||||||
panelEditPage.getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.header),
|
|
||||||
'Panel header should have the new viz type name'
|
|
||||||
).toHaveText(vizName);
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { expect, test } from '@grafana/plugin-e2e';
|
import { expect, test } from '@grafana/plugin-e2e';
|
||||||
|
|
||||||
import { setVisualization } from '../../../panels-suite/vizpicker-utils';
|
|
||||||
import { formatExpectError } from '../errors';
|
import { formatExpectError } from '../errors';
|
||||||
import { successfulDataQuery } from '../mocks/queries';
|
import { successfulDataQuery } from '../mocks/queries';
|
||||||
|
|
||||||
@@ -25,10 +24,10 @@ test.describe(
|
|||||||
).toContainText(['Field', 'Max', 'Mean', 'Last']);
|
).toContainText(['Field', 'Max', 'Mean', 'Last']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('table panel data assertions', async ({ panelEditPage, selectors }) => {
|
test('table panel data assertions', async ({ panelEditPage }) => {
|
||||||
await panelEditPage.mockQueryDataResponse(successfulDataQuery, 200);
|
await panelEditPage.mockQueryDataResponse(successfulDataQuery, 200);
|
||||||
await panelEditPage.datasource.set('gdev-testdata');
|
await panelEditPage.datasource.set('gdev-testdata');
|
||||||
await setVisualization(panelEditPage, 'Table', selectors);
|
await panelEditPage.setVisualization('Table');
|
||||||
await panelEditPage.refreshPanel();
|
await panelEditPage.refreshPanel();
|
||||||
await expect(
|
await expect(
|
||||||
panelEditPage.panel.locator,
|
panelEditPage.panel.locator,
|
||||||
@@ -44,10 +43,10 @@ test.describe(
|
|||||||
).toContainText(['val1', 'val2', 'val3', 'val4']);
|
).toContainText(['val1', 'val2', 'val3', 'val4']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('timeseries panel - table view assertions', async ({ panelEditPage, selectors }) => {
|
test('timeseries panel - table view assertions', async ({ panelEditPage }) => {
|
||||||
await panelEditPage.mockQueryDataResponse(successfulDataQuery, 200);
|
await panelEditPage.mockQueryDataResponse(successfulDataQuery, 200);
|
||||||
await panelEditPage.datasource.set('gdev-testdata');
|
await panelEditPage.datasource.set('gdev-testdata');
|
||||||
await setVisualization(panelEditPage, 'Time series', selectors);
|
await panelEditPage.setVisualization('Time series');
|
||||||
await panelEditPage.refreshPanel();
|
await panelEditPage.refreshPanel();
|
||||||
await panelEditPage.toggleTableView();
|
await panelEditPage.toggleTableView();
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { expect, test } from '@grafana/plugin-e2e';
|
import { expect, test } from '@grafana/plugin-e2e';
|
||||||
|
|
||||||
import { setVisualization } from '../../../panels-suite/vizpicker-utils';
|
|
||||||
import { formatExpectError } from '../errors';
|
import { formatExpectError } from '../errors';
|
||||||
import { successfulDataQuery } from '../mocks/queries';
|
import { successfulDataQuery } from '../mocks/queries';
|
||||||
import { scenarios } from '../mocks/resources';
|
import { scenarios } from '../mocks/resources';
|
||||||
@@ -54,10 +53,10 @@ test.describe(
|
|||||||
).toHaveText(scenarios.map((s) => s.name));
|
).toHaveText(scenarios.map((s) => s.name));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('mocked query data response', async ({ panelEditPage, page, selectors }) => {
|
test('mocked query data response', async ({ panelEditPage, page }) => {
|
||||||
await panelEditPage.mockQueryDataResponse(successfulDataQuery, 200);
|
await panelEditPage.mockQueryDataResponse(successfulDataQuery, 200);
|
||||||
await panelEditPage.datasource.set('gdev-testdata');
|
await panelEditPage.datasource.set('gdev-testdata');
|
||||||
await setVisualization(panelEditPage, TABLE_VIZ_NAME, selectors);
|
await panelEditPage.setVisualization(TABLE_VIZ_NAME);
|
||||||
await panelEditPage.refreshPanel();
|
await panelEditPage.refreshPanel();
|
||||||
await expect(
|
await expect(
|
||||||
panelEditPage.panel.getErrorIcon(),
|
panelEditPage.panel.getErrorIcon(),
|
||||||
@@ -76,7 +75,7 @@ test.describe(
|
|||||||
selectors,
|
selectors,
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await setVisualization(panelEditPage, TABLE_VIZ_NAME, selectors);
|
await panelEditPage.setVisualization(TABLE_VIZ_NAME);
|
||||||
await expect(
|
await expect(
|
||||||
panelEditPage.getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.header),
|
panelEditPage.getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.header),
|
||||||
formatExpectError('Expected panel visualization to be set to table')
|
formatExpectError('Expected panel visualization to be set to table')
|
||||||
@@ -93,8 +92,8 @@ test.describe(
|
|||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Select time zone in timezone picker', async ({ panelEditPage, selectors }) => {
|
test('Select time zone in timezone picker', async ({ panelEditPage }) => {
|
||||||
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
|
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
|
||||||
const axisOptions = await panelEditPage.getCustomOptions('Axis');
|
const axisOptions = await panelEditPage.getCustomOptions('Axis');
|
||||||
const timeZonePicker = axisOptions.getSelect('Time zone');
|
const timeZonePicker = axisOptions.getSelect('Time zone');
|
||||||
|
|
||||||
@@ -102,8 +101,8 @@ test.describe(
|
|||||||
await expect(timeZonePicker).toHaveSelected('Europe/Stockholm');
|
await expect(timeZonePicker).toHaveSelected('Europe/Stockholm');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('select unit in unit picker', async ({ panelEditPage, selectors }) => {
|
test('select unit in unit picker', async ({ panelEditPage }) => {
|
||||||
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
|
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
|
||||||
const standardOptions = panelEditPage.getStandardOptions();
|
const standardOptions = panelEditPage.getStandardOptions();
|
||||||
const unitPicker = standardOptions.getUnitPicker('Unit');
|
const unitPicker = standardOptions.getUnitPicker('Unit');
|
||||||
|
|
||||||
@@ -112,8 +111,8 @@ test.describe(
|
|||||||
await expect(unitPicker).toHaveSelected('Pixels');
|
await expect(unitPicker).toHaveSelected('Pixels');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('enter value in number input', async ({ panelEditPage, selectors }) => {
|
test('enter value in number input', async ({ panelEditPage }) => {
|
||||||
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
|
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
|
||||||
const axisOptions = panelEditPage.getCustomOptions('Axis');
|
const axisOptions = panelEditPage.getCustomOptions('Axis');
|
||||||
const lineWith = axisOptions.getNumberInput('Soft min');
|
const lineWith = axisOptions.getNumberInput('Soft min');
|
||||||
|
|
||||||
@@ -122,8 +121,8 @@ test.describe(
|
|||||||
await expect(lineWith).toHaveValue('10');
|
await expect(lineWith).toHaveValue('10');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('enter value in slider', async ({ panelEditPage, selectors }) => {
|
test('enter value in slider', async ({ panelEditPage }) => {
|
||||||
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
|
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
|
||||||
const graphOptions = panelEditPage.getCustomOptions('Graph styles');
|
const graphOptions = panelEditPage.getCustomOptions('Graph styles');
|
||||||
const lineWidth = graphOptions.getSliderInput('Line width');
|
const lineWidth = graphOptions.getSliderInput('Line width');
|
||||||
|
|
||||||
@@ -132,8 +131,8 @@ test.describe(
|
|||||||
await expect(lineWidth).toHaveValue('10');
|
await expect(lineWidth).toHaveValue('10');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('select value in single value select', async ({ panelEditPage, selectors }) => {
|
test('select value in single value select', async ({ panelEditPage }) => {
|
||||||
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
|
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
|
||||||
const standardOptions = panelEditPage.getStandardOptions();
|
const standardOptions = panelEditPage.getStandardOptions();
|
||||||
const colorSchemeSelect = standardOptions.getSelect('Color scheme');
|
const colorSchemeSelect = standardOptions.getSelect('Color scheme');
|
||||||
|
|
||||||
@@ -141,8 +140,8 @@ test.describe(
|
|||||||
await expect(colorSchemeSelect).toHaveSelected('Classic palette');
|
await expect(colorSchemeSelect).toHaveSelected('Classic palette');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('clear input', async ({ panelEditPage, selectors }) => {
|
test('clear input', async ({ panelEditPage }) => {
|
||||||
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
|
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
|
||||||
const panelOptions = panelEditPage.getPanelOptions();
|
const panelOptions = panelEditPage.getPanelOptions();
|
||||||
const title = panelOptions.getTextInput('Title');
|
const title = panelOptions.getTextInput('Title');
|
||||||
|
|
||||||
@@ -151,8 +150,8 @@ test.describe(
|
|||||||
await expect(title).toHaveValue('');
|
await expect(title).toHaveValue('');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('enter value in input', async ({ panelEditPage, selectors }) => {
|
test('enter value in input', async ({ panelEditPage }) => {
|
||||||
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
|
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
|
||||||
const panelOptions = panelEditPage.getPanelOptions();
|
const panelOptions = panelEditPage.getPanelOptions();
|
||||||
const description = panelOptions.getTextInput('Description');
|
const description = panelOptions.getTextInput('Description');
|
||||||
|
|
||||||
@@ -161,8 +160,8 @@ test.describe(
|
|||||||
await expect(description).toHaveValue('This is a panel');
|
await expect(description).toHaveValue('This is a panel');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('unchecking switch', async ({ panelEditPage, selectors }) => {
|
test('unchecking switch', async ({ panelEditPage }) => {
|
||||||
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
|
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
|
||||||
const axisOptions = panelEditPage.getCustomOptions('Axis');
|
const axisOptions = panelEditPage.getCustomOptions('Axis');
|
||||||
const showBorder = axisOptions.getSwitch('Show border');
|
const showBorder = axisOptions.getSwitch('Show border');
|
||||||
|
|
||||||
@@ -174,8 +173,8 @@ test.describe(
|
|||||||
await expect(showBorder).toBeChecked({ checked: false });
|
await expect(showBorder).toBeChecked({ checked: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('checking switch', async ({ panelEditPage, selectors }) => {
|
test('checking switch', async ({ panelEditPage }) => {
|
||||||
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
|
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
|
||||||
const axisOptions = panelEditPage.getCustomOptions('Axis');
|
const axisOptions = panelEditPage.getCustomOptions('Axis');
|
||||||
const showBorder = axisOptions.getSwitch('Show border');
|
const showBorder = axisOptions.getSwitch('Show border');
|
||||||
|
|
||||||
@@ -184,8 +183,8 @@ test.describe(
|
|||||||
await expect(showBorder).toBeChecked();
|
await expect(showBorder).toBeChecked();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('re-selecting value in radio button group', async ({ panelEditPage, selectors }) => {
|
test('re-selecting value in radio button group', async ({ panelEditPage }) => {
|
||||||
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
|
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
|
||||||
const axisOptions = panelEditPage.getCustomOptions('Axis');
|
const axisOptions = panelEditPage.getCustomOptions('Axis');
|
||||||
const placement = axisOptions.getRadioGroup('Placement');
|
const placement = axisOptions.getRadioGroup('Placement');
|
||||||
|
|
||||||
@@ -196,8 +195,8 @@ test.describe(
|
|||||||
await expect(placement).toHaveChecked('Auto');
|
await expect(placement).toHaveChecked('Auto');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('selecting value in radio button group', async ({ panelEditPage, selectors }) => {
|
test('selecting value in radio button group', async ({ panelEditPage }) => {
|
||||||
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
|
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
|
||||||
const axisOptions = panelEditPage.getCustomOptions('Axis');
|
const axisOptions = panelEditPage.getCustomOptions('Axis');
|
||||||
const placement = axisOptions.getRadioGroup('Placement');
|
const placement = axisOptions.getRadioGroup('Placement');
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { BootData } from '@grafana/data';
|
import { BootData, PanelPluginMeta } from '@grafana/data';
|
||||||
import { test, expect } from '@grafana/plugin-e2e';
|
import { test, expect } from '@grafana/plugin-e2e';
|
||||||
|
|
||||||
test.describe(
|
test.describe(
|
||||||
@@ -22,7 +22,7 @@ test.describe(
|
|||||||
await dashboardPage.addPanel();
|
await dashboardPage.addPanel();
|
||||||
|
|
||||||
// Get panel types from window object
|
// Get panel types from window object
|
||||||
const panelTypes = await page.evaluate(() => {
|
const panelTypes: PanelPluginMeta[] = await page.evaluate(() => {
|
||||||
// @grafana/plugin-e2e doesn't export the full bootdata config
|
// @grafana/plugin-e2e doesn't export the full bootdata config
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
const win = window as typeof window & { grafanaBootData: BootData };
|
const win = window as typeof window & { grafanaBootData: BootData };
|
||||||
|
|||||||
@@ -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(
|
test.describe(
|
||||||
'Visualization suggestions',
|
'Visualization suggestions',
|
||||||
{
|
{
|
||||||
tag: ['@various'],
|
tag: ['@various', '@suggestions'],
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
test('Should be shown and clickable', async ({ page, selectors, gotoPanelEditPage }) => {
|
test('Should be shown and clickable', async ({ page, selectors, gotoPanelEditPage }) => {
|
||||||
|
|||||||
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/googleapis/gax-go/v2 v2.15.0 // @grafana/grafana-backend-group
|
||||||
github.com/gorilla/mux v1.8.1 // @grafana/grafana-backend-group
|
github.com/gorilla/mux v1.8.1 // @grafana/grafana-backend-group
|
||||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // @grafana/grafana-app-platform-squad
|
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // @grafana/grafana-app-platform-squad
|
||||||
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 // @grafana/alerting-backend
|
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f // @grafana/alerting-backend
|
||||||
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // @grafana/identity-access-team
|
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // @grafana/identity-access-team
|
||||||
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // @grafana/identity-access-team
|
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // @grafana/identity-access-team
|
||||||
github.com/grafana/dataplane/examples v0.0.1 // @grafana/observability-metrics
|
github.com/grafana/dataplane/examples v0.0.1 // @grafana/observability-metrics
|
||||||
@@ -181,6 +181,7 @@ require (
|
|||||||
github.com/xlab/treeprint v1.2.0 // @grafana/observability-traces-and-profiling
|
github.com/xlab/treeprint v1.2.0 // @grafana/observability-traces-and-profiling
|
||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // @grafana/grafana-operator-experience-squad
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // @grafana/grafana-operator-experience-squad
|
||||||
github.com/yudai/gojsondiff v1.0.0 // @grafana/grafana-backend-group
|
github.com/yudai/gojsondiff v1.0.0 // @grafana/grafana-backend-group
|
||||||
|
go.etcd.io/bbolt v1.4.2 // @grafana/grafana-search-and-storage
|
||||||
go.opentelemetry.io/collector/pdata v1.44.0 // @grafana/grafana-backend-group
|
go.opentelemetry.io/collector/pdata v1.44.0 // @grafana/grafana-backend-group
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 // @grafana/plugins-platform-backend
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 // @grafana/plugins-platform-backend
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.63.0 // @grafana/grafana-operator-experience-squad
|
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.63.0 // @grafana/grafana-operator-experience-squad
|
||||||
@@ -603,7 +604,6 @@ require (
|
|||||||
github.com/yuin/gopher-lua v1.1.1 // indirect
|
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||||
github.com/zclconf/go-cty v1.16.3 // indirect
|
github.com/zclconf/go-cty v1.16.3 // indirect
|
||||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||||
go.etcd.io/bbolt v1.4.2 // indirect
|
|
||||||
go.etcd.io/etcd/api/v3 v3.6.6 // indirect
|
go.etcd.io/etcd/api/v3 v3.6.6 // indirect
|
||||||
go.etcd.io/etcd/client/pkg/v3 v3.6.6 // indirect
|
go.etcd.io/etcd/client/pkg/v3 v3.6.6 // indirect
|
||||||
go.etcd.io/etcd/client/v3 v3.6.6 // indirect
|
go.etcd.io/etcd/client/v3 v3.6.6 // indirect
|
||||||
|
|||||||
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/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
||||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
||||||
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ=
|
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts=
|
||||||
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
|
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
|
||||||
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o=
|
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o=
|
||||||
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg=
|
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg=
|
||||||
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY=
|
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY=
|
||||||
|
|||||||
@@ -285,6 +285,10 @@ const injectedRtkApi = api
|
|||||||
query: (queryArg) => ({ url: `/snapshots/delete/${queryArg.deleteKey}`, method: 'DELETE' }),
|
query: (queryArg) => ({ url: `/snapshots/delete/${queryArg.deleteKey}`, method: 'DELETE' }),
|
||||||
invalidatesTags: ['Snapshot'],
|
invalidatesTags: ['Snapshot'],
|
||||||
}),
|
}),
|
||||||
|
getSnapshotSettings: build.query<GetSnapshotSettingsApiResponse, GetSnapshotSettingsApiArg>({
|
||||||
|
query: () => ({ url: `/snapshots/settings` }),
|
||||||
|
providesTags: ['Snapshot'],
|
||||||
|
}),
|
||||||
getSnapshot: build.query<GetSnapshotApiResponse, GetSnapshotApiArg>({
|
getSnapshot: build.query<GetSnapshotApiResponse, GetSnapshotApiArg>({
|
||||||
query: (queryArg) => ({
|
query: (queryArg) => ({
|
||||||
url: `/snapshots/${queryArg.name}`,
|
url: `/snapshots/${queryArg.name}`,
|
||||||
@@ -742,6 +746,8 @@ export type DeleteWithKeyApiArg = {
|
|||||||
/** unique key returned in create */
|
/** unique key returned in create */
|
||||||
deleteKey: string;
|
deleteKey: string;
|
||||||
};
|
};
|
||||||
|
export type GetSnapshotSettingsApiResponse = /** status 200 undefined */ any;
|
||||||
|
export type GetSnapshotSettingsApiArg = void;
|
||||||
export type GetSnapshotApiResponse = /** status 200 OK */ Snapshot;
|
export type GetSnapshotApiResponse = /** status 200 OK */ Snapshot;
|
||||||
export type GetSnapshotApiArg = {
|
export type GetSnapshotApiArg = {
|
||||||
/** name of the Snapshot */
|
/** name of the Snapshot */
|
||||||
@@ -1273,6 +1279,8 @@ export const {
|
|||||||
useLazyListSnapshotQuery,
|
useLazyListSnapshotQuery,
|
||||||
useCreateSnapshotMutation,
|
useCreateSnapshotMutation,
|
||||||
useDeleteWithKeyMutation,
|
useDeleteWithKeyMutation,
|
||||||
|
useGetSnapshotSettingsQuery,
|
||||||
|
useLazyGetSnapshotSettingsQuery,
|
||||||
useGetSnapshotQuery,
|
useGetSnapshotQuery,
|
||||||
useLazyGetSnapshotQuery,
|
useLazyGetSnapshotQuery,
|
||||||
useDeleteSnapshotMutation,
|
useDeleteSnapshotMutation,
|
||||||
|
|||||||
@@ -356,10 +356,6 @@ export interface FeatureToggles {
|
|||||||
*/
|
*/
|
||||||
dashboardNewLayouts?: boolean;
|
dashboardNewLayouts?: boolean;
|
||||||
/**
|
/**
|
||||||
* Use the v2 kubernetes API in the frontend for dashboards
|
|
||||||
*/
|
|
||||||
kubernetesDashboardsV2?: boolean;
|
|
||||||
/**
|
|
||||||
* Enables undo/redo in dynamic dashboards
|
* Enables undo/redo in dynamic dashboards
|
||||||
*/
|
*/
|
||||||
dashboardUndoRedo?: boolean;
|
dashboardUndoRedo?: boolean;
|
||||||
@@ -421,6 +417,10 @@ export interface FeatureToggles {
|
|||||||
*/
|
*/
|
||||||
jitterAlertRulesWithinGroups?: boolean;
|
jitterAlertRulesWithinGroups?: boolean;
|
||||||
/**
|
/**
|
||||||
|
* Enable audit logging with Kubernetes under app platform
|
||||||
|
*/
|
||||||
|
auditLoggingAppPlatform?: boolean;
|
||||||
|
/**
|
||||||
* Enable the secrets management API and services under app platform
|
* Enable the secrets management API and services under app platform
|
||||||
*/
|
*/
|
||||||
secretsManagementAppPlatform?: boolean;
|
secretsManagementAppPlatform?: boolean;
|
||||||
|
|||||||
@@ -535,6 +535,11 @@ export const versionedComponents = {
|
|||||||
'12.3.0': 'data-testid viz-tooltip-wrapper',
|
'12.3.0': 'data-testid viz-tooltip-wrapper',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Gauge: {
|
||||||
|
Container: {
|
||||||
|
'12.4.0': 'data-testid gauge container',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
VizLegend: {
|
VizLegend: {
|
||||||
@@ -1288,6 +1293,9 @@ export const versionedComponents = {
|
|||||||
card: {
|
card: {
|
||||||
[MIN_GRAFANA_VERSION]: (name: string) => `data-testid suggestion-${name}`,
|
[MIN_GRAFANA_VERSION]: (name: string) => `data-testid suggestion-${name}`,
|
||||||
},
|
},
|
||||||
|
confirm: {
|
||||||
|
'12.4.0': (name: string) => `data-testid suggestion-${name} confirm button`,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
ColorSwatch: {
|
ColorSwatch: {
|
||||||
name: {
|
name: {
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ describe('MetricsModal', () => {
|
|||||||
operations: [],
|
operations: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
setup(query, ['with-labels'], true);
|
setup(query, ['with-labels']);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('with-labels')).toBeInTheDocument();
|
expect(screen.getByText('with-labels')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -220,6 +220,10 @@ function createDatasource(withLabels?: boolean) {
|
|||||||
// display different results if their labels are selected in the PromVisualQuery
|
// display different results if their labels are selected in the PromVisualQuery
|
||||||
if (withLabels) {
|
if (withLabels) {
|
||||||
languageProvider.queryMetricsMetadata = jest.fn().mockResolvedValue({
|
languageProvider.queryMetricsMetadata = jest.fn().mockResolvedValue({
|
||||||
|
ALERTS: {
|
||||||
|
type: 'gauge',
|
||||||
|
help: 'alerts help text',
|
||||||
|
},
|
||||||
'with-labels': {
|
'with-labels': {
|
||||||
type: 'with-labels-type',
|
type: 'with-labels-type',
|
||||||
help: 'with-labels-help',
|
help: 'with-labels-help',
|
||||||
@@ -297,7 +301,7 @@ function createProps(query: PromVisualQuery, datasource: PrometheusDatasource, m
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function setup(query: PromVisualQuery, metrics: string[], withlabels?: boolean) {
|
function setup(query: PromVisualQuery, metrics: string[]) {
|
||||||
const withLabels: boolean = query.labels.length > 0;
|
const withLabels: boolean = query.labels.length > 0;
|
||||||
const datasource = createDatasource(withLabels);
|
const datasource = createDatasource(withLabels);
|
||||||
const props = createProps(query, datasource, metrics);
|
const props = createProps(query, datasource, metrics);
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ const MetricsModalContent = (props: MetricsModalProps) => {
|
|||||||
|
|
||||||
export const MetricsModal = (props: MetricsModalProps) => {
|
export const MetricsModal = (props: MetricsModalProps) => {
|
||||||
return (
|
return (
|
||||||
<MetricsModalContextProvider languageProvider={props.datasource.languageProvider}>
|
<MetricsModalContextProvider languageProvider={props.datasource.languageProvider} timeRange={props.timeRange}>
|
||||||
<MetricsModalContent {...props} />
|
<MetricsModalContent {...props} />
|
||||||
</MetricsModalContextProvider>
|
</MetricsModalContextProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ReactNode } from 'react';
|
|||||||
import { TimeRange } from '@grafana/data';
|
import { TimeRange } from '@grafana/data';
|
||||||
|
|
||||||
import { PrometheusLanguageProviderInterface } from '../../../language_provider';
|
import { PrometheusLanguageProviderInterface } from '../../../language_provider';
|
||||||
|
import { getMockTimeRange } from '../../../test/mocks/datasource';
|
||||||
|
|
||||||
import { DEFAULT_RESULTS_PER_PAGE, MetricsModalContextProvider, useMetricsModal } from './MetricsModalContext';
|
import { DEFAULT_RESULTS_PER_PAGE, MetricsModalContextProvider, useMetricsModal } from './MetricsModalContext';
|
||||||
import { generateMetricData } from './helpers';
|
import { generateMetricData } from './helpers';
|
||||||
@@ -25,7 +26,9 @@ const mockLanguageProvider: PrometheusLanguageProviderInterface = {
|
|||||||
// Helper to create wrapper component
|
// Helper to create wrapper component
|
||||||
const createWrapper = (languageProvider = mockLanguageProvider) => {
|
const createWrapper = (languageProvider = mockLanguageProvider) => {
|
||||||
return ({ children }: { children: ReactNode }) => (
|
return ({ children }: { children: ReactNode }) => (
|
||||||
<MetricsModalContextProvider languageProvider={languageProvider}>{children}</MetricsModalContextProvider>
|
<MetricsModalContextProvider languageProvider={languageProvider} timeRange={getMockTimeRange()}>
|
||||||
|
{children}
|
||||||
|
</MetricsModalContextProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -167,6 +170,7 @@ describe('MetricsModalContext', () => {
|
|||||||
|
|
||||||
it('should handle empty metadata response', async () => {
|
it('should handle empty metadata response', async () => {
|
||||||
(mockLanguageProvider.queryMetricsMetadata as jest.Mock).mockResolvedValue({});
|
(mockLanguageProvider.queryMetricsMetadata as jest.Mock).mockResolvedValue({});
|
||||||
|
(mockLanguageProvider.queryLabelValues as jest.Mock).mockResolvedValue(['metric1', 'metric2']);
|
||||||
|
|
||||||
const { result } = renderHook(() => useMetricsModal(), {
|
const { result } = renderHook(() => useMetricsModal(), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
@@ -176,7 +180,18 @@ describe('MetricsModalContext', () => {
|
|||||||
expect(result.current.isLoading).toBe(false);
|
expect(result.current.isLoading).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.filteredMetricsData).toEqual([]);
|
expect(result.current.filteredMetricsData).toEqual([
|
||||||
|
{
|
||||||
|
value: 'metric1',
|
||||||
|
type: 'counter',
|
||||||
|
description: 'Test metric',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'metric2',
|
||||||
|
type: 'counter',
|
||||||
|
description: 'Test metric',
|
||||||
|
},
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle metadata fetch error', async () => {
|
it('should handle metadata fetch error', async () => {
|
||||||
@@ -239,6 +254,7 @@ describe('MetricsModalContext', () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
(mockLanguageProvider.queryMetricsMetadata as jest.Mock).mockResolvedValue({
|
(mockLanguageProvider.queryMetricsMetadata as jest.Mock).mockResolvedValue({
|
||||||
|
ALERTS: { type: 'gauge', help: 'Test alerts help' },
|
||||||
test_metric: { type: 'counter', help: 'Test metric' },
|
test_metric: { type: 'counter', help: 'Test metric' },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -250,7 +266,7 @@ describe('MetricsModalContext', () => {
|
|||||||
expect(result.current.isLoading).toBe(false);
|
expect(result.current.isLoading).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.filteredMetricsData).toHaveLength(1);
|
expect(result.current.filteredMetricsData).toHaveLength(2);
|
||||||
expect(result.current.selectedTypes).toEqual([]);
|
expect(result.current.selectedTypes).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -318,7 +334,7 @@ describe('MetricsModalContext', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const { getByTestId } = render(
|
const { getByTestId } = render(
|
||||||
<MetricsModalContextProvider languageProvider={mockLanguageProvider}>
|
<MetricsModalContextProvider languageProvider={mockLanguageProvider} timeRange={getMockTimeRange()}>
|
||||||
<TestComponent />
|
<TestComponent />
|
||||||
</MetricsModalContextProvider>
|
</MetricsModalContextProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -52,11 +52,13 @@ const MetricsModalContext = createContext<MetricsModalContextValue | undefined>(
|
|||||||
|
|
||||||
type MetricsModalContextProviderProps = {
|
type MetricsModalContextProviderProps = {
|
||||||
languageProvider: PrometheusLanguageProviderInterface;
|
languageProvider: PrometheusLanguageProviderInterface;
|
||||||
|
timeRange: TimeRange;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MetricsModalContextProvider: FC<PropsWithChildren<MetricsModalContextProviderProps>> = ({
|
export const MetricsModalContextProvider: FC<PropsWithChildren<MetricsModalContextProviderProps>> = ({
|
||||||
children,
|
children,
|
||||||
languageProvider,
|
languageProvider,
|
||||||
|
timeRange,
|
||||||
}) => {
|
}) => {
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [metricsData, setMetricsData] = useState<MetricsData>([]);
|
const [metricsData, setMetricsData] = useState<MetricsData>([]);
|
||||||
@@ -111,8 +113,16 @@ export const MetricsModalContextProvider: FC<PropsWithChildren<MetricsModalConte
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const metadata = await languageProvider.queryMetricsMetadata(PROMETHEUS_QUERY_BUILDER_MAX_RESULTS);
|
const metadata = await languageProvider.queryMetricsMetadata(PROMETHEUS_QUERY_BUILDER_MAX_RESULTS);
|
||||||
|
|
||||||
if (Object.keys(metadata).length === 0) {
|
// We receive ALERTS metadata in any case
|
||||||
setMetricsData([]);
|
if (Object.keys(metadata).length <= 1) {
|
||||||
|
const fetchedMetrics = await languageProvider.queryLabelValues(
|
||||||
|
timeRange,
|
||||||
|
METRIC_LABEL,
|
||||||
|
undefined,
|
||||||
|
PROMETHEUS_QUERY_BUILDER_MAX_RESULTS
|
||||||
|
);
|
||||||
|
const processedData = fetchedMetrics.map((m) => generateMetricData(m, languageProvider));
|
||||||
|
setMetricsData(processedData);
|
||||||
} else {
|
} else {
|
||||||
const processedData = Object.keys(metadata).map((m) => generateMetricData(m, languageProvider));
|
const processedData = Object.keys(metadata).map((m) => generateMetricData(m, languageProvider));
|
||||||
setMetricsData(processedData);
|
setMetricsData(processedData);
|
||||||
@@ -122,7 +132,7 @@ export const MetricsModalContextProvider: FC<PropsWithChildren<MetricsModalConte
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [languageProvider]);
|
}, [languageProvider, timeRange]);
|
||||||
|
|
||||||
const debouncedBackendSearch = useMemo(
|
const debouncedBackendSearch = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export interface Options extends common.SingleStatBaseOptions {
|
|||||||
showThresholdLabels: boolean;
|
showThresholdLabels: boolean;
|
||||||
showThresholdMarkers: boolean;
|
showThresholdMarkers: boolean;
|
||||||
sparkline?: boolean;
|
sparkline?: boolean;
|
||||||
|
textMode?: ('auto' | 'value_and_name' | 'value' | 'name' | 'none');
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaultOptions: Partial<Options> = {
|
export const defaultOptions: Partial<Options> = {
|
||||||
@@ -48,4 +49,5 @@ export const defaultOptions: Partial<Options> = {
|
|||||||
showThresholdLabels: false,
|
showThresholdLabels: false,
|
||||||
showThresholdMarkers: true,
|
showThresholdMarkers: true,
|
||||||
sparkline: true,
|
sparkline: true,
|
||||||
|
textMode: 'auto',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -248,15 +248,17 @@ export function PanelChrome({
|
|||||||
|
|
||||||
const onContentPointerDown = React.useCallback(
|
const onContentPointerDown = React.useCallback(
|
||||||
(evt: React.PointerEvent) => {
|
(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
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onSelect?.(evt);
|
onSelect?.(evt);
|
||||||
},
|
},
|
||||||
[onSelect]
|
[isSelected, onSelect]
|
||||||
);
|
);
|
||||||
|
|
||||||
const headerContent = (
|
const headerContent = (
|
||||||
|
|||||||
@@ -32,24 +32,6 @@ const meta: Meta<StoryProps> = {
|
|||||||
controls: {
|
controls: {
|
||||||
exclude: ['theme', 'values', 'vizCount'],
|
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: {
|
args: {
|
||||||
barWidthFactor: 0.2,
|
barWidthFactor: 0.2,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { css, cx } from '@emotion/css';
|
|||||||
import { useId } from 'react';
|
import { useId } from 'react';
|
||||||
|
|
||||||
import { DisplayValueAlignmentFactors, FALLBACK_COLOR, FieldDisplay, GrafanaTheme2, TimeRange } from '@grafana/data';
|
import { DisplayValueAlignmentFactors, FALLBACK_COLOR, FieldDisplay, GrafanaTheme2, TimeRange } from '@grafana/data';
|
||||||
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
import { t } from '@grafana/i18n';
|
import { t } from '@grafana/i18n';
|
||||||
|
|
||||||
import { useStyles2, useTheme2 } from '../../themes/ThemeContext';
|
import { useStyles2, useTheme2 } from '../../themes/ThemeContext';
|
||||||
@@ -275,7 +276,11 @@ export function RadialGauge(props: RadialGaugeProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.vizWrapper} style={{ width, height }}>
|
<div
|
||||||
|
data-testid={selectors.components.Panels.Visualization.Gauge.Container}
|
||||||
|
className={styles.vizWrapper}
|
||||||
|
style={{ width, height }}
|
||||||
|
>
|
||||||
{body}
|
{body}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { colorManipulator, GrafanaTheme2 } from '@grafana/data';
|
||||||
|
|
||||||
import { RadialGaugeDimensions } from './types';
|
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 }) {
|
export function CenterGlowGradient({ gaugeId, color }: { gaugeId: string; color: string }) {
|
||||||
|
const transparentColor = colorManipulator.alpha(color, CENTER_GLOW_OPACITY);
|
||||||
return (
|
return (
|
||||||
<radialGradient id={`circle-glow-${gaugeId}`} r="50%" fr="0%">
|
<radialGradient id={`circle-glow-${gaugeId}`} r="50%" fr="0%">
|
||||||
<stop offset="0%" stopColor={color} stopOpacity={CENTER_GLOW_OPACITY} />
|
<stop offset="0%" stopColor={transparentColor} />
|
||||||
<stop offset="90%" stopColor={color} stopOpacity={0} />
|
<stop offset="90%" stopColor={'#ffffff00'} />
|
||||||
</radialGradient>
|
</radialGradient>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -44,13 +45,14 @@ export interface CenterGlowProps {
|
|||||||
|
|
||||||
export function MiddleCircleGlow({ dimensions, gaugeId, color }: CenterGlowProps) {
|
export function MiddleCircleGlow({ dimensions, gaugeId, color }: CenterGlowProps) {
|
||||||
const gradientId = `circle-glow-${gaugeId}`;
|
const gradientId = `circle-glow-${gaugeId}`;
|
||||||
|
const transparentColor = color ? colorManipulator.alpha(color, CENTER_GLOW_OPACITY) : color;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<defs>
|
<defs>
|
||||||
<radialGradient id={gradientId} r="50%" fr="0%">
|
<radialGradient id={gradientId} r="50%" fr="0%">
|
||||||
<stop offset="0%" stopColor={color} stopOpacity={CENTER_GLOW_OPACITY} />
|
<stop offset="0%" stopColor={transparentColor} />
|
||||||
<stop offset="90%" stopColor={color} stopOpacity={0} />
|
<stop offset="90%" stopColor="#ffffff00" />
|
||||||
</radialGradient>
|
</radialGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<g>
|
<g>
|
||||||
@@ -86,9 +88,9 @@ export function SpotlightGradient({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<linearGradient x1={x1} y1={y1} x2={x2} y2={y2} id={id} gradientUnits="userSpaceOnUse">
|
<linearGradient x1={x1} y1={y1} x2={x2} y2={y2} id={id} gradientUnits="userSpaceOnUse">
|
||||||
<stop offset="0%" stopColor={'white'} stopOpacity={0.0} />
|
<stop offset="0%" stopColor="#ffffff00" />
|
||||||
<stop offset="95%" stopColor={'white'} stopOpacity={0.5} />
|
<stop offset="95%" stopColor="#ffffff88" />
|
||||||
{roundedBars && <stop offset="100%" stopColor={'white'} stopOpacity={roundedBars ? 0.7 : 1} />}
|
{roundedBars && <stop offset="100%" stopColor={roundedBars ? '#ffffffbb' : 'white'} />}
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ export interface SparklineProps extends Themeable2 {
|
|||||||
showHighlights?: boolean;
|
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 { sparkline, config: fieldConfig, theme, width, height, showHighlights } = props;
|
||||||
|
|
||||||
const { frame: alignedDataFrame, warning } = prepareSeries(sparkline, theme, fieldConfig, showHighlights);
|
const { frame: alignedDataFrame, warning } = prepareSeries(sparkline, theme, fieldConfig, showHighlights);
|
||||||
if (warning) {
|
if (warning) {
|
||||||
return null;
|
return null;
|
||||||
@@ -30,14 +31,4 @@ export const SparklineFn: React.FC<SparklineProps> = memo((props) => {
|
|||||||
return <UPlotChart data={data} config={configBuilder} width={width} height={height} />;
|
return <UPlotChart data={data} config={configBuilder} width={width} height={height} />;
|
||||||
});
|
});
|
||||||
|
|
||||||
SparklineFn.displayName = 'Sparkline';
|
Sparkline.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} />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -167,7 +167,8 @@ export class VizRepeater<V, D = {}> extends PureComponent<PropsWithDefaults<V, D
|
|||||||
|
|
||||||
const repeaterStyle: React.CSSProperties = {
|
const repeaterStyle: React.CSSProperties = {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
overflow: `${minVizWidth ? 'auto' : 'hidden'} ${minVizHeight ? 'auto' : 'hidden'}`,
|
overflowX: `${minVizWidth ? 'auto' : 'hidden'}`,
|
||||||
|
overflowY: `${minVizHeight ? 'auto' : 'hidden'}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
let vizHeight = height;
|
let vizHeight = height;
|
||||||
|
|||||||
55
pkg/apiserver/auditing/logger.go
Normal file
55
pkg/apiserver/auditing/logger.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package auditing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sinkable is a log entry abstraction that can be sent to an audit log sink through the different implementing methods.
|
||||||
|
type Sinkable interface {
|
||||||
|
json.Marshaler
|
||||||
|
KVPairs() []any
|
||||||
|
Time() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logger specifies the contract for a specific audit logger.
|
||||||
|
type Logger interface {
|
||||||
|
Log(entry Sinkable) error
|
||||||
|
Close() error
|
||||||
|
Type() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Implementation inspired by https://github.com/grafana/grafana-app-sdk/blob/main/logging/logger.go
|
||||||
|
type loggerContextKey struct{}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// DefaultLogger is the default Logger if one hasn't been provided in the context.
|
||||||
|
// You may use this to add arbitrary audit logging outside of an API request lifecycle.
|
||||||
|
DefaultLogger Logger = &NoopLogger{}
|
||||||
|
|
||||||
|
contextKey = loggerContextKey{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// FromContext returns the Logger set in the context with Context(), or the DefaultLogger if no Logger is set in the context.
|
||||||
|
// If DefaultLogger is nil, it returns a *NoopLogger so that the return is always valid to call methods on without nil-checking.
|
||||||
|
// You may use this to add arbitrary audit logging outside of an API request lifecycle.
|
||||||
|
func FromContext(ctx context.Context) Logger {
|
||||||
|
if l := ctx.Value(contextKey); l != nil {
|
||||||
|
if logger, ok := l.(Logger); ok {
|
||||||
|
return logger
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if DefaultLogger != nil {
|
||||||
|
return DefaultLogger
|
||||||
|
}
|
||||||
|
|
||||||
|
return &NoopLogger{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context returns a new context built from the provided context with the provided logger in it.
|
||||||
|
// The Logger added with Context() can be retrieved with FromContext()
|
||||||
|
func Context(ctx context.Context, logger Logger) context.Context {
|
||||||
|
return context.WithValue(ctx, contextKey, logger)
|
||||||
|
}
|
||||||
@@ -11,9 +11,9 @@ type NoopBackend struct{}
|
|||||||
|
|
||||||
func ProvideNoopBackend() audit.Backend { return &NoopBackend{} }
|
func ProvideNoopBackend() audit.Backend { return &NoopBackend{} }
|
||||||
|
|
||||||
func (b *NoopBackend) ProcessEvents(k8sEvents ...*auditinternal.Event) bool { return false }
|
func (NoopBackend) ProcessEvents(...*auditinternal.Event) bool { return false }
|
||||||
|
|
||||||
func (NoopBackend) Run(stopCh <-chan struct{}) error { return nil }
|
func (NoopBackend) Run(<-chan struct{}) error { return nil }
|
||||||
|
|
||||||
func (NoopBackend) Shutdown() {}
|
func (NoopBackend) Shutdown() {}
|
||||||
|
|
||||||
@@ -34,3 +34,14 @@ type NoopPolicyRuleEvaluator struct{}
|
|||||||
func (NoopPolicyRuleEvaluator) EvaluatePolicyRule(authorizer.Attributes) audit.RequestAuditConfig {
|
func (NoopPolicyRuleEvaluator) EvaluatePolicyRule(authorizer.Attributes) audit.RequestAuditConfig {
|
||||||
return audit.RequestAuditConfig{Level: auditinternal.LevelNone}
|
return audit.RequestAuditConfig{Level: auditinternal.LevelNone}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NoopLogger is a no-op implementation of Logger
|
||||||
|
type NoopLogger struct{}
|
||||||
|
|
||||||
|
func ProvideNoopLogger() Logger { return &NoopLogger{} }
|
||||||
|
|
||||||
|
func (NoopLogger) Type() string { return "noop" }
|
||||||
|
|
||||||
|
func (NoopLogger) Log(Sinkable) error { return nil }
|
||||||
|
|
||||||
|
func (NoopLogger) Close() error { return nil }
|
||||||
|
|||||||
@@ -46,14 +46,23 @@ func (defaultGrafanaPolicyRuleEvaluator) EvaluatePolicyRule(attrs authorizer.Att
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Logging the response object allows us to get the resource name for create requests.
|
||||||
|
level := auditinternal.LevelMetadata
|
||||||
|
if attrs.GetVerb() == utils.VerbCreate {
|
||||||
|
level = auditinternal.LevelRequestResponse
|
||||||
|
}
|
||||||
|
|
||||||
return audit.RequestAuditConfig{
|
return audit.RequestAuditConfig{
|
||||||
Level: auditinternal.LevelMetadata,
|
Level: level,
|
||||||
|
|
||||||
|
// Only log on StageResponseComplete, to avoid noisy logs.
|
||||||
OmitStages: []auditinternal.Stage{
|
OmitStages: []auditinternal.Stage{
|
||||||
// Only log on StageResponseComplete
|
|
||||||
auditinternal.StageRequestReceived,
|
auditinternal.StageRequestReceived,
|
||||||
auditinternal.StageResponseStarted,
|
auditinternal.StageResponseStarted,
|
||||||
auditinternal.StagePanic,
|
auditinternal.StagePanic,
|
||||||
},
|
},
|
||||||
OmitManagedFields: false, // Setting it to true causes extra copying/unmarshalling.
|
|
||||||
|
// Setting it to true causes extra copying/unmarshalling.
|
||||||
|
OmitManagedFields: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ func TestDefaultGrafanaPolicyRuleEvaluator(t *testing.T) {
|
|||||||
require.Equal(t, auditinternal.LevelNone, config.Level)
|
require.Equal(t, auditinternal.LevelNone, config.Level)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("return audit level metadata for other resource requests", func(t *testing.T) {
|
t.Run("return audit level request+response for create requests", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
attrs := authorizer.AttributesRecord{
|
attrs := authorizer.AttributesRecord{
|
||||||
@@ -67,6 +67,22 @@ func TestDefaultGrafanaPolicyRuleEvaluator(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
config := evaluator.EvaluatePolicyRule(attrs)
|
||||||
|
require.Equal(t, auditinternal.LevelRequestResponse, config.Level)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("return audit level metadata for other resource requests", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
attrs := authorizer.AttributesRecord{
|
||||||
|
ResourceRequest: true,
|
||||||
|
Verb: utils.VerbGet,
|
||||||
|
User: &user.DefaultInfo{
|
||||||
|
Name: "test-user",
|
||||||
|
Groups: []string{"test-group"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
config := evaluator.EvaluatePolicyRule(attrs)
|
config := evaluator.EvaluatePolicyRule(attrs)
|
||||||
require.Equal(t, auditinternal.LevelMetadata, config.Level)
|
require.Equal(t, auditinternal.LevelMetadata, config.Level)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/configprovider"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
@@ -62,7 +63,6 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/services/quota"
|
"github.com/grafana/grafana/pkg/services/quota"
|
||||||
"github.com/grafana/grafana/pkg/services/search/sort"
|
"github.com/grafana/grafana/pkg/services/search/sort"
|
||||||
"github.com/grafana/grafana/pkg/services/user"
|
"github.com/grafana/grafana/pkg/services/user"
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
|
||||||
"github.com/grafana/grafana/pkg/storage/legacysql"
|
"github.com/grafana/grafana/pkg/storage/legacysql"
|
||||||
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
|
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
|
||||||
"github.com/grafana/grafana/pkg/storage/unified/apistore"
|
"github.com/grafana/grafana/pkg/storage/unified/apistore"
|
||||||
@@ -128,7 +128,6 @@ type DashboardsAPIBuilder struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func RegisterAPIService(
|
func RegisterAPIService(
|
||||||
cfg *setting.Cfg,
|
|
||||||
features featuremgmt.FeatureToggles,
|
features featuremgmt.FeatureToggles,
|
||||||
apiregistration builder.APIRegistrar,
|
apiregistration builder.APIRegistrar,
|
||||||
dashboardService dashboards.DashboardService,
|
dashboardService dashboards.DashboardService,
|
||||||
@@ -154,7 +153,14 @@ func RegisterAPIService(
|
|||||||
publicDashboardService publicdashboards.Service,
|
publicDashboardService publicdashboards.Service,
|
||||||
snapshotService dashboardsnapshots.Service,
|
snapshotService dashboardsnapshots.Service,
|
||||||
dashboardActivityChannel live.DashboardActivityChannel,
|
dashboardActivityChannel live.DashboardActivityChannel,
|
||||||
|
configProvider configprovider.ConfigProvider,
|
||||||
) *DashboardsAPIBuilder {
|
) *DashboardsAPIBuilder {
|
||||||
|
cfg, err := configProvider.Get(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
logging.DefaultLogger.Error("failed to load settings configuration instance", "stackId", cfg.StackID, "err", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
dbp := legacysql.NewDatabaseProvider(sql)
|
dbp := legacysql.NewDatabaseProvider(sql)
|
||||||
namespacer := request.GetNamespaceMapper(cfg)
|
namespacer := request.GetNamespaceMapper(cfg)
|
||||||
legacyDashboardSearcher := legacysearcher.NewDashboardSearchClient(dashStore, sorter)
|
legacyDashboardSearcher := legacysearcher.NewDashboardSearchClient(dashStore, sorter)
|
||||||
@@ -237,7 +243,7 @@ func NewAPIService(ac authlib.AccessClient, features featuremgmt.FeatureToggles,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *DashboardsAPIBuilder) GetGroupVersions() []schema.GroupVersion {
|
func (b *DashboardsAPIBuilder) GetGroupVersions() []schema.GroupVersion {
|
||||||
if featuremgmt.AnyEnabled(b.features, featuremgmt.FlagDashboardNewLayouts, featuremgmt.FlagKubernetesDashboardsV2) {
|
if featuremgmt.AnyEnabled(b.features, featuremgmt.FlagDashboardNewLayouts) {
|
||||||
// If dashboards v2 is enabled, we want to use v2beta1 as the default API version.
|
// If dashboards v2 is enabled, we want to use v2beta1 as the default API version.
|
||||||
return []schema.GroupVersion{
|
return []schema.GroupVersion{
|
||||||
dashv2beta1.DashboardResourceInfo.GroupVersion(),
|
dashv2beta1.DashboardResourceInfo.GroupVersion(),
|
||||||
@@ -747,7 +753,6 @@ func (b *DashboardsAPIBuilder) storageForVersion(
|
|||||||
ResourceInfo: *snapshots,
|
ResourceInfo: *snapshots,
|
||||||
Service: b.snapshotService,
|
Service: b.snapshotService,
|
||||||
Namespacer: b.namespacer,
|
Namespacer: b.namespacer,
|
||||||
Options: b.snapshotOptions,
|
|
||||||
}
|
}
|
||||||
storage[snapshots.StoragePath()] = snapshotLegacyStore
|
storage[snapshots.StoragePath()] = snapshotLegacyStore
|
||||||
storage[snapshots.StoragePath("dashboard")], err = snapshot.NewDashboardREST(dashboards, b.snapshotService)
|
storage[snapshots.StoragePath("dashboard")], err = snapshot.NewDashboardREST(dashboards, b.snapshotService)
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ func GetRoutes(service dashboardsnapshots.Service, options dashv0.SnapshotSharin
|
|||||||
createCmd := defs["github.com/grafana/grafana/apps/dashboard/pkg/apissnapshot/v0alpha1.DashboardCreateCommand"].Schema
|
createCmd := defs["github.com/grafana/grafana/apps/dashboard/pkg/apissnapshot/v0alpha1.DashboardCreateCommand"].Schema
|
||||||
createExample := `{"dashboard":{"annotations":{"list":[{"name":"Annotations & Alerts","enable":true,"iconColor":"rgba(0, 211, 255, 1)","snapshotData":[],"type":"dashboard","builtIn":1,"hide":true}]},"editable":true,"fiscalYearStartMonth":0,"graphTooltip":0,"id":203,"links":[],"liveNow":false,"panels":[{"datasource":null,"fieldConfig":{"defaults":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisLabel":"","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":43,"gradientMode":"opacity","hideFrom":{"legend":false,"tooltip":false,"viz":false},"insertNulls":false,"lineInterpolation":"smooth","lineWidth":1,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"auto","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"off"}},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"red","value":80}]},"unitScale":true},"overrides":[]},"gridPos":{"h":8,"w":12,"x":0,"y":0},"id":1,"options":{"legend":{"calcs":[],"displayMode":"list","placement":"bottom","showLegend":true},"tooltip":{"mode":"single","sort":"none"}},"pluginVersion":"10.4.0-pre","snapshotData":[{"fields":[{"config":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":43,"gradientMode":"opacity","hideFrom":{"legend":false,"tooltip":false,"viz":false},"lineInterpolation":"smooth","lineWidth":1,"pointSize":5,"showPoints":"auto","thresholdsStyle":{"mode":"off"}},"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"red","value":80}]},"unitScale":true},"name":"time","type":"time","values":[1706030536378,1706034856378,1706039176378,1706043496378,1706047816378,1706052136378]},{"config":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisLabel":"","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":43,"gradientMode":"opacity","hideFrom":{"legend":false,"tooltip":false,"viz":false},"insertNulls":false,"lineInterpolation":"smooth","lineWidth":1,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"auto","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"off"}},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"red","value":80}]},"unitScale":true},"name":"A-series","type":"number","values":[1,20,90,30,50,0]}],"refId":"A"}],"targets":[],"title":"Simple example","type":"timeseries","links":[]}],"refresh":"","schemaVersion":39,"snapshot":{"timestamp":"2024-01-23T23:22:16.377Z"},"tags":[],"templating":{"list":[]},"time":{"from":"2024-01-23T17:22:20.380Z","to":"2024-01-23T23:22:20.380Z","raw":{"from":"now-6h","to":"now"}},"timepicker":{},"timezone":"","title":"simple and small","uid":"b22ec8db-399b-403b-b6c7-b0fb30ccb2a5","version":1,"weekStart":""},"name":"simple and small","expires":86400}`
|
createExample := `{"dashboard":{"annotations":{"list":[{"name":"Annotations & Alerts","enable":true,"iconColor":"rgba(0, 211, 255, 1)","snapshotData":[],"type":"dashboard","builtIn":1,"hide":true}]},"editable":true,"fiscalYearStartMonth":0,"graphTooltip":0,"id":203,"links":[],"liveNow":false,"panels":[{"datasource":null,"fieldConfig":{"defaults":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisLabel":"","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":43,"gradientMode":"opacity","hideFrom":{"legend":false,"tooltip":false,"viz":false},"insertNulls":false,"lineInterpolation":"smooth","lineWidth":1,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"auto","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"off"}},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"red","value":80}]},"unitScale":true},"overrides":[]},"gridPos":{"h":8,"w":12,"x":0,"y":0},"id":1,"options":{"legend":{"calcs":[],"displayMode":"list","placement":"bottom","showLegend":true},"tooltip":{"mode":"single","sort":"none"}},"pluginVersion":"10.4.0-pre","snapshotData":[{"fields":[{"config":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":43,"gradientMode":"opacity","hideFrom":{"legend":false,"tooltip":false,"viz":false},"lineInterpolation":"smooth","lineWidth":1,"pointSize":5,"showPoints":"auto","thresholdsStyle":{"mode":"off"}},"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"red","value":80}]},"unitScale":true},"name":"time","type":"time","values":[1706030536378,1706034856378,1706039176378,1706043496378,1706047816378,1706052136378]},{"config":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisLabel":"","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":43,"gradientMode":"opacity","hideFrom":{"legend":false,"tooltip":false,"viz":false},"insertNulls":false,"lineInterpolation":"smooth","lineWidth":1,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"auto","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"off"}},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"red","value":80}]},"unitScale":true},"name":"A-series","type":"number","values":[1,20,90,30,50,0]}],"refId":"A"}],"targets":[],"title":"Simple example","type":"timeseries","links":[]}],"refresh":"","schemaVersion":39,"snapshot":{"timestamp":"2024-01-23T23:22:16.377Z"},"tags":[],"templating":{"list":[]},"time":{"from":"2024-01-23T17:22:20.380Z","to":"2024-01-23T23:22:20.380Z","raw":{"from":"now-6h","to":"now"}},"timepicker":{},"timezone":"","title":"simple and small","uid":"b22ec8db-399b-403b-b6c7-b0fb30ccb2a5","version":1,"weekStart":""},"name":"simple and small","expires":86400}`
|
||||||
createRsp := defs["github.com/grafana/grafana/apps/dashboard/pkg/apissnapshot/v0alpha1.DashboardCreateResponse"].Schema
|
createRsp := defs["github.com/grafana/grafana/apps/dashboard/pkg/apissnapshot/v0alpha1.DashboardCreateResponse"].Schema
|
||||||
|
getSettingsRsp := defs["github.com/grafana/grafana/apps/dashboard/pkg/apissnapshot/v0alpha1.SnapshotSharingOptions"].Schema
|
||||||
|
getSettingsRspExample := `{"snapshotsEnabled":true,"externalSnapshotURL":"https://externalurl.com","externalSnapshotName":"external","externalEnabled":true}`
|
||||||
|
|
||||||
return &builder.APIRoutes{
|
return &builder.APIRoutes{
|
||||||
Namespace: []builder.APIRouteHandler{
|
Namespace: []builder.APIRouteHandler{
|
||||||
@@ -167,5 +169,84 @@ func GetRoutes(service dashboardsnapshots.Service, options dashv0.SnapshotSharin
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Path: prefix + "/settings",
|
||||||
|
Spec: &spec3.PathProps{
|
||||||
|
Get: &spec3.Operation{
|
||||||
|
VendorExtensible: spec.VendorExtensible{
|
||||||
|
Extensions: map[string]any{
|
||||||
|
"x-grafana-action": "get",
|
||||||
|
"x-kubernetes-group-version-kind": metav1.GroupVersionKind{
|
||||||
|
Group: dashv0.GROUP,
|
||||||
|
Version: dashv0.VERSION,
|
||||||
|
Kind: "SnapshotSharingOptions",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OperationProps: spec3.OperationProps{
|
||||||
|
Tags: tags,
|
||||||
|
OperationId: "getSnapshotSettings",
|
||||||
|
Description: "Get Snapshot sharing settings",
|
||||||
|
Parameters: []*spec3.Parameter{
|
||||||
|
{
|
||||||
|
ParameterProps: spec3.ParameterProps{
|
||||||
|
Name: "namespace",
|
||||||
|
In: "path",
|
||||||
|
Required: true,
|
||||||
|
Example: "default",
|
||||||
|
Description: "workspace",
|
||||||
|
Schema: spec.StringProperty(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Responses: &spec3.Responses{
|
||||||
|
ResponsesProps: spec3.ResponsesProps{
|
||||||
|
StatusCodeResponses: map[int]*spec3.Response{
|
||||||
|
200: {
|
||||||
|
ResponseProps: spec3.ResponseProps{
|
||||||
|
Content: map[string]*spec3.MediaType{
|
||||||
|
"application/json": {
|
||||||
|
MediaTypeProps: spec3.MediaTypeProps{
|
||||||
|
Schema: &getSettingsRsp,
|
||||||
|
Example: getSettingsRspExample,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Handler: func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, err := identity.GetRequester(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
errhttp.Write(r.Context(), err, w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wrap := &contextmodel.ReqContext{
|
||||||
|
Context: &web.Context{
|
||||||
|
Req: r,
|
||||||
|
Resp: web.NewResponseWriter(r.Method, w),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
info, err := authlib.ParseNamespace(vars["namespace"])
|
||||||
|
if err != nil {
|
||||||
|
wrap.JsonApiErr(http.StatusBadRequest, "expected namespace", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if info.OrgID != user.GetOrgID() {
|
||||||
|
wrap.JsonApiErr(http.StatusBadRequest,
|
||||||
|
fmt.Sprintf("user orgId does not match namespace (%d != %d)", info.OrgID, user.GetOrgID()), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wrap.JSON(http.StatusOK, options)
|
||||||
|
},
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package snapshot
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
|
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
@@ -29,7 +28,6 @@ type SnapshotLegacyStore struct {
|
|||||||
ResourceInfo utils.ResourceInfo
|
ResourceInfo utils.ResourceInfo
|
||||||
Service dashboardsnapshots.Service
|
Service dashboardsnapshots.Service
|
||||||
Namespacer request.NamespaceMapper
|
Namespacer request.NamespaceMapper
|
||||||
Options dashV0.SnapshotSharingOptions
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SnapshotLegacyStore) New() runtime.Object {
|
func (s *SnapshotLegacyStore) New() runtime.Object {
|
||||||
@@ -117,15 +115,6 @@ func (s *SnapshotLegacyStore) List(ctx context.Context, options *internalversion
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *SnapshotLegacyStore) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
|
func (s *SnapshotLegacyStore) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
|
||||||
info, err := request.NamespaceInfoFrom(ctx, true)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = s.checkEnabled(info.Value)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
query := dashboardsnapshots.GetDashboardSnapshotQuery{
|
query := dashboardsnapshots.GetDashboardSnapshotQuery{
|
||||||
Key: name,
|
Key: name,
|
||||||
}
|
}
|
||||||
@@ -140,10 +129,3 @@ func (s *SnapshotLegacyStore) Get(ctx context.Context, name string, options *met
|
|||||||
}
|
}
|
||||||
return nil, s.ResourceInfo.NewNotFound(name)
|
return nil, s.ResourceInfo.NewNotFound(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SnapshotLegacyStore) checkEnabled(ns string) error {
|
|
||||||
if !s.Options.SnapshotsEnabled {
|
|
||||||
return fmt.Errorf("snapshots not enabled")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
8
pkg/server/wire_gen.go
generated
8
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
zanzanaReconciler := dualwrite2.ProvideZanzanaReconciler(cfg, featureToggles, zanzanaClient, sqlStore, serverLockService, folderimplService)
|
zanzanaReconciler := dualwrite2.ProvideZanzanaReconciler(cfg, featureToggles, zanzanaClient, sqlStore, serverLockService, folderimplService, registerer)
|
||||||
investigationsAppProvider := investigations.RegisterApp(cfg)
|
investigationsAppProvider := investigations.RegisterApp(cfg)
|
||||||
appregistryService, err := appregistry.ProvideBuilderRunners(apiserverService, eventualRestConfigProvider, featureToggles, investigationsAppProvider, cfg)
|
appregistryService, err := appregistry.ProvideBuilderRunners(apiserverService, eventualRestConfigProvider, featureToggles, investigationsAppProvider, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -875,7 +875,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
|
|||||||
ldapImpl := service12.ProvideService(cfg, featureToggles, ssosettingsimplService)
|
ldapImpl := service12.ProvideService(cfg, featureToggles, ssosettingsimplService)
|
||||||
apiService := api4.ProvideService(cfg, routeRegisterImpl, accessControl, userService, authinfoimplService, ossGroups, identitySynchronizer, orgService, ldapImpl, userAuthTokenService, bundleregistryService)
|
apiService := api4.ProvideService(cfg, routeRegisterImpl, accessControl, userService, authinfoimplService, ossGroups, identitySynchronizer, orgService, ldapImpl, userAuthTokenService, bundleregistryService)
|
||||||
dashboardActivityChannel := live.ProvideDashboardActivityChannel(grafanaLive)
|
dashboardActivityChannel := live.ProvideDashboardActivityChannel(grafanaLive)
|
||||||
dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService, libraryElementService, publicDashboardServiceImpl, serviceImpl, dashboardActivityChannel)
|
dashboardsAPIBuilder := dashboard.RegisterAPIService(featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService, libraryElementService, publicDashboardServiceImpl, serviceImpl, dashboardActivityChannel, configProvider)
|
||||||
dataSourceAPIBuilder, err := datasource.RegisterAPIService(featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, accessControl, registerer, sourcesService)
|
dataSourceAPIBuilder, err := datasource.RegisterAPIService(featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, accessControl, registerer, sourcesService)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1509,7 +1509,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
zanzanaReconciler := dualwrite2.ProvideZanzanaReconciler(cfg, featureToggles, zanzanaClient, sqlStore, serverLockService, folderimplService)
|
zanzanaReconciler := dualwrite2.ProvideZanzanaReconciler(cfg, featureToggles, zanzanaClient, sqlStore, serverLockService, folderimplService, registerer)
|
||||||
investigationsAppProvider := investigations.RegisterApp(cfg)
|
investigationsAppProvider := investigations.RegisterApp(cfg)
|
||||||
appregistryService, err := appregistry.ProvideBuilderRunners(apiserverService, eventualRestConfigProvider, featureToggles, investigationsAppProvider, cfg)
|
appregistryService, err := appregistry.ProvideBuilderRunners(apiserverService, eventualRestConfigProvider, featureToggles, investigationsAppProvider, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1537,7 +1537,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
|
|||||||
ldapImpl := service12.ProvideService(cfg, featureToggles, ssosettingsimplService)
|
ldapImpl := service12.ProvideService(cfg, featureToggles, ssosettingsimplService)
|
||||||
apiService := api4.ProvideService(cfg, routeRegisterImpl, accessControl, userService, authinfoimplService, ossGroups, identitySynchronizer, orgService, ldapImpl, userAuthTokenService, bundleregistryService)
|
apiService := api4.ProvideService(cfg, routeRegisterImpl, accessControl, userService, authinfoimplService, ossGroups, identitySynchronizer, orgService, ldapImpl, userAuthTokenService, bundleregistryService)
|
||||||
dashboardActivityChannel := live.ProvideDashboardActivityChannel(grafanaLive)
|
dashboardActivityChannel := live.ProvideDashboardActivityChannel(grafanaLive)
|
||||||
dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService, libraryElementService, publicDashboardServiceImpl, serviceImpl, dashboardActivityChannel)
|
dashboardsAPIBuilder := dashboard.RegisterAPIService(featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService, libraryElementService, publicDashboardServiceImpl, serviceImpl, dashboardActivityChannel, configProvider)
|
||||||
dataSourceAPIBuilder, err := datasource.RegisterAPIService(featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, accessControl, registerer, sourcesService)
|
dataSourceAPIBuilder, err := datasource.RegisterAPIService(featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, accessControl, registerer, sourcesService)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
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/migrator"
|
||||||
"github.com/grafana/grafana/pkg/services/accesscontrol/permreg"
|
"github.com/grafana/grafana/pkg/services/accesscontrol/permreg"
|
||||||
"github.com/grafana/grafana/pkg/services/accesscontrol/pluginutils"
|
"github.com/grafana/grafana/pkg/services/accesscontrol/pluginutils"
|
||||||
|
"github.com/grafana/grafana/pkg/services/accesscontrol/seeding"
|
||||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||||
"github.com/grafana/grafana/pkg/services/folder"
|
"github.com/grafana/grafana/pkg/services/folder"
|
||||||
@@ -96,6 +97,12 @@ func ProvideOSSService(
|
|||||||
roles: accesscontrol.BuildBasicRoleDefinitions(),
|
roles: accesscontrol.BuildBasicRoleDefinitions(),
|
||||||
store: store,
|
store: store,
|
||||||
permRegistry: permRegistry,
|
permRegistry: permRegistry,
|
||||||
|
sql: db,
|
||||||
|
serverLock: lock,
|
||||||
|
}
|
||||||
|
|
||||||
|
if backend, ok := store.(*database.AccessControlStore); ok {
|
||||||
|
s.seeder = seeding.New(log.New("accesscontrol.seeder"), backend, backend)
|
||||||
}
|
}
|
||||||
|
|
||||||
return s
|
return s
|
||||||
@@ -112,8 +119,11 @@ type Service struct {
|
|||||||
rolesMu sync.RWMutex
|
rolesMu sync.RWMutex
|
||||||
roles map[string]*accesscontrol.RoleDTO
|
roles map[string]*accesscontrol.RoleDTO
|
||||||
store accesscontrol.Store
|
store accesscontrol.Store
|
||||||
|
seeder *seeding.Seeder
|
||||||
permRegistry permreg.PermissionRegistry
|
permRegistry permreg.PermissionRegistry
|
||||||
isInitialized bool
|
isInitialized bool
|
||||||
|
sql db.DB
|
||||||
|
serverLock *serverlock.ServerLockService
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) GetUsageStats(_ context.Context) map[string]any {
|
func (s *Service) GetUsageStats(_ context.Context) map[string]any {
|
||||||
@@ -431,17 +441,54 @@ func (s *Service) RegisterFixedRoles(ctx context.Context) error {
|
|||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
s.rolesMu.Lock()
|
s.rolesMu.Lock()
|
||||||
defer s.rolesMu.Unlock()
|
registrations := s.registrations.Slice()
|
||||||
|
|
||||||
s.registrations.Range(func(registration accesscontrol.RoleRegistration) bool {
|
s.registrations.Range(func(registration accesscontrol.RoleRegistration) bool {
|
||||||
s.registerRolesLocked(registration)
|
s.registerRolesLocked(registration)
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
s.isInitialized = true
|
s.isInitialized = true
|
||||||
|
|
||||||
|
rolesSnapshot := s.getBasicRolePermissionsLocked()
|
||||||
|
s.rolesMu.Unlock()
|
||||||
|
|
||||||
|
if s.seeder != nil {
|
||||||
|
if err := s.seeder.SeedRoles(ctx, registrations); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.seeder.RemoveAbsentRoles(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.refreshBasicRolePermissionsInDB(ctx, rolesSnapshot); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getBasicRolePermissionsSnapshotFromRegistrationsLocked computes the desired basic role permissions from the
|
||||||
|
// current registration list, using the shared seeding registration logic.
|
||||||
|
//
|
||||||
|
// it has to be called while holding the roles lock
|
||||||
|
func (s *Service) getBasicRolePermissionsLocked() map[string][]accesscontrol.Permission {
|
||||||
|
desired := map[accesscontrol.SeedPermission]struct{}{}
|
||||||
|
s.registrations.Range(func(registration accesscontrol.RoleRegistration) bool {
|
||||||
|
seeding.AppendDesiredPermissions(desired, s.log, ®istration.Role, registration.Grants, registration.Exclude, true)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
out := make(map[string][]accesscontrol.Permission)
|
||||||
|
for sp := range desired {
|
||||||
|
out[sp.BuiltInRole] = append(out[sp.BuiltInRole], accesscontrol.Permission{
|
||||||
|
Action: sp.Action,
|
||||||
|
Scope: sp.Scope,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// registerRolesLocked processes a single role registration and adds permissions to basic roles.
|
// registerRolesLocked processes a single role registration and adds permissions to basic roles.
|
||||||
// Must be called with s.rolesMu locked.
|
// Must be called with s.rolesMu locked.
|
||||||
func (s *Service) registerRolesLocked(registration accesscontrol.RoleRegistration) {
|
func (s *Service) registerRolesLocked(registration accesscontrol.RoleRegistration) {
|
||||||
@@ -474,6 +521,7 @@ func (s *Service) DeclarePluginRoles(ctx context.Context, ID, name string, regs
|
|||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
acRegs := pluginutils.ToRegistrations(ID, name, regs)
|
acRegs := pluginutils.ToRegistrations(ID, name, regs)
|
||||||
|
updatedBasicRoles := false
|
||||||
for _, r := range acRegs {
|
for _, r := range acRegs {
|
||||||
if err := pluginutils.ValidatePluginRole(ID, r.Role); err != nil {
|
if err := pluginutils.ValidatePluginRole(ID, r.Role); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -500,11 +548,23 @@ func (s *Service) DeclarePluginRoles(ctx context.Context, ID, name string, regs
|
|||||||
if initialized {
|
if initialized {
|
||||||
s.rolesMu.Lock()
|
s.rolesMu.Lock()
|
||||||
s.registerRolesLocked(r)
|
s.registerRolesLocked(r)
|
||||||
|
updatedBasicRoles = true
|
||||||
s.rolesMu.Unlock()
|
s.rolesMu.Unlock()
|
||||||
s.cache.Flush()
|
s.cache.Flush()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if updatedBasicRoles {
|
||||||
|
s.rolesMu.RLock()
|
||||||
|
rolesSnapshot := s.getBasicRolePermissionsLocked()
|
||||||
|
s.rolesMu.RUnlock()
|
||||||
|
|
||||||
|
// plugin roles can be declared after startup - keep DB in sync
|
||||||
|
if err := s.refreshBasicRolePermissionsInDB(ctx, rolesSnapshot); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
"go.opentelemetry.io/otel"
|
"go.opentelemetry.io/otel"
|
||||||
|
|
||||||
claims "github.com/grafana/authlib/types"
|
claims "github.com/grafana/authlib/types"
|
||||||
@@ -13,6 +15,7 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/infra/db"
|
"github.com/grafana/grafana/pkg/infra/db"
|
||||||
"github.com/grafana/grafana/pkg/infra/log"
|
"github.com/grafana/grafana/pkg/infra/log"
|
||||||
"github.com/grafana/grafana/pkg/infra/serverlock"
|
"github.com/grafana/grafana/pkg/infra/serverlock"
|
||||||
|
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
"github.com/grafana/grafana/pkg/services/authz/zanzana"
|
"github.com/grafana/grafana/pkg/services/authz/zanzana"
|
||||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||||
"github.com/grafana/grafana/pkg/services/folder"
|
"github.com/grafana/grafana/pkg/services/folder"
|
||||||
@@ -33,12 +36,15 @@ type ZanzanaReconciler struct {
|
|||||||
store db.DB
|
store db.DB
|
||||||
client zanzana.Client
|
client zanzana.Client
|
||||||
lock *serverlock.ServerLockService
|
lock *serverlock.ServerLockService
|
||||||
|
metrics struct {
|
||||||
|
lastSuccess prometheus.Gauge
|
||||||
|
}
|
||||||
// reconcilers are migrations that tries to reconcile the state of grafana db to zanzana store.
|
// reconcilers are migrations that tries to reconcile the state of grafana db to zanzana store.
|
||||||
// These are run periodically to try to maintain a consistent state.
|
// These are run periodically to try to maintain a consistent state.
|
||||||
reconcilers []resourceReconciler
|
reconcilers []resourceReconciler
|
||||||
}
|
}
|
||||||
|
|
||||||
func ProvideZanzanaReconciler(cfg *setting.Cfg, features featuremgmt.FeatureToggles, client zanzana.Client, store db.DB, lock *serverlock.ServerLockService, folderService folder.Service) *ZanzanaReconciler {
|
func ProvideZanzanaReconciler(cfg *setting.Cfg, features featuremgmt.FeatureToggles, client zanzana.Client, store db.DB, lock *serverlock.ServerLockService, folderService folder.Service, reg prometheus.Registerer) *ZanzanaReconciler {
|
||||||
zanzanaReconciler := &ZanzanaReconciler{
|
zanzanaReconciler := &ZanzanaReconciler{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
log: reconcilerLogger,
|
log: reconcilerLogger,
|
||||||
@@ -92,6 +98,13 @@ func ProvideZanzanaReconciler(cfg *setting.Cfg, features featuremgmt.FeatureTogg
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if reg != nil {
|
||||||
|
zanzanaReconciler.metrics.lastSuccess = promauto.With(reg).NewGauge(prometheus.GaugeOpts{
|
||||||
|
Name: "grafana_zanzana_reconcile_last_success_timestamp_seconds",
|
||||||
|
Help: "Unix timestamp (seconds) when the Zanzana reconciler last completed a reconciliation cycle.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if cfg.Anonymous.Enabled {
|
if cfg.Anonymous.Enabled {
|
||||||
zanzanaReconciler.reconcilers = append(zanzanaReconciler.reconcilers,
|
zanzanaReconciler.reconcilers = append(zanzanaReconciler.reconcilers,
|
||||||
newResourceReconciler(
|
newResourceReconciler(
|
||||||
@@ -118,6 +131,9 @@ func (r *ZanzanaReconciler) Run(ctx context.Context) error {
|
|||||||
// Reconcile schedules as job that will run and reconcile resources between
|
// Reconcile schedules as job that will run and reconcile resources between
|
||||||
// legacy access control and zanzana.
|
// legacy access control and zanzana.
|
||||||
func (r *ZanzanaReconciler) Reconcile(ctx context.Context) error {
|
func (r *ZanzanaReconciler) Reconcile(ctx context.Context) error {
|
||||||
|
// Ensure we don't reconcile an empty/partial RBAC state before OSS has seeded basic role permissions.
|
||||||
|
// This matters most during startup where fixed-role loading + basic-role permission refresh runs as another background service.
|
||||||
|
r.waitForBasicRolesSeeded(ctx)
|
||||||
r.reconcile(ctx)
|
r.reconcile(ctx)
|
||||||
|
|
||||||
// FIXME:
|
// FIXME:
|
||||||
@@ -133,6 +149,57 @@ func (r *ZanzanaReconciler) Reconcile(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *ZanzanaReconciler) hasBasicRolePermissions(ctx context.Context) bool {
|
||||||
|
var count int64
|
||||||
|
// Basic role permissions are stored on "basic:%" roles in the global org (0).
|
||||||
|
// In a fresh DB, this will be empty until fixed roles are registered and the basic role permission refresh runs.
|
||||||
|
type row struct {
|
||||||
|
Count int64 `xorm:"count"`
|
||||||
|
}
|
||||||
|
_ = r.store.WithDbSession(ctx, func(sess *db.Session) error {
|
||||||
|
var rr row
|
||||||
|
_, err := sess.SQL(
|
||||||
|
`SELECT COUNT(*) AS count
|
||||||
|
FROM role INNER JOIN permission AS p ON p.role_id = role.id
|
||||||
|
WHERE role.org_id = ? AND role.name LIKE ?`,
|
||||||
|
accesscontrol.GlobalOrgID,
|
||||||
|
accesscontrol.BasicRolePrefix+"%",
|
||||||
|
).Get(&rr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
count = rr.Count
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return count > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ZanzanaReconciler) waitForBasicRolesSeeded(ctx context.Context) {
|
||||||
|
// Best-effort: don't block forever. If we can't observe basic roles, proceed anyway.
|
||||||
|
const (
|
||||||
|
maxWait = 15 * time.Second
|
||||||
|
interval = 1 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
deadline := time.NewTimer(maxWait)
|
||||||
|
defer deadline.Stop()
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
if r.hasBasicRolePermissions(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-deadline.C:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (r *ZanzanaReconciler) reconcile(ctx context.Context) {
|
func (r *ZanzanaReconciler) reconcile(ctx context.Context) {
|
||||||
run := func(ctx context.Context, namespace string) {
|
run := func(ctx context.Context, namespace string) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -144,6 +211,9 @@ func (r *ZanzanaReconciler) reconcile(ctx context.Context) {
|
|||||||
r.log.Warn("Failed to perform reconciliation for resource", "err", err)
|
r.log.Warn("Failed to perform reconciliation for resource", "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if r.metrics.lastSuccess != nil {
|
||||||
|
r.metrics.lastSuccess.SetToCurrentTime()
|
||||||
|
}
|
||||||
r.log.Debug("Finished reconciliation", "elapsed", time.Since(now))
|
r.log.Debug("Finished reconciliation", "elapsed", time.Since(now))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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
|
package accesscontrol
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -594,3 +595,18 @@ type QueryWithOrg struct {
|
|||||||
OrgId *int64 `json:"orgId"`
|
OrgId *int64 `json:"orgId"`
|
||||||
Global bool `json:"global"`
|
Global bool `json:"global"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SeedPermission struct {
|
||||||
|
BuiltInRole string `xorm:"builtin_role"`
|
||||||
|
Action string `xorm:"action"`
|
||||||
|
Scope string `xorm:"scope"`
|
||||||
|
Origin string `xorm:"origin"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RoleStore interface {
|
||||||
|
LoadRoles(ctx context.Context) (map[string]*RoleDTO, error)
|
||||||
|
SetRole(ctx context.Context, existingRole *RoleDTO, wantedRole RoleDTO) error
|
||||||
|
SetPermissions(ctx context.Context, existingRole *RoleDTO, wantedRole RoleDTO) error
|
||||||
|
CreateRole(ctx context.Context, role RoleDTO) error
|
||||||
|
DeleteRoles(ctx context.Context, roleUIDs []string) error
|
||||||
|
}
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@ var _ authorizer.Authorizer = &roleAuthorizer{}
|
|||||||
|
|
||||||
var orgRoleNoneAsViewerAPIGroups = []string{
|
var orgRoleNoneAsViewerAPIGroups = []string{
|
||||||
"productactivation.ext.grafana.com",
|
"productactivation.ext.grafana.com",
|
||||||
|
// playlist can be removed after this issue is resolved: https://github.com/grafana/grafana/issues/115712
|
||||||
|
"playlist.grafana.app",
|
||||||
}
|
}
|
||||||
|
|
||||||
type roleAuthorizer struct{}
|
type roleAuthorizer struct{}
|
||||||
|
|||||||
@@ -20,9 +20,10 @@ const (
|
|||||||
|
|
||||||
// Typed errors
|
// Typed errors
|
||||||
var (
|
var (
|
||||||
ErrUserTokenNotFound = errors.New("user token not found")
|
ErrUserTokenNotFound = errors.New("user token not found")
|
||||||
ErrInvalidSessionToken = usertoken.ErrInvalidSessionToken
|
ErrInvalidSessionToken = usertoken.ErrInvalidSessionToken
|
||||||
ErrExternalSessionNotFound = errors.New("external session not found")
|
ErrExternalSessionNotFound = errors.New("external session not found")
|
||||||
|
ErrExternalSessionTokenNotFound = errors.New("session token was nil")
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
|
|||||||
@@ -78,6 +78,9 @@ func ProvideZanzanaClient(cfg *setting.Cfg, db db.DB, tracer tracing.Tracer, fea
|
|||||||
ctx = types.WithAuthInfo(ctx, authnlib.NewAccessTokenAuthInfo(authnlib.Claims[authnlib.AccessTokenClaims]{
|
ctx = types.WithAuthInfo(ctx, authnlib.NewAccessTokenAuthInfo(authnlib.Claims[authnlib.AccessTokenClaims]{
|
||||||
Rest: authnlib.AccessTokenClaims{
|
Rest: authnlib.AccessTokenClaims{
|
||||||
Namespace: "*",
|
Namespace: "*",
|
||||||
|
Permissions: []string{
|
||||||
|
zanzana.TokenPermissionUpdate,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
return ctx, nil
|
return ctx, nil
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/infra/log"
|
"github.com/grafana/grafana/pkg/infra/log"
|
||||||
|
"github.com/grafana/grafana/pkg/services/authz/zanzana"
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
|
"golang.org/x/exp/slices"
|
||||||
|
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
@@ -30,3 +32,20 @@ func authorize(ctx context.Context, namespace string, ss setting.ZanzanaServerSe
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func authorizeWrite(ctx context.Context, namespace string, ss setting.ZanzanaServerSettings) error {
|
||||||
|
if err := authorize(ctx, namespace, ss); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
c, ok := claims.AuthInfoFrom(ctx)
|
||||||
|
if !ok {
|
||||||
|
return status.Errorf(codes.Unauthenticated, "unauthenticated")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Contains(c.GetTokenPermissions(), zanzana.TokenPermissionUpdate) {
|
||||||
|
return status.Errorf(codes.PermissionDenied, "missing token permission %s", zanzana.TokenPermissionUpdate)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -391,7 +391,7 @@ func setupBenchmarkServer(b *testing.B) (*Server, *benchmarkData) {
|
|||||||
b.Logf("Total tuples to write: %d", len(allTuples))
|
b.Logf("Total tuples to write: %d", len(allTuples))
|
||||||
|
|
||||||
// Get store info
|
// Get store info
|
||||||
ctx := newContextWithNamespace()
|
ctx := newContextWithZanzanaUpdatePermission()
|
||||||
storeInf, err := srv.getStoreInfo(ctx, benchNamespace)
|
storeInf, err := srv.getStoreInfo(ctx, benchNamespace)
|
||||||
require.NoError(b, err)
|
require.NoError(b, err)
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
openfgav1 "github.com/openfga/api/proto/openfga/v1"
|
||||||
"go.opentelemetry.io/otel/codes"
|
"go.opentelemetry.io/otel/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
|
||||||
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
|
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
|
||||||
)
|
)
|
||||||
@@ -35,6 +36,9 @@ func (s *Server) Mutate(ctx context.Context, req *authzextv1.MutateRequest) (*au
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
span.RecordError(err)
|
span.RecordError(err)
|
||||||
span.SetStatus(codes.Error, err.Error())
|
span.SetStatus(codes.Error, err.Error())
|
||||||
|
if _, ok := status.FromError(err); ok {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
s.logger.Error("failed to perform mutate request", "error", err, "namespace", req.GetNamespace())
|
s.logger.Error("failed to perform mutate request", "error", err, "namespace", req.GetNamespace())
|
||||||
return nil, errors.New("failed to perform mutate request")
|
return nil, errors.New("failed to perform mutate request")
|
||||||
}
|
}
|
||||||
@@ -43,7 +47,7 @@ func (s *Server) Mutate(ctx context.Context, req *authzextv1.MutateRequest) (*au
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) mutate(ctx context.Context, req *authzextv1.MutateRequest) (*authzextv1.MutateResponse, error) {
|
func (s *Server) mutate(ctx context.Context, req *authzextv1.MutateRequest) (*authzextv1.MutateResponse, error) {
|
||||||
if err := authorize(ctx, req.GetNamespace(), s.cfg); err != nil {
|
if err := authorizeWrite(ctx, req.GetNamespace(), s.cfg); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ func testMutateFolders(t *testing.T, srv *Server) {
|
|||||||
setupMutateFolders(t, srv)
|
setupMutateFolders(t, srv)
|
||||||
|
|
||||||
t.Run("should create new folder parent relation", func(t *testing.T) {
|
t.Run("should create new folder parent relation", func(t *testing.T) {
|
||||||
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
|
_, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{
|
||||||
Namespace: "default",
|
Namespace: "default",
|
||||||
Operations: []*v1.MutateOperation{
|
Operations: []*v1.MutateOperation{
|
||||||
{
|
{
|
||||||
@@ -61,7 +61,7 @@ func testMutateFolders(t *testing.T, srv *Server) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("should delete folder parent relation", func(t *testing.T) {
|
t.Run("should delete folder parent relation", func(t *testing.T) {
|
||||||
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
|
_, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{
|
||||||
Namespace: "default",
|
Namespace: "default",
|
||||||
Operations: []*v1.MutateOperation{
|
Operations: []*v1.MutateOperation{
|
||||||
{
|
{
|
||||||
@@ -88,7 +88,7 @@ func testMutateFolders(t *testing.T, srv *Server) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("should clean up all parent relations", func(t *testing.T) {
|
t.Run("should clean up all parent relations", func(t *testing.T) {
|
||||||
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
|
_, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{
|
||||||
Namespace: "default",
|
Namespace: "default",
|
||||||
Operations: []*v1.MutateOperation{
|
Operations: []*v1.MutateOperation{
|
||||||
{
|
{
|
||||||
@@ -115,7 +115,7 @@ func testMutateFolders(t *testing.T, srv *Server) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("should perform batch mutate if multiple operations are provided", func(t *testing.T) {
|
t.Run("should perform batch mutate if multiple operations are provided", func(t *testing.T) {
|
||||||
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
|
_, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{
|
||||||
Namespace: "default",
|
Namespace: "default",
|
||||||
Operations: []*v1.MutateOperation{
|
Operations: []*v1.MutateOperation{
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func testMutateOrgRoles(t *testing.T, srv *Server) {
|
|||||||
setupMutateOrgRoles(t, srv)
|
setupMutateOrgRoles(t, srv)
|
||||||
|
|
||||||
t.Run("should update user org role and delete old role", func(t *testing.T) {
|
t.Run("should update user org role and delete old role", func(t *testing.T) {
|
||||||
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
|
_, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{
|
||||||
Namespace: "default",
|
Namespace: "default",
|
||||||
Operations: []*v1.MutateOperation{
|
Operations: []*v1.MutateOperation{
|
||||||
{
|
{
|
||||||
@@ -63,7 +63,7 @@ func testMutateOrgRoles(t *testing.T, srv *Server) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("should add user org role and delete old role", func(t *testing.T) {
|
t.Run("should add user org role and delete old role", func(t *testing.T) {
|
||||||
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
|
_, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{
|
||||||
Namespace: "default",
|
Namespace: "default",
|
||||||
Operations: []*v1.MutateOperation{
|
Operations: []*v1.MutateOperation{
|
||||||
{
|
{
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user