Compare commits

..

20 Commits

Author SHA1 Message Date
Nathan Verzemnieks
79a61a2b63 CloudWatch: fix error source for some query errors 2026-01-02 14:57:50 +01:00
Mustafa Sencer Özcan
dc4c106e91 fix: use memory index if index file already open (#115720)
* feat: add lock structure into bleve index files

* fix: another approach

* fix: new check

* fix: build in memory if index file already open

* fix: update workspace

* fix: add test

* refactor: update func signature

* fix: address comments

* fix: make const
2026-01-02 13:51:51 +01:00
Kristina Demeshchik
33a1c60433 Dashboard: Add lazy loading for repeated panels (#115047)
Co-authored-by: Haris Rozajac <haris.rozajac12@gmail.com>
Co-authored-by: Ivan Ortega <ivanortegaalba@gmail.com>
2026-01-02 08:15:40 +01:00
Stephanie Hingtgen
521670981a Zanzana: Add metric for last reconciliation (#115768) 2025-12-31 12:42:09 -06:00
alerting-team[bot]
79ca4e5aec Alerting: Update alerting module to b7821017d69f2e31500fc0e49cd0ba3b85372a1b (#115767)
* [create-pull-request] automated change

* Fix tests

---------

Co-authored-by: alexander-akhmetov <1875873+alexander-akhmetov@users.noreply.github.com>
Co-authored-by: Alexander Akhmetov <me@alx.cx>
2025-12-31 16:04:41 +00:00
Paul Marbach
e3bc61e7d2 Suggestions: Add intermediate state to avoid unexpected saved states (#115709)
* Suggestions: Add intermediate state to avoid unexpected saved states

* cleanup

* update and add e2es to confirm behavior

* fix some of the change dispatch

* codeowners

* fix js error that this exposed

* Apply suggestion from @fastfrwrd
2025-12-31 10:56:47 -05:00
Stephanie Hingtgen
cc6a75d021 Zanzana: Add folder integration tests (#115766) 2025-12-31 15:15:20 +00:00
Stephanie Hingtgen
6d0f7f3567 AccessControl: Seed basic roles on startup (#115729) 2025-12-31 08:43:29 -06:00
Stephanie Hingtgen
913c0ba3c5 Dashboards: Cleanup integration tests (#115765) 2025-12-31 14:29:01 +00:00
Matheus Macabu
552b6aa717 Secrets: Dont update createdBy when updating a secure value (#115760) 2025-12-31 15:01:22 +01:00
Alexander Akhmetov
2ddb4049c6 Alerting: Fix target datasource description (#115666) 2025-12-31 12:35:35 +00:00
Jo
318a0ebb36 IAM: Authorize writes to zanzana on token permissions (#115645)
* validate writes to zanzana, not reads

* lint ignore
2025-12-31 09:15:00 +00:00
grafana-pr-automation[bot]
bba5c44dc4 I18n: Download translations from Crowdin (#115757)
New Crowdin translations by GitHub Action

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-31 00:42:54 +00:00
Paul Marbach
44e6ea3d8b Gauge: Fix issues found during bug bash (#115740)
* fix warning for VizRepeater styles

* Gauge: Update test dashboard to round two of the segment panels to whole numbers

* Gauge: E2E tests

* add test for sparklines

* Gauge: Change inner glow to be friendlier to our a11y tests

* remove unused CODEOWNER declaration

* expose text mode so that old displayName usage is somewhat preserved

* update migrations to use the value_and_text mode if displayName has a non-empty value

* more test cases

* update unit tests for fixture updates
2025-12-30 15:27:32 -05:00
Kristina Demeshchik
014d4758c6 Dashboards: Prevent row selection when clicking canvas add actions (#115580)
* event propogation issues

* Action items width

* prevent pointer up event
2025-12-30 12:27:38 -07:00
Sean Griffin
82b4ce0ece Redesign Empty Transformation Panel (#115648)
Co-authored-by: Alex Spencer <52186778+alexjonspencer1@users.noreply.github.com>
2025-12-30 16:46:29 +00:00
Paul Marbach
52698cf0da Sparkline: Restore to a function component (#115447)
* Sparkline: Restore to a function component

* fix whitespace lint issue
2025-12-30 10:55:40 -05:00
Haris Rozajac
d291dfb35b Dashboard Conversion: Fix type assertion mismatch in data loss detection (#115749) 2025-12-30 08:51:46 -07:00
Andrew Hackmann
9c6feb8de5 Elasticsearch: Builder queries no longer execute in code mode (#115456)
* The builder query no longer runs if code mode query is empty. Remove checks for query being empty to run raw query.

* missed save

* prettier?

* Update public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.ts

Co-authored-by: Andreas Christou <andreas.christou@grafana.com>

---------

Co-authored-by: Andreas Christou <andreas.christou@grafana.com>
2025-12-30 15:37:19 +00:00
Ayush Kaithwas
e7625186af Dashboards: Clear edit pane selection when entering panel edit (#115658)
* Clear selection on entering edit mode. Added test to verify selection is cleared when editing a panel.

* Update comment

---------

Co-authored-by: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com>
2025-12-30 07:35:43 -07:00
127 changed files with 3154 additions and 588 deletions

3
.github/CODEOWNERS vendored
View File

@@ -501,7 +501,6 @@ i18next.config.ts @grafana/grafana-frontend-platform
/e2e-playwright/various-suite/filter-annotations.spec.ts @grafana/dashboards-squad
/e2e-playwright/various-suite/frontend-sandbox-app.spec.ts @grafana/plugins-platform-frontend
/e2e-playwright/various-suite/frontend-sandbox-datasource.spec.ts @grafana/plugins-platform-frontend
/e2e-playwright/various-suite/gauge.spec.ts @grafana/dataviz-squad
/e2e-playwright/various-suite/grafana-datasource-random-walk.spec.ts @grafana/grafana-frontend-platform
/e2e-playwright/various-suite/graph-auto-migrate.spec.ts @grafana/dataviz-squad
/e2e-playwright/various-suite/inspect-drawer.spec.ts @grafana/dashboards-squad
@@ -520,7 +519,7 @@ i18next.config.ts @grafana/grafana-frontend-platform
/e2e-playwright/various-suite/solo-route.spec.ts @grafana/dashboards-squad
/e2e-playwright/various-suite/trace-view-scrolling.spec.ts @grafana/observability-traces-and-profiling
/e2e-playwright/various-suite/verify-i18n.spec.ts @grafana/grafana-frontend-platform
/e2e-playwright/various-suite/visualization-suggestions.spec.ts @grafana/dataviz-squad
/e2e-playwright/various-suite/visualization-suggestions*.spec.ts @grafana/dataviz-squad
/e2e-playwright/various-suite/perf-test.spec.ts @grafana/grafana-frontend-platform
# Packages

View File

@@ -157,7 +157,7 @@ require (
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/google/wire v0.7.0 // indirect
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 // indirect
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f // indirect
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect
github.com/grafana/dataplane/sdata v0.0.9 // indirect
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 // indirect

View File

@@ -619,8 +619,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ=
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts=
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o=
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg=
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY=

View File

@@ -4,7 +4,7 @@ go 1.25.5
require (
github.com/go-kit/log v0.2.1
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4
github.com/grafana/grafana-app-sdk v0.48.7
github.com/grafana/grafana-app-sdk/logging v0.48.7

View File

@@ -243,8 +243,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ=
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts=
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 h1:jSojuc7njleS3UOz223WDlXOinmuLAIPI0z2vtq8EgI=
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4/go.mod h1:VahT+GtfQIM+o8ht2StR6J9g+Ef+C2Vokh5uuSmOD/4=
github.com/grafana/grafana-app-sdk v0.48.7 h1:9mF7nqkqP0QUYYDlznoOt+GIyjzj45wGfUHB32u2ZMo=

View File

@@ -180,12 +180,15 @@ func countAnnotationsV0V1(spec map[string]interface{}) int {
return 0
}
annotationList, ok := annotations["list"].([]interface{})
if !ok {
return 0
// Handle both []interface{} (from JSON unmarshaling) and []map[string]interface{} (from programmatic creation)
if annotationList, ok := annotations["list"].([]interface{}); ok {
return len(annotationList)
}
if annotationList, ok := annotations["list"].([]map[string]interface{}); ok {
return len(annotationList)
}
return len(annotationList)
return 0
}
// countLinksV0V1 counts dashboard links in v0alpha1 or v1beta1 dashboard spec
@@ -194,12 +197,15 @@ func countLinksV0V1(spec map[string]interface{}) int {
return 0
}
links, ok := spec["links"].([]interface{})
if !ok {
return 0
// Handle both []interface{} (from JSON unmarshaling) and []map[string]interface{} (from programmatic creation)
if links, ok := spec["links"].([]interface{}); ok {
return len(links)
}
if links, ok := spec["links"].([]map[string]interface{}); ok {
return len(links)
}
return len(links)
return 0
}
// countVariablesV0V1 counts template variables in v0alpha1 or v1beta1 dashboard spec
@@ -213,12 +219,15 @@ func countVariablesV0V1(spec map[string]interface{}) int {
return 0
}
variableList, ok := templating["list"].([]interface{})
if !ok {
return 0
// Handle both []interface{} (from JSON unmarshaling) and []map[string]interface{} (from programmatic creation)
if variableList, ok := templating["list"].([]interface{}); ok {
return len(variableList)
}
if variableList, ok := templating["list"].([]map[string]interface{}); ok {
return len(variableList)
}
return len(variableList)
return 0
}
// collectStatsV0V1 collects statistics from v0alpha1 or v1beta1 dashboard

View File

@@ -628,6 +628,20 @@
}
],
"title": "Only nulls and no user set min \u0026 max",
"transformations": [
{
"id": "convertFieldType",
"options": {
"conversions": [
{
"destinationType": "number",
"targetField": "A-series"
}
],
"fields": {}
}
}
],
"type": "gauge"
},
{
@@ -1179,4 +1193,4 @@
"title": "Panel Tests - Gauge",
"uid": "_5rDmaQiz",
"weekStart": ""
}
}

View File

@@ -1760,6 +1760,22 @@
"startValue": 0
}
],
"transformations": [
{
"id": "calculateField",
"options": {
"mode": "unary",
"reduce": {
"reducer": "sum"
},
"replaceFields": true,
"unary": {
"operator": "round",
"fieldName": "A-series"
}
}
}
],
"title": "Active gateways",
"type": "radialbar"
},
@@ -1843,6 +1859,22 @@
"startValue": 0
}
],
"transformations": [
{
"id": "calculateField",
"options": {
"mode": "unary",
"reduce": {
"reducer": "sum"
},
"replaceFields": true,
"unary": {
"operator": "round",
"fieldName": "A-series"
}
}
}
],
"title": "Active pods",
"type": "radialbar"
},

View File

@@ -485,6 +485,7 @@
},
"id": 12,
"options": {
"displayName": "My gauge",
"minVizHeight": 75,
"minVizWidth": 75,
"orientation": "auto",

View File

@@ -223,7 +223,7 @@ require (
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 // indirect
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f // indirect
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // indirect
github.com/grafana/dataplane/sdata v0.0.9 // indirect

View File

@@ -827,8 +827,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ=
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts=
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o=
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg=
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY=

View File

@@ -90,7 +90,7 @@ require (
github.com/google/gnostic-models v0.7.1 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 // indirect
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f // indirect
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // indirect
github.com/grafana/dataplane/sdata v0.0.9 // indirect

View File

@@ -213,8 +213,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ=
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts=
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o=
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg=
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY=

View File

@@ -600,6 +600,20 @@
"stringInput": "null,null"
}
],
"transformations": [
{
"id": "convertFieldType",
"options": {
"fields": {},
"conversions": [
{
"targetField": "A-series",
"destinationType": "number"
}
]
}
}
],
"title": "Only nulls and no user set min & max",
"type": "gauge"
},

View File

@@ -1718,6 +1718,22 @@
"startValue": 0
}
],
"transformations": [
{
"id": "calculateField",
"options": {
"mode": "unary",
"reduce": {
"reducer": "sum"
},
"replaceFields": true,
"unary": {
"operator": "round",
"fieldName": "A-series"
}
}
}
],
"title": "Active gateways",
"type": "radialbar"
},
@@ -1799,6 +1815,22 @@
"startValue": 0
}
],
"transformations": [
{
"id": "calculateField",
"options": {
"mode": "unary",
"reduce": {
"reducer": "sum"
},
"replaceFields": true,
"unary": {
"operator": "round",
"fieldName": "A-series"
}
}
}
],
"title": "Active pods",
"type": "radialbar"
},

View File

@@ -474,6 +474,7 @@
},
"id": 12,
"options": {
"displayName": "My gauge",
"minVizHeight": 75,
"minVizWidth": 75,
"orientation": "auto",

View File

@@ -134,7 +134,7 @@ To convert data source-managed alert rules to Grafana managed alerts:
Pausing stops alert rule evaluation behavior for the newly created Grafana-managed alert rules.
9. (Optional) In the **Target data source** of the **Recording rules** section, you can select the data source that the imported recording rules will query. By default, it is the data source selected in the **Data source** dropdown.
9. (Optional) In the **Target data source** of the **Recording rules** section, you can select the data source to which the imported recording rules will write metrics. By default, it is the data source selected in the **Data source** dropdown.
10. Click **Import**.

View 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');
});
}
);

View File

@@ -1,4 +1,4 @@
import { BootData } from '@grafana/data';
import { BootData, PanelPluginMeta } from '@grafana/data';
import { test, expect } from '@grafana/plugin-e2e';
test.describe(
@@ -22,7 +22,7 @@ test.describe(
await dashboardPage.addPanel();
// Get panel types from window object
const panelTypes = await page.evaluate(() => {
const panelTypes: PanelPluginMeta[] = await page.evaluate(() => {
// @grafana/plugin-e2e doesn't export the full bootdata config
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const win = window as typeof window & { grafanaBootData: BootData };

View File

@@ -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();
});
}
);

View File

@@ -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();
});
}
);

View File

@@ -3,7 +3,7 @@ import { test, expect } from '@grafana/plugin-e2e';
test.describe(
'Visualization suggestions',
{
tag: ['@various'],
tag: ['@various', '@suggestions'],
},
() => {
test('Should be shown and clickable', async ({ page, selectors, gotoPanelEditPage }) => {

4
go.mod
View File

@@ -87,7 +87,7 @@ require (
github.com/googleapis/gax-go/v2 v2.15.0 // @grafana/grafana-backend-group
github.com/gorilla/mux v1.8.1 // @grafana/grafana-backend-group
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // @grafana/grafana-app-platform-squad
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 // @grafana/alerting-backend
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f // @grafana/alerting-backend
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // @grafana/identity-access-team
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // @grafana/identity-access-team
github.com/grafana/dataplane/examples v0.0.1 // @grafana/observability-metrics
@@ -181,6 +181,7 @@ require (
github.com/xlab/treeprint v1.2.0 // @grafana/observability-traces-and-profiling
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // @grafana/grafana-operator-experience-squad
github.com/yudai/gojsondiff v1.0.0 // @grafana/grafana-backend-group
go.etcd.io/bbolt v1.4.2 // @grafana/grafana-search-and-storage
go.opentelemetry.io/collector/pdata v1.44.0 // @grafana/grafana-backend-group
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 // @grafana/plugins-platform-backend
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.63.0 // @grafana/grafana-operator-experience-squad
@@ -603,7 +604,6 @@ require (
github.com/yuin/gopher-lua v1.1.1 // indirect
github.com/zclconf/go-cty v1.16.3 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
go.etcd.io/bbolt v1.4.2 // indirect
go.etcd.io/etcd/api/v3 v3.6.6 // indirect
go.etcd.io/etcd/client/pkg/v3 v3.6.6 // indirect
go.etcd.io/etcd/client/v3 v3.6.6 // indirect

4
go.sum
View File

@@ -1622,8 +1622,8 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ=
github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts=
github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU=
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o=
github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg=
github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY=

View File

@@ -535,6 +535,11 @@ export const versionedComponents = {
'12.3.0': 'data-testid viz-tooltip-wrapper',
},
},
Gauge: {
Container: {
'12.4.0': 'data-testid gauge container',
},
},
},
},
VizLegend: {
@@ -1288,6 +1293,9 @@ export const versionedComponents = {
card: {
[MIN_GRAFANA_VERSION]: (name: string) => `data-testid suggestion-${name}`,
},
confirm: {
'12.4.0': (name: string) => `data-testid suggestion-${name} confirm button`,
},
},
ColorSwatch: {
name: {

View File

@@ -35,6 +35,7 @@ export interface Options extends common.SingleStatBaseOptions {
showThresholdLabels: boolean;
showThresholdMarkers: boolean;
sparkline?: boolean;
textMode?: ('auto' | 'value_and_name' | 'value' | 'name' | 'none');
}
export const defaultOptions: Partial<Options> = {
@@ -48,4 +49,5 @@ export const defaultOptions: Partial<Options> = {
showThresholdLabels: false,
showThresholdMarkers: true,
sparkline: true,
textMode: 'auto',
};

View File

@@ -248,15 +248,17 @@ export function PanelChrome({
const onContentPointerDown = React.useCallback(
(evt: React.PointerEvent) => {
// Ignore clicks inside buttons, links, canvas and svg elments
// When selected, ignore clicks inside buttons, links, canvas and svg elments
// This does prevent a clicks inside a graphs from selecting panel as there is normal div above the canvas element that intercepts the click
if (evt.target instanceof Element && evt.target.closest('button,a,canvas,svg')) {
if (isSelected && evt.target instanceof Element && evt.target.closest('button,a,canvas,svg')) {
// Stop propagation otherwise row config editor will get selected
evt.stopPropagation();
return;
}
onSelect?.(evt);
},
[onSelect]
[isSelected, onSelect]
);
const headerContent = (

View File

@@ -32,24 +32,6 @@ const meta: Meta<StoryProps> = {
controls: {
exclude: ['theme', 'values', 'vizCount'],
},
a11y: {
config: {
rules: [
{
id: 'scrollable-region-focusable',
selector: 'body',
enabled: false,
},
// NOTE: this is necessary due to a false positive with the filered svg glow in one of the examples.
// The color-contrast in this component should be accessible!
{
id: 'color-contrast',
selector: 'text',
enabled: false,
},
],
},
},
},
args: {
barWidthFactor: 0.2,

View File

@@ -2,6 +2,7 @@ import { css, cx } from '@emotion/css';
import { useId } from 'react';
import { DisplayValueAlignmentFactors, FALLBACK_COLOR, FieldDisplay, GrafanaTheme2, TimeRange } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { t } from '@grafana/i18n';
import { useStyles2, useTheme2 } from '../../themes/ThemeContext';
@@ -275,7 +276,11 @@ export function RadialGauge(props: RadialGaugeProps) {
}
return (
<div className={styles.vizWrapper} style={{ width, height }}>
<div
data-testid={selectors.components.Panels.Visualization.Gauge.Container}
className={styles.vizWrapper}
style={{ width, height }}
>
{body}
</div>
);

View File

@@ -1,4 +1,4 @@
import { GrafanaTheme2 } from '@grafana/data';
import { colorManipulator, GrafanaTheme2 } from '@grafana/data';
import { RadialGaugeDimensions } from './types';
@@ -25,13 +25,14 @@ export function GlowGradient({ id, barWidth }: GlowGradientProps) {
);
}
const CENTER_GLOW_OPACITY = 0.15;
const CENTER_GLOW_OPACITY = 0.25;
export function CenterGlowGradient({ gaugeId, color }: { gaugeId: string; color: string }) {
const transparentColor = colorManipulator.alpha(color, CENTER_GLOW_OPACITY);
return (
<radialGradient id={`circle-glow-${gaugeId}`} r="50%" fr="0%">
<stop offset="0%" stopColor={color} stopOpacity={CENTER_GLOW_OPACITY} />
<stop offset="90%" stopColor={color} stopOpacity={0} />
<stop offset="0%" stopColor={transparentColor} />
<stop offset="90%" stopColor={'#ffffff00'} />
</radialGradient>
);
}
@@ -44,13 +45,14 @@ export interface CenterGlowProps {
export function MiddleCircleGlow({ dimensions, gaugeId, color }: CenterGlowProps) {
const gradientId = `circle-glow-${gaugeId}`;
const transparentColor = color ? colorManipulator.alpha(color, CENTER_GLOW_OPACITY) : color;
return (
<>
<defs>
<radialGradient id={gradientId} r="50%" fr="0%">
<stop offset="0%" stopColor={color} stopOpacity={CENTER_GLOW_OPACITY} />
<stop offset="90%" stopColor={color} stopOpacity={0} />
<stop offset="0%" stopColor={transparentColor} />
<stop offset="90%" stopColor="#ffffff00" />
</radialGradient>
</defs>
<g>
@@ -86,9 +88,9 @@ export function SpotlightGradient({
return (
<linearGradient x1={x1} y1={y1} x2={x2} y2={y2} id={id} gradientUnits="userSpaceOnUse">
<stop offset="0%" stopColor={'white'} stopOpacity={0.0} />
<stop offset="95%" stopColor={'white'} stopOpacity={0.5} />
{roundedBars && <stop offset="100%" stopColor={'white'} stopOpacity={roundedBars ? 0.7 : 1} />}
<stop offset="0%" stopColor="#ffffff00" />
<stop offset="95%" stopColor="#ffffff88" />
{roundedBars && <stop offset="100%" stopColor={roundedBars ? '#ffffffbb' : 'white'} />}
</linearGradient>
);
}

View File

@@ -17,8 +17,9 @@ export interface SparklineProps extends Themeable2 {
showHighlights?: boolean;
}
export const SparklineFn: React.FC<SparklineProps> = memo((props) => {
export const Sparkline: React.FC<SparklineProps> = memo((props) => {
const { sparkline, config: fieldConfig, theme, width, height, showHighlights } = props;
const { frame: alignedDataFrame, warning } = prepareSeries(sparkline, theme, fieldConfig, showHighlights);
if (warning) {
return null;
@@ -30,14 +31,4 @@ export const SparklineFn: React.FC<SparklineProps> = memo((props) => {
return <UPlotChart data={data} config={configBuilder} width={width} height={height} />;
});
SparklineFn.displayName = 'Sparkline';
// we converted to function component above, but some apps extend Sparkline, so we need
// to keep exporting a class component until those apps are all rolled out.
// see https://github.com/grafana/app-observability-plugin/pull/2079
// eslint-disable-next-line react-prefer-function-component/react-prefer-function-component
export class Sparkline extends React.PureComponent<SparklineProps> {
render() {
return <SparklineFn {...this.props} />;
}
}
Sparkline.displayName = 'Sparkline';

View File

@@ -167,7 +167,8 @@ export class VizRepeater<V, D = {}> extends PureComponent<PropsWithDefaults<V, D
const repeaterStyle: React.CSSProperties = {
display: 'flex',
overflow: `${minVizWidth ? 'auto' : 'hidden'} ${minVizHeight ? 'auto' : 'hidden'}`,
overflowX: `${minVizWidth ? 'auto' : 'hidden'}`,
overflowY: `${minVizHeight ? 'auto' : 'hidden'}`,
};
let vizHeight = height;

View File

@@ -847,7 +847,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
if err != nil {
return nil, err
}
zanzanaReconciler := dualwrite2.ProvideZanzanaReconciler(cfg, featureToggles, zanzanaClient, sqlStore, serverLockService, folderimplService)
zanzanaReconciler := dualwrite2.ProvideZanzanaReconciler(cfg, featureToggles, zanzanaClient, sqlStore, serverLockService, folderimplService, registerer)
investigationsAppProvider := investigations.RegisterApp(cfg)
appregistryService, err := appregistry.ProvideBuilderRunners(apiserverService, eventualRestConfigProvider, featureToggles, investigationsAppProvider, cfg)
if err != nil {
@@ -1509,7 +1509,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
if err != nil {
return nil, err
}
zanzanaReconciler := dualwrite2.ProvideZanzanaReconciler(cfg, featureToggles, zanzanaClient, sqlStore, serverLockService, folderimplService)
zanzanaReconciler := dualwrite2.ProvideZanzanaReconciler(cfg, featureToggles, zanzanaClient, sqlStore, serverLockService, folderimplService, registerer)
investigationsAppProvider := investigations.RegisterApp(cfg)
appregistryService, err := appregistry.ProvideBuilderRunners(apiserverService, eventualRestConfigProvider, featureToggles, investigationsAppProvider, cfg)
if err != nil {

View 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
}

View 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
}))
}

View File

@@ -30,6 +30,7 @@ import (
"github.com/grafana/grafana/pkg/services/accesscontrol/migrator"
"github.com/grafana/grafana/pkg/services/accesscontrol/permreg"
"github.com/grafana/grafana/pkg/services/accesscontrol/pluginutils"
"github.com/grafana/grafana/pkg/services/accesscontrol/seeding"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
@@ -96,6 +97,12 @@ func ProvideOSSService(
roles: accesscontrol.BuildBasicRoleDefinitions(),
store: store,
permRegistry: permRegistry,
sql: db,
serverLock: lock,
}
if backend, ok := store.(*database.AccessControlStore); ok {
s.seeder = seeding.New(log.New("accesscontrol.seeder"), backend, backend)
}
return s
@@ -112,8 +119,11 @@ type Service struct {
rolesMu sync.RWMutex
roles map[string]*accesscontrol.RoleDTO
store accesscontrol.Store
seeder *seeding.Seeder
permRegistry permreg.PermissionRegistry
isInitialized bool
sql db.DB
serverLock *serverlock.ServerLockService
}
func (s *Service) GetUsageStats(_ context.Context) map[string]any {
@@ -431,17 +441,54 @@ func (s *Service) RegisterFixedRoles(ctx context.Context) error {
defer span.End()
s.rolesMu.Lock()
defer s.rolesMu.Unlock()
registrations := s.registrations.Slice()
s.registrations.Range(func(registration accesscontrol.RoleRegistration) bool {
s.registerRolesLocked(registration)
return true
})
s.isInitialized = true
rolesSnapshot := s.getBasicRolePermissionsLocked()
s.rolesMu.Unlock()
if s.seeder != nil {
if err := s.seeder.SeedRoles(ctx, registrations); err != nil {
return err
}
if err := s.seeder.RemoveAbsentRoles(ctx); err != nil {
return err
}
}
if err := s.refreshBasicRolePermissionsInDB(ctx, rolesSnapshot); err != nil {
return err
}
return nil
}
// getBasicRolePermissionsSnapshotFromRegistrationsLocked computes the desired basic role permissions from the
// current registration list, using the shared seeding registration logic.
//
// it has to be called while holding the roles lock
func (s *Service) getBasicRolePermissionsLocked() map[string][]accesscontrol.Permission {
desired := map[accesscontrol.SeedPermission]struct{}{}
s.registrations.Range(func(registration accesscontrol.RoleRegistration) bool {
seeding.AppendDesiredPermissions(desired, s.log, &registration.Role, registration.Grants, registration.Exclude, true)
return true
})
out := make(map[string][]accesscontrol.Permission)
for sp := range desired {
out[sp.BuiltInRole] = append(out[sp.BuiltInRole], accesscontrol.Permission{
Action: sp.Action,
Scope: sp.Scope,
})
}
return out
}
// registerRolesLocked processes a single role registration and adds permissions to basic roles.
// Must be called with s.rolesMu locked.
func (s *Service) registerRolesLocked(registration accesscontrol.RoleRegistration) {
@@ -474,6 +521,7 @@ func (s *Service) DeclarePluginRoles(ctx context.Context, ID, name string, regs
defer span.End()
acRegs := pluginutils.ToRegistrations(ID, name, regs)
updatedBasicRoles := false
for _, r := range acRegs {
if err := pluginutils.ValidatePluginRole(ID, r.Role); err != nil {
return err
@@ -500,11 +548,23 @@ func (s *Service) DeclarePluginRoles(ctx context.Context, ID, name string, regs
if initialized {
s.rolesMu.Lock()
s.registerRolesLocked(r)
updatedBasicRoles = true
s.rolesMu.Unlock()
s.cache.Flush()
}
}
if updatedBasicRoles {
s.rolesMu.RLock()
rolesSnapshot := s.getBasicRolePermissionsLocked()
s.rolesMu.RUnlock()
// plugin roles can be declared after startup - keep DB in sync
if err := s.refreshBasicRolePermissionsInDB(ctx, rolesSnapshot); err != nil {
return err
}
}
return nil
}

View 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
}

View File

@@ -6,6 +6,8 @@ import (
"strconv"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"go.opentelemetry.io/otel"
claims "github.com/grafana/authlib/types"
@@ -13,6 +15,7 @@ import (
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/serverlock"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/authz/zanzana"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
@@ -33,12 +36,15 @@ type ZanzanaReconciler struct {
store db.DB
client zanzana.Client
lock *serverlock.ServerLockService
metrics struct {
lastSuccess prometheus.Gauge
}
// reconcilers are migrations that tries to reconcile the state of grafana db to zanzana store.
// These are run periodically to try to maintain a consistent state.
reconcilers []resourceReconciler
}
func ProvideZanzanaReconciler(cfg *setting.Cfg, features featuremgmt.FeatureToggles, client zanzana.Client, store db.DB, lock *serverlock.ServerLockService, folderService folder.Service) *ZanzanaReconciler {
func ProvideZanzanaReconciler(cfg *setting.Cfg, features featuremgmt.FeatureToggles, client zanzana.Client, store db.DB, lock *serverlock.ServerLockService, folderService folder.Service, reg prometheus.Registerer) *ZanzanaReconciler {
zanzanaReconciler := &ZanzanaReconciler{
cfg: cfg,
log: reconcilerLogger,
@@ -92,6 +98,13 @@ func ProvideZanzanaReconciler(cfg *setting.Cfg, features featuremgmt.FeatureTogg
},
}
if reg != nil {
zanzanaReconciler.metrics.lastSuccess = promauto.With(reg).NewGauge(prometheus.GaugeOpts{
Name: "grafana_zanzana_reconcile_last_success_timestamp_seconds",
Help: "Unix timestamp (seconds) when the Zanzana reconciler last completed a reconciliation cycle.",
})
}
if cfg.Anonymous.Enabled {
zanzanaReconciler.reconcilers = append(zanzanaReconciler.reconcilers,
newResourceReconciler(
@@ -118,6 +131,9 @@ func (r *ZanzanaReconciler) Run(ctx context.Context) error {
// Reconcile schedules as job that will run and reconcile resources between
// legacy access control and zanzana.
func (r *ZanzanaReconciler) Reconcile(ctx context.Context) error {
// Ensure we don't reconcile an empty/partial RBAC state before OSS has seeded basic role permissions.
// This matters most during startup where fixed-role loading + basic-role permission refresh runs as another background service.
r.waitForBasicRolesSeeded(ctx)
r.reconcile(ctx)
// FIXME:
@@ -133,6 +149,57 @@ func (r *ZanzanaReconciler) Reconcile(ctx context.Context) error {
}
}
func (r *ZanzanaReconciler) hasBasicRolePermissions(ctx context.Context) bool {
var count int64
// Basic role permissions are stored on "basic:%" roles in the global org (0).
// In a fresh DB, this will be empty until fixed roles are registered and the basic role permission refresh runs.
type row struct {
Count int64 `xorm:"count"`
}
_ = r.store.WithDbSession(ctx, func(sess *db.Session) error {
var rr row
_, err := sess.SQL(
`SELECT COUNT(*) AS count
FROM role INNER JOIN permission AS p ON p.role_id = role.id
WHERE role.org_id = ? AND role.name LIKE ?`,
accesscontrol.GlobalOrgID,
accesscontrol.BasicRolePrefix+"%",
).Get(&rr)
if err != nil {
return err
}
count = rr.Count
return nil
})
return count > 0
}
func (r *ZanzanaReconciler) waitForBasicRolesSeeded(ctx context.Context) {
// Best-effort: don't block forever. If we can't observe basic roles, proceed anyway.
const (
maxWait = 15 * time.Second
interval = 1 * time.Second
)
deadline := time.NewTimer(maxWait)
defer deadline.Stop()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
if r.hasBasicRolePermissions(ctx) {
return
}
select {
case <-ctx.Done():
return
case <-deadline.C:
return
case <-ticker.C:
}
}
}
func (r *ZanzanaReconciler) reconcile(ctx context.Context) {
run := func(ctx context.Context, namespace string) {
now := time.Now()
@@ -144,6 +211,9 @@ func (r *ZanzanaReconciler) reconcile(ctx context.Context) {
r.log.Warn("Failed to perform reconciliation for resource", "err", err)
}
}
if r.metrics.lastSuccess != nil {
r.metrics.lastSuccess.SetToCurrentTime()
}
r.log.Debug("Finished reconciliation", "elapsed", time.Since(now))
}

View 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))
}

View File

@@ -1,6 +1,7 @@
package accesscontrol
import (
"context"
"encoding/json"
"errors"
"fmt"
@@ -594,3 +595,18 @@ type QueryWithOrg struct {
OrgId *int64 `json:"orgId"`
Global bool `json:"global"`
}
type SeedPermission struct {
BuiltInRole string `xorm:"builtin_role"`
Action string `xorm:"action"`
Scope string `xorm:"scope"`
Origin string `xorm:"origin"`
}
type RoleStore interface {
LoadRoles(ctx context.Context) (map[string]*RoleDTO, error)
SetRole(ctx context.Context, existingRole *RoleDTO, wantedRole RoleDTO) error
SetPermissions(ctx context.Context, existingRole *RoleDTO, wantedRole RoleDTO) error
CreateRole(ctx context.Context, role RoleDTO) error
DeleteRoles(ctx context.Context, roleUIDs []string) error
}

View 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(&registration.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
}

View File

@@ -78,6 +78,9 @@ func ProvideZanzanaClient(cfg *setting.Cfg, db db.DB, tracer tracing.Tracer, fea
ctx = types.WithAuthInfo(ctx, authnlib.NewAccessTokenAuthInfo(authnlib.Claims[authnlib.AccessTokenClaims]{
Rest: authnlib.AccessTokenClaims{
Namespace: "*",
Permissions: []string{
zanzana.TokenPermissionUpdate,
},
},
}))
return ctx, nil

View File

@@ -4,7 +4,9 @@ import (
"context"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/authz/zanzana"
"github.com/grafana/grafana/pkg/setting"
"golang.org/x/exp/slices"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
@@ -30,3 +32,20 @@ func authorize(ctx context.Context, namespace string, ss setting.ZanzanaServerSe
}
return nil
}
func authorizeWrite(ctx context.Context, namespace string, ss setting.ZanzanaServerSettings) error {
if err := authorize(ctx, namespace, ss); err != nil {
return err
}
c, ok := claims.AuthInfoFrom(ctx)
if !ok {
return status.Errorf(codes.Unauthenticated, "unauthenticated")
}
if !slices.Contains(c.GetTokenPermissions(), zanzana.TokenPermissionUpdate) {
return status.Errorf(codes.PermissionDenied, "missing token permission %s", zanzana.TokenPermissionUpdate)
}
return nil
}

View File

@@ -391,7 +391,7 @@ func setupBenchmarkServer(b *testing.B) (*Server, *benchmarkData) {
b.Logf("Total tuples to write: %d", len(allTuples))
// Get store info
ctx := newContextWithNamespace()
ctx := newContextWithZanzanaUpdatePermission()
storeInf, err := srv.getStoreInfo(ctx, benchNamespace)
require.NoError(b, err)

View File

@@ -8,6 +8,7 @@ import (
openfgav1 "github.com/openfga/api/proto/openfga/v1"
"go.opentelemetry.io/otel/codes"
"google.golang.org/grpc/status"
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
)
@@ -35,6 +36,9 @@ func (s *Server) Mutate(ctx context.Context, req *authzextv1.MutateRequest) (*au
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
if _, ok := status.FromError(err); ok {
return nil, err
}
s.logger.Error("failed to perform mutate request", "error", err, "namespace", req.GetNamespace())
return nil, errors.New("failed to perform mutate request")
}
@@ -43,7 +47,7 @@ func (s *Server) Mutate(ctx context.Context, req *authzextv1.MutateRequest) (*au
}
func (s *Server) mutate(ctx context.Context, req *authzextv1.MutateRequest) (*authzextv1.MutateResponse, error) {
if err := authorize(ctx, req.GetNamespace(), s.cfg); err != nil {
if err := authorizeWrite(ctx, req.GetNamespace(), s.cfg); err != nil {
return nil, err
}

View File

@@ -30,7 +30,7 @@ func testMutateFolders(t *testing.T, srv *Server) {
setupMutateFolders(t, srv)
t.Run("should create new folder parent relation", func(t *testing.T) {
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
_, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{
Namespace: "default",
Operations: []*v1.MutateOperation{
{
@@ -61,7 +61,7 @@ func testMutateFolders(t *testing.T, srv *Server) {
})
t.Run("should delete folder parent relation", func(t *testing.T) {
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
_, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{
Namespace: "default",
Operations: []*v1.MutateOperation{
{
@@ -88,7 +88,7 @@ func testMutateFolders(t *testing.T, srv *Server) {
})
t.Run("should clean up all parent relations", func(t *testing.T) {
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
_, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{
Namespace: "default",
Operations: []*v1.MutateOperation{
{
@@ -115,7 +115,7 @@ func testMutateFolders(t *testing.T, srv *Server) {
})
t.Run("should perform batch mutate if multiple operations are provided", func(t *testing.T) {
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
_, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{
Namespace: "default",
Operations: []*v1.MutateOperation{
{

View File

@@ -25,7 +25,7 @@ func testMutateOrgRoles(t *testing.T, srv *Server) {
setupMutateOrgRoles(t, srv)
t.Run("should update user org role and delete old role", func(t *testing.T) {
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
_, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{
Namespace: "default",
Operations: []*v1.MutateOperation{
{
@@ -63,7 +63,7 @@ func testMutateOrgRoles(t *testing.T, srv *Server) {
})
t.Run("should add user org role and delete old role", func(t *testing.T) {
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
_, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{
Namespace: "default",
Operations: []*v1.MutateOperation{
{

View File

@@ -28,7 +28,7 @@ func testMutateResourcePermissions(t *testing.T, srv *Server) {
setupMutateResourcePermissions(t, srv)
t.Run("should create new resource permission", func(t *testing.T) {
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
_, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{
Namespace: "default",
Operations: []*v1.MutateOperation{
{
@@ -76,7 +76,7 @@ func testMutateResourcePermissions(t *testing.T, srv *Server) {
require.NoError(t, err)
require.Len(t, res.Tuples, 2)
_, err = srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
_, err = srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{
Namespace: "default",
Operations: []*v1.MutateOperation{
{

View File

@@ -25,7 +25,7 @@ func testMutateRoleBindings(t *testing.T, srv *Server) {
setupMutateRoleBindings(t, srv)
t.Run("should update user role and delete old role", func(t *testing.T) {
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
_, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{
Namespace: "default",
Operations: []*v1.MutateOperation{
{
@@ -75,7 +75,7 @@ func testMutateRoleBindings(t *testing.T, srv *Server) {
})
t.Run("should assign role to basic role", func(t *testing.T) {
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
_, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{
Namespace: "default",
Operations: []*v1.MutateOperation{
{

View File

@@ -25,7 +25,7 @@ func testMutateRoles(t *testing.T, srv *Server) {
setupMutateRoles(t, srv)
t.Run("should update role and delete old role permissions", func(t *testing.T) {
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
_, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{
Namespace: "default",
Operations: []*v1.MutateOperation{
{

View File

@@ -25,7 +25,7 @@ func testMutateTeamBindings(t *testing.T, srv *Server) {
setupMutateTeamBindings(t, srv)
t.Run("should update user team binding and delete old team binding", func(t *testing.T) {
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
_, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{
Namespace: "default",
Operations: []*v1.MutateOperation{
{

View File

@@ -5,6 +5,8 @@ import (
openfgav1 "github.com/openfga/api/proto/openfga/v1"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/structpb"
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
@@ -33,7 +35,7 @@ func testMutate(t *testing.T, srv *Server) {
setupMutate(t, srv)
t.Run("should perform multiple mutate operations", func(t *testing.T) {
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
_, err := srv.Mutate(newContextWithZanzanaUpdatePermission(), &v1.MutateRequest{
Namespace: "default",
Operations: []*v1.MutateOperation{
{
@@ -133,6 +135,25 @@ func testMutate(t *testing.T, srv *Server) {
require.NoError(t, err)
require.Len(t, res.Tuples, 0)
})
t.Run("should reject mutate without zanzana:update", func(t *testing.T) {
_, err := srv.Mutate(newContextWithNamespace(), &v1.MutateRequest{
Namespace: "default",
Operations: []*v1.MutateOperation{
{
Operation: &v1.MutateOperation_SetFolderParent{
SetFolderParent: &v1.SetFolderParentOperation{
Folder: "new-folder",
Parent: "1",
DeleteExisting: false,
},
},
},
},
})
require.Error(t, err)
require.Equal(t, codes.PermissionDenied, status.Code(err))
})
}
func TestDeduplicateTupleKeys(t *testing.T) {

View File

@@ -14,6 +14,7 @@ import (
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/authz/zanzana"
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
"github.com/grafana/grafana/pkg/services/authz/zanzana/store"
"github.com/grafana/grafana/pkg/services/sqlstore"
@@ -218,11 +219,21 @@ func setupOpenFGADatabase(t *testing.T, srv *Server, tuples []*openfgav1.TupleKe
}
func newContextWithNamespace() context.Context {
return newContextWithNamespaceAndPermissions()
}
func newContextWithNamespaceAndPermissions(perms ...string) context.Context {
ctx := context.Background()
ctx = claims.WithAuthInfo(ctx, authnlib.NewAccessTokenAuthInfo(authnlib.Claims[authnlib.AccessTokenClaims]{
Rest: authnlib.AccessTokenClaims{
Namespace: "*",
Namespace: "*",
Permissions: perms,
DelegatedPermissions: perms,
},
}))
return ctx
}
func newContextWithZanzanaUpdatePermission() context.Context {
return newContextWithNamespaceAndPermissions(zanzana.TokenPermissionUpdate)
}

View File

@@ -8,6 +8,7 @@ import (
openfgav1 "github.com/openfga/api/proto/openfga/v1"
"go.opentelemetry.io/otel/codes"
"google.golang.org/grpc/status"
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
@@ -25,6 +26,9 @@ func (s *Server) Write(ctx context.Context, req *authzextv1.WriteRequest) (*auth
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
if _, ok := status.FromError(err); ok {
return nil, err
}
s.logger.Error("failed to perform write request", "error", err, "namespace", req.GetNamespace())
return nil, errors.New("failed to perform write request")
}
@@ -33,7 +37,7 @@ func (s *Server) Write(ctx context.Context, req *authzextv1.WriteRequest) (*auth
}
func (s *Server) write(ctx context.Context, req *authzextv1.WriteRequest) (*authzextv1.WriteResponse, error) {
if err := authorize(ctx, req.GetNamespace(), s.cfg); err != nil {
if err := authorizeWrite(ctx, req.GetNamespace(), s.cfg); err != nil {
return nil, err
}

View File

@@ -0,0 +1,46 @@
package server
import (
"testing"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/setting"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
"github.com/stretchr/testify/require"
)
func TestWriteAuthorization(t *testing.T) {
cfg := setting.NewCfg()
testStore := sqlstore.NewTestStore(t, sqlstore.WithCfg(cfg))
srv := setupOpenFGAServer(t, testStore, cfg)
setup(t, srv)
req := &authzextv1.WriteRequest{
Namespace: namespace,
Writes: &authzextv1.WriteRequestWrites{
TupleKeys: []*authzextv1.TupleKey{
{
// Folder parent tuples are valid without any relationship condition.
User: "folder:1",
Relation: common.RelationParent,
Object: "folder:write-authz-test",
},
},
},
}
t.Run("denies Write without zanzana:update", func(t *testing.T) {
_, err := srv.Write(newContextWithNamespace(), req)
require.Error(t, err)
require.Equal(t, codes.PermissionDenied, status.Code(err))
})
t.Run("allows Write with zanzana:update", func(t *testing.T) {
_, err := srv.Write(newContextWithZanzanaUpdatePermission(), req)
require.NoError(t, err)
})
}

View File

@@ -16,6 +16,9 @@ const (
TypeNamespace = common.TypeGroupResouce
)
// TokenPermissionUpdate is required for callers to perform write operations against Zanzana (Mutate/Write).
const TokenPermissionUpdate = "zanzana:update" //nolint:gosec // G101: permission identifier, not a credential.
const (
RelationTeamMember = common.RelationTeamMember
RelationTeamAdmin = common.RelationTeamAdmin

View File

@@ -1,12 +1,13 @@
SELECT
{{ .Ident "created" }},
{{ .Ident "created_by" }},
{{ .Ident "version" }},
{{ .Ident "active" }},
{{ .Ident "namespace" }},
{{ .Ident "name" }}
FROM
{{ .Ident "secret_secure_value" }}
WHERE
WHERE
{{ .Ident "namespace" }} = {{ .Arg .Namespace }} AND
{{ .Ident "name" }} = {{ .Arg .Name }}
ORDER BY {{ .Ident "version" }} DESC

View File

@@ -122,7 +122,7 @@ func (sv *secureValueDB) toKubernetes() (*secretv1beta1.SecureValue, error) {
}
// toCreateRow maps a Kubernetes resource into a DB row for new resources being created/inserted.
func toCreateRow(createdAt, updatedAt int64, keeper string, sv *secretv1beta1.SecureValue, actorUID string) (*secureValueDB, error) {
func toCreateRow(createdAt, updatedAt int64, keeper string, sv *secretv1beta1.SecureValue, createdBy, updatedBy string) (*secureValueDB, error) {
row, err := toRow(keeper, sv, "")
if err != nil {
return nil, fmt.Errorf("failed to convert SecureValue to secureValueDB: %w", err)
@@ -130,9 +130,9 @@ func toCreateRow(createdAt, updatedAt int64, keeper string, sv *secretv1beta1.Se
row.GUID = uuid.New().String()
row.Created = createdAt
row.CreatedBy = actorUID
row.CreatedBy = createdBy
row.Updated = updatedAt
row.UpdatedBy = actorUID
row.UpdatedBy = updatedBy
return row, nil
}

View File

@@ -85,7 +85,7 @@ func (s *secureValueMetadataStorage) Create(ctx context.Context, keeper string,
var row *secureValueDB
err := s.db.Transaction(ctx, func(ctx context.Context) error {
latest, err := s.getLatestVersionAndCreatedAt(ctx, xkube.Namespace(sv.Namespace), sv.Name)
latest, err := s.getLatestVersionAndCreated(ctx, xkube.Namespace(sv.Namespace), sv.Name)
if err != nil {
return fmt.Errorf("fetching latest secure value version: %w", err)
}
@@ -110,7 +110,13 @@ func (s *secureValueMetadataStorage) Create(ctx context.Context, keeper string,
}
updatedAt := now
row, err = toCreateRow(createdAt, updatedAt, keeper, sv, actorUID)
createdBy := actorUID
if latest.createdBy != "" {
createdBy = latest.createdBy
}
updatedBy := actorUID
row, err = toCreateRow(createdAt, updatedAt, keeper, sv, createdBy, updatedBy)
if err != nil {
return fmt.Errorf("to create row: %w", err)
}
@@ -161,13 +167,14 @@ func (s *secureValueMetadataStorage) Create(ctx context.Context, keeper string,
return createdSecureValue, nil
}
type versionAndCreatedAt struct {
type versionAndCreated struct {
createdAt int64
createdBy string
version int64
}
func (s *secureValueMetadataStorage) getLatestVersionAndCreatedAt(ctx context.Context, namespace xkube.Namespace, name string) (versionAndCreatedAt, error) {
ctx, span := s.tracer.Start(ctx, "SecureValueMetadataStorage.getLatestVersionAndCreatedAt", trace.WithAttributes(
func (s *secureValueMetadataStorage) getLatestVersionAndCreated(ctx context.Context, namespace xkube.Namespace, name string) (versionAndCreated, error) {
ctx, span := s.tracer.Start(ctx, "SecureValueMetadataStorage.getLatestVersionAndCreated", trace.WithAttributes(
attribute.String("name", name),
attribute.String("namespace", namespace.String()),
))
@@ -181,45 +188,48 @@ func (s *secureValueMetadataStorage) getLatestVersionAndCreatedAt(ctx context.Co
q, err := sqltemplate.Execute(sqlGetLatestSecureValueVersionAndCreatedAt, req)
if err != nil {
return versionAndCreatedAt{}, fmt.Errorf("execute template %q: %w", sqlGetLatestSecureValueVersionAndCreatedAt.Name(), err)
return versionAndCreated{}, fmt.Errorf("execute template %q: %w", sqlGetLatestSecureValueVersionAndCreatedAt.Name(), err)
}
rows, err := s.db.QueryContext(ctx, q, req.GetArgs()...)
if err != nil {
return versionAndCreatedAt{}, fmt.Errorf("fetching latest version for secure value: namespace=%+v name=%+v %w", namespace, name, err)
return versionAndCreated{}, fmt.Errorf("fetching latest version for secure value: namespace=%+v name=%+v %w", namespace, name, err)
}
defer func() { _ = rows.Close() }()
if err := rows.Err(); err != nil {
return versionAndCreatedAt{}, fmt.Errorf("error executing query: %w", err)
return versionAndCreated{}, fmt.Errorf("error executing query: %w", err)
}
if !rows.Next() {
return versionAndCreatedAt{}, nil
return versionAndCreated{}, nil
}
var (
createdAt int64
createdBy string
version int64
active bool
namespaceFromDB string
nameFromDB string
)
if err := rows.Scan(&createdAt, &version, &active, &namespaceFromDB, &nameFromDB); err != nil {
return versionAndCreatedAt{}, fmt.Errorf("scanning version from returned rows: %w", err)
if err := rows.Scan(&createdAt, &createdBy, &version, &active, &namespaceFromDB, &nameFromDB); err != nil {
return versionAndCreated{}, fmt.Errorf("scanning version and created from returned rows: %w", err)
}
if namespaceFromDB != namespace.String() || nameFromDB != name {
return versionAndCreatedAt{}, fmt.Errorf("bug: expected to find latest version for namespace=%+v name=%+v but got version for namespace=%+v name=%+v",
return versionAndCreated{}, fmt.Errorf("bug: expected to find version and created for namespace=%+v name=%+v but got for namespace=%+v name=%+v",
namespace, name, namespaceFromDB, nameFromDB)
}
if !active {
createdAt = 0
createdBy = ""
}
return versionAndCreatedAt{
return versionAndCreated{
createdAt: createdAt,
createdBy: createdBy,
version: version,
}, nil
}

View File

@@ -1,12 +1,13 @@
SELECT
`created`,
`created_by`,
`version`,
`active`,
`namespace`,
`name`
FROM
`secret_secure_value`
WHERE
WHERE
`namespace` = 'ns' AND
`name` = 'name'
ORDER BY `version` DESC

View File

@@ -1,12 +1,13 @@
SELECT
"created",
"created_by",
"version",
"active",
"namespace",
"name"
FROM
"secret_secure_value"
WHERE
WHERE
"namespace" = 'ns' AND
"name" = 'name'
ORDER BY "version" DESC

View File

@@ -1,12 +1,13 @@
SELECT
"created",
"created_by",
"version",
"active",
"namespace",
"name"
FROM
"secret_secure_value"
WHERE
WHERE
"namespace" = 'ns' AND
"name" = 'name'
ORDER BY "version" DESC

View File

@@ -25,6 +25,7 @@ import (
bleveSearch "github.com/blevesearch/bleve/v2/search/searcher"
index "github.com/blevesearch/bleve_index_api"
"github.com/prometheus/client_golang/prometheus"
bolterrors "go.etcd.io/bbolt/errors"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.uber.org/atomic"
@@ -44,6 +45,7 @@ import (
const (
indexStorageMemory = "memory"
indexStorageFile = "file"
boltTimeout = "500ms"
)
// Keys used to store internal data in index.
@@ -415,14 +417,25 @@ func (b *bleveBackend) BuildIndex(
// This happens on startup, or when memory-based index has expired. (We don't expire file-based indexes)
// If we do have an unexpired cached index already, we always build a new index from scratch.
if cachedIndex == nil && !rebuild {
index, fileIndexName, indexRV = b.findPreviousFileBasedIndex(resourceDir)
result := b.findPreviousFileBasedIndex(resourceDir)
if result != nil && result.IsOpen {
// Index file exists but is opened by another process, fallback to memory.
// Keep the name so we can skip cleanup of that directory.
newIndexType = indexStorageMemory
fileIndexName = result.Name
} else if result != nil && result.Index != nil {
// Found and opened existing index successfully
index = result.Index
fileIndexName = result.Name
indexRV = result.RV
}
}
if index != nil {
if newIndexType == indexStorageFile && index != nil {
build = false
logWithDetails.Debug("Existing index found on filesystem", "indexRV", indexRV, "directory", filepath.Join(resourceDir, fileIndexName))
defer closeIndexOnExit(index, "") // Close index, but don't delete directory.
} else {
} else if newIndexType == indexStorageFile {
// Building index from scratch. Index name has a time component in it to be unique, but if
// we happen to create non-unique name, we bump the time and try again.
@@ -449,7 +462,9 @@ func (b *bleveBackend) BuildIndex(
logWithDetails.Info("Building index using filesystem", "directory", indexDir)
defer closeIndexOnExit(index, indexDir) // Close index, and delete new index directory.
}
} else {
}
if newIndexType == indexStorageMemory {
index, err = newBleveIndex("", mapper, time.Now(), b.opts.BuildVersion)
if err != nil {
return nil, fmt.Errorf("error creating new in-memory bleve index: %w", err)
@@ -552,30 +567,30 @@ func cleanFileSegment(input string) string {
return input
}
// cleanOldIndexes deletes all subdirectories inside dir, skipping directory with "skipName".
// cleanOldIndexes deletes all subdirectories inside resourceDir, skipping directory with "skipName".
// "skipName" can be empty.
func (b *bleveBackend) cleanOldIndexes(dir string, skipName string) {
files, err := os.ReadDir(dir)
func (b *bleveBackend) cleanOldIndexes(resourceDir string, skipName string) {
entries, err := os.ReadDir(resourceDir)
if err != nil {
if os.IsNotExist(err) {
return
}
b.log.Warn("error cleaning folders from", "directory", dir, "error", err)
b.log.Warn("error cleaning folders from", "directory", resourceDir, "error", err)
return
}
for _, file := range files {
if file.IsDir() && file.Name() != skipName {
fpath := filepath.Join(dir, file.Name())
if !isPathWithinRoot(fpath, b.opts.Root) {
b.log.Warn("Skipping cleanup of directory", "directory", fpath)
for _, ent := range entries {
if ent.IsDir() && ent.Name() != skipName {
indexDir := filepath.Join(resourceDir, ent.Name())
if !isPathWithinRoot(indexDir, b.opts.Root) {
b.log.Warn("Skipping cleanup of directory", "directory", indexDir)
continue
}
err = os.RemoveAll(fpath)
err = os.RemoveAll(indexDir)
if err != nil {
b.log.Error("Unable to remove old index folder", "directory", fpath, "error", err)
b.log.Error("Unable to remove old index folder", "directory", indexDir, "error", err)
} else {
b.log.Info("Removed old index folder", "directory", fpath)
b.log.Info("Removed old index folder", "directory", indexDir)
}
}
}
@@ -622,10 +637,17 @@ func formatIndexName(now time.Time) string {
return now.Format("20060102-150405")
}
func (b *bleveBackend) findPreviousFileBasedIndex(resourceDir string) (bleve.Index, string, int64) {
type fileIndex struct {
Index bleve.Index
Name string
RV int64
IsOpen bool
}
func (b *bleveBackend) findPreviousFileBasedIndex(resourceDir string) *fileIndex {
entries, err := os.ReadDir(resourceDir)
if err != nil {
return nil, "", 0
return nil
}
for _, ent := range entries {
@@ -635,8 +657,13 @@ func (b *bleveBackend) findPreviousFileBasedIndex(resourceDir string) (bleve.Ind
indexName := ent.Name()
indexDir := filepath.Join(resourceDir, indexName)
idx, err := bleve.Open(indexDir)
idx, err := bleve.OpenUsing(indexDir, map[string]interface{}{"bolt_timeout": boltTimeout})
if err != nil {
if errors.Is(err, bolterrors.ErrTimeout) {
b.log.Debug("Index is opened by another process (timeout), skipping", "indexDir", indexDir)
return &fileIndex{Name: indexName, IsOpen: true}
}
b.log.Debug("error opening index", "indexDir", indexDir, "err", err)
continue
}
@@ -648,10 +675,14 @@ func (b *bleveBackend) findPreviousFileBasedIndex(resourceDir string) (bleve.Ind
continue
}
return idx, indexName, indexRV
return &fileIndex{
Index: idx,
Name: indexName,
RV: indexRV,
}
}
return nil, "", 0
return nil
}
// Stop closes all indexes and stops background tasks.

View File

@@ -1583,3 +1583,76 @@ func docCount(t *testing.T, idx resource.ResourceIndex) int {
require.NoError(t, err)
return int(cnt)
}
func TestBleveBackendFallsBackToMemory(t *testing.T) {
ns := resource.NamespacedResource{
Namespace: "test",
Group: "group",
Resource: "resource",
}
tmpDir := t.TempDir()
// First, create a file-based index with one backend and keep it open
backend1, reg1 := setupBleveBackend(t, withRootDir(tmpDir))
index1, err := backend1.BuildIndex(context.Background(), ns, 100 /* file based */, nil, "test", indexTestDocs(ns, 10, 100), nil, false)
require.NoError(t, err)
require.NotNil(t, index1)
// Verify first index is file-based
bleveIdx1, ok := index1.(*bleveIndex)
require.True(t, ok)
require.Equal(t, indexStorageFile, bleveIdx1.indexStorage)
checkOpenIndexes(t, reg1, 0, 1)
// Now create a second backend using the same directory
// This simulates another instance trying to open the same index
backend2, reg2 := setupBleveBackend(t, withRootDir(tmpDir))
// BuildIndex should detect the file is locked and fallback to memory
index2, err := backend2.BuildIndex(context.Background(), ns, 100 /* file based */, nil, "test", indexTestDocs(ns, 10, 100), nil, false)
require.NoError(t, err)
require.NotNil(t, index2)
// Verify second index fell back to in-memory despite size being above file threshold
bleveIdx2, ok := index2.(*bleveIndex)
require.True(t, ok)
require.Equal(t, indexStorageMemory, bleveIdx2.indexStorage)
// Verify metrics show 1 memory index and 0 file indexes for backend2
checkOpenIndexes(t, reg2, 1, 0)
// Verify the in-memory index works correctly
require.Equal(t, 10, docCount(t, index2))
// Clean up: close first backend to release the file lock
backend1.Stop()
}
func TestBleveSkipCleanOldIndexesOnMemoryFallback(t *testing.T) {
ns := resource.NamespacedResource{
Namespace: "test",
Group: "group",
Resource: "resource",
}
tmpDir := t.TempDir()
backend1, _ := setupBleveBackend(t, withRootDir(tmpDir))
_, err := backend1.BuildIndex(context.Background(), ns, 100 /* file based */, nil, "test", indexTestDocs(ns, 10, 100), nil, false)
require.NoError(t, err)
// Now create a second backend using the same directory
// This simulates another instance trying to open the same index
backend2, _ := setupBleveBackend(t, withRootDir(tmpDir))
// BuildIndex should detect the file is locked and fallback to memory
_, err = backend2.BuildIndex(context.Background(), ns, 100 /* file based */, nil, "test", indexTestDocs(ns, 10, 100), nil, false)
require.NoError(t, err)
// Verify that the index directory still exists (i.e., cleanOldIndexes was skipped)
verifyDirEntriesCount(t, backend2.getResourceDir(ns), 1)
// Clean up: close first backend to release the file lock
backend1.Stop()
}

View File

@@ -2470,7 +2470,7 @@ var expNonEmailNotifications = map[string][]string{
"title_link": "http://localhost:3000/alerting/grafana/UID_SlackAlert1/view?orgId=1",
"text": "Integration Test ",
"fallback": "Integration Test [FIRING:1] SlackAlert1 (default)",
"footer": "Grafana v",
"footer": "Grafana",
"footer_icon": "https://grafana.com/static/assets/img/fav32.png",
"color": "#D63232",
"ts": %s,
@@ -2490,7 +2490,7 @@ var expNonEmailNotifications = map[string][]string{
"title_link": "http://localhost:3000/alerting/grafana/UID_SlackAlert2/view?orgId=1",
"text": "**Firing**\n\nValue: A=1\nLabels:\n - alertname = SlackAlert2\n - grafana_folder = default\nAnnotations:\nSource: http://localhost:3000/alerting/grafana/UID_SlackAlert2/view?orgId=1\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=__alert_rule_uid__%%3DUID_SlackAlert2&orgId=1\n",
"fallback": "[FIRING:1] SlackAlert2 (default)",
"footer": "Grafana v",
"footer": "Grafana",
"footer_icon": "https://grafana.com/static/assets/img/fav32.png",
"color": "#D63232",
"ts": %s,

View File

@@ -2699,6 +2699,24 @@
"secure": false,
"dependsOn": "",
"subformOptions": null
},
{
"element": "input",
"inputType": "text",
"label": "Footer",
"description": "Templated footer of the slack message",
"placeholder": "{{ template \"slack.default.footer\" . }}",
"propertyName": "footer",
"selectOptions": null,
"showWhen": {
"field": "",
"is": ""
},
"required": false,
"validationRule": "",
"secure": false,
"dependsOn": "",
"subformOptions": null
}
]
},

View File

@@ -7017,6 +7017,24 @@
"secure": false,
"dependsOn": "",
"subformOptions": null
},
{
"element": "input",
"inputType": "text",
"label": "Footer",
"description": "Templated footer of the slack message",
"placeholder": "{{ template \"slack.default.footer\" . }}",
"propertyName": "footer",
"selectOptions": null,
"showWhen": {
"field": "",
"is": ""
},
"required": false,
"validationRule": "",
"secure": false,
"dependsOn": "",
"subformOptions": null
}
]
},

View File

@@ -132,53 +132,6 @@ func TestIntegrationDashboardAPIValidation(t *testing.T) {
}
}
// TestIntegrationDashboardAPIAuthorization tests the dashboard K8s API with authorization checks
func TestIntegrationDashboardAPIAuthorization(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
dualWriterModes := []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode2, rest.Mode3, rest.Mode4, rest.Mode5}
for _, dualWriterMode := range dualWriterModes {
t.Run(fmt.Sprintf("DualWriterMode %d", dualWriterMode), func(t *testing.T) {
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
DisableDataMigrations: true,
DisableAnonymous: true,
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
"dashboards.dashboard.grafana.app": {
DualWriterMode: dualWriterMode,
},
"folders.folder.grafana.app": {
DualWriterMode: dualWriterMode,
},
},
UnifiedStorageEnableSearch: true,
})
t.Cleanup(func() {
helper.Shutdown()
})
org1Ctx := createTestContext(t, helper, helper.Org1, dualWriterMode)
org2Ctx := createTestContext(t, helper, helper.OrgB, dualWriterMode)
t.Run("Authorization tests for all identity types", func(t *testing.T) {
runAuthorizationTests(t, org1Ctx)
})
t.Run("Dashboard permission tests", func(t *testing.T) {
runDashboardPermissionTests(t, org1Ctx, true)
})
t.Run("Cross-organization tests", func(t *testing.T) {
runCrossOrgTests(t, org1Ctx, org2Ctx)
})
t.Run("Dashboard HTTP API test", func(t *testing.T) {
runDashboardHttpTest(t, org1Ctx, org2Ctx)
})
})
}
}
// TestIntegrationDashboardAPI tests the dashboard K8s API
func TestIntegrationDashboardAPI(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)

View File

@@ -8,6 +8,7 @@ import (
"net/http"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/xlab/treeprint"
@@ -31,6 +32,33 @@ import (
"github.com/grafana/grafana/pkg/util/testutil"
)
func TestIntegrationFolderTreeZanzana(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
runIntegrationFolderTree(t, testinfra.GrafanaOpts{
DisableDataMigrations: true,
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
"dashboards.dashboard.grafana.app": {
DualWriterMode: grafanarest.Mode5,
},
folderV1.RESOURCEGROUP: {
DualWriterMode: grafanarest.Mode5,
},
},
EnableFeatureToggles: []string{
"zanzana",
"zanzanaNoLegacyClient",
"kubernetesAuthzZanzanaSync",
},
UnifiedStorageEnableSearch: true,
ZanzanaReconciliationInterval: 100 * time.Millisecond,
DisableZanzanaCache: true,
})
}
func TestIntegrationFolderTree(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
@@ -47,7 +75,7 @@ func TestIntegrationFolderTree(t *testing.T) {
}
for _, mode := range modes {
t.Run(fmt.Sprintf("mode %d", mode), func(t *testing.T) {
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
runIntegrationFolderTree(t, testinfra.GrafanaOpts{
DisableDataMigrations: true,
AppModeProduction: true,
DisableAnonymous: true,
@@ -62,113 +90,122 @@ func TestIntegrationFolderTree(t *testing.T) {
},
UnifiedStorageEnableSearch: mode >= grafanarest.Mode3, // make sure modes 0-3 work without search enabled
})
defer helper.Shutdown()
})
}
}
tests := []struct {
Name string
Definition FolderDefinition
Expected []ExpectedTree
}{
{
Name: "admin-only-tree",
Definition: FolderDefinition{
func runIntegrationFolderTree(t *testing.T, opts testinfra.GrafanaOpts) {
if !db.IsTestDbSQLite() {
t.Skip("test only on sqlite for now")
}
helper := apis.NewK8sTestHelper(t, opts)
defer helper.Shutdown()
apis.AwaitZanzanaReconcileNext(t, helper)
tests := []struct {
Name string
Definition FolderDefinition
Expected []ExpectedTree
}{
{
Name: "admin-only-tree",
Definition: FolderDefinition{
Children: []FolderDefinition{
{Name: "top",
Creator: helper.Org1.Admin,
Children: []FolderDefinition{
{Name: "top",
{Name: "middle",
Creator: helper.Org1.Admin,
Children: []FolderDefinition{
{Name: "middle",
{Name: "child",
Creator: helper.Org1.Admin,
Children: []FolderDefinition{
{Name: "child",
Creator: helper.Org1.Admin,
Permissions: []FolderPermission{{
Permission: "View",
User: helper.Org1.None,
}},
},
},
Permissions: []FolderPermission{{
Permission: "View",
User: helper.Org1.None,
}},
},
},
},
},
},
Expected: []ExpectedTree{
{User: helper.Org1.Admin, Listing: `
},
},
Expected: []ExpectedTree{
{User: helper.Org1.Admin, Listing: `
└── top (admin,edit,save,delete)
....└── middle (admin,edit,save,delete)
........└── child (admin,edit,save,delete)`},
{User: helper.Org1.Viewer, Listing: `
{User: helper.Org1.Viewer, Listing: `
└── top (view)
....└── middle (view)
........└── child (view)`},
{User: helper.Org1.None, Listing: `
{User: helper.Org1.None, Listing: `
└── sharedwithme (???)
....└── child (view)`,
E403: []string{"top", "middle"},
},
},
E403: []string{"top", "middle"},
},
}
},
},
}
var statusCode int
for _, tt := range tests {
t.Run(tt.Name, func(t *testing.T) {
tt.Definition.RequireUniqueName(t, make(map[string]bool))
var statusCode int
for _, tt := range tests {
t.Run(tt.Name, func(t *testing.T) {
tt.Definition.RequireUniqueName(t, make(map[string]bool))
tt.Definition.CreateWithLegacyAPI(t, helper, "")
// CreateWithLegacyAPI
tt.Definition.CreateWithLegacyAPI(t, helper, "")
for _, expect := range tt.Expected {
unstructured, client := getFolderClients(t, expect.User)
t.Run(fmt.Sprintf("query as %s", expect.User.Identity.GetLogin()), func(t *testing.T) {
legacy := getFoldersFromLegacyAPISearch(t, client)
legacy.requireEqual(t, expect.Listing, "legacy")
for _, expect := range tt.Expected {
unstructured, client := getFolderClients(t, expect.User)
t.Run(fmt.Sprintf("query as %s", expect.User.Identity.GetLogin()), func(t *testing.T) {
legacy := getFoldersFromLegacyAPISearch(t, client)
legacy.requireEqual(t, expect.Listing, "legacy")
listed := getFoldersFromAPIServerList(t, unstructured)
listed.requireEqual(t, expect.Listing, "listed")
listed := getFoldersFromAPIServerList(t, unstructured)
listed.requireEqual(t, expect.Listing, "listed")
search := getFoldersFromDashboardV0Search(t, client, expect.User.Identity.GetNamespace())
search.requireEqual(t, expect.Listing, "search")
search := getFoldersFromDashboardV0Search(t, client, expect.User.Identity.GetNamespace())
search.requireEqual(t, expect.Listing, "search")
// ensure sure GET also works on each folder we can list
listed.forEach(func(fv *FolderView) {
if fv.Name == folder.SharedWithMeFolderUID {
return // skip it
}
found, err := unstructured.Get(context.Background(), fv.Name, v1.GetOptions{})
require.NoErrorf(t, err, "getting folder: %s", fv.Name)
require.Equal(t, found.GetName(), fv.Name)
})
// ensure sure GET also works on each folder we can list
listed.forEach(func(fv *FolderView) {
if fv.Name == folder.SharedWithMeFolderUID {
return // skip it
}
found, err := unstructured.Get(context.Background(), fv.Name, v1.GetOptions{})
require.NoErrorf(t, err, "getting folder: %s", fv.Name)
require.Equal(t, found.GetName(), fv.Name)
})
// Forbidden things should really be hidden
for _, name := range expect.E403 {
_, err := unstructured.Get(context.Background(), name, v1.GetOptions{})
require.Error(t, err)
require.Truef(t, apierrors.IsForbidden(err), "error: %w", err) // 404 vs 403 ????
// Forbidden things should really be hidden
for _, name := range expect.E403 {
_, err := unstructured.Get(context.Background(), name, v1.GetOptions{})
require.Error(t, err)
require.Truef(t, apierrors.IsForbidden(err), "error: %w", err) // 404 vs 403 ????
result := client.Get().AbsPath("api", "folders", name).
Do(context.Background()).
StatusCode(&statusCode)
require.Equal(t, int(http.StatusForbidden), statusCode)
require.Error(t, result.Error())
result := client.Get().AbsPath("api", "folders", name).
Do(context.Background()).
StatusCode(&statusCode)
require.Equal(t, int(http.StatusForbidden), statusCode)
require.Error(t, result.Error())
// Verify sub-resources are hidden
for _, sub := range []string{"access", "parents", "children", "counts"} {
_, err := unstructured.Get(context.Background(), name, v1.GetOptions{}, sub)
require.Error(t, err, "expect error for subresource", sub)
require.Truef(t, apierrors.IsForbidden(err), "error: %w", err) // 404 vs 403 ????
}
// Verify sub-resources are hidden
for _, sub := range []string{"access", "parents", "children", "counts"} {
_, err := unstructured.Get(context.Background(), name, v1.GetOptions{}, sub)
require.Error(t, err, "expect error for subresource", sub)
require.Truef(t, apierrors.IsForbidden(err), "error: %w", err) // 404 vs 403 ????
}
// Verify legacy API access is also hidden
for _, sub := range []string{"permissions", "counts"} {
result := client.Get().AbsPath("api", "folders", name, sub).
Do(context.Background()).
StatusCode(&statusCode)
require.Equalf(t, int(http.StatusForbidden), statusCode, "legacy access to: %s", sub)
require.Error(t, result.Error())
}
}
})
// Verify legacy API access is also hidden
for _, sub := range []string{"permissions", "counts"} {
result := client.Get().AbsPath("api", "folders", name, sub).
Do(context.Background()).
StatusCode(&statusCode)
require.Equalf(t, int(http.StatusForbidden), statusCode, "legacy access to: %s", sub)
require.Error(t, result.Error())
}
}
})
}
@@ -212,6 +249,8 @@ func (f *FolderDefinition) CreateWithLegacyAPI(t *testing.T, h *apis.K8sTestHelp
})
require.NoError(t, err)
apis.AwaitZanzanaReconcileNext(t, h)
var statusCode int
result := client.Post().AbsPath("api", "folders").
Body(body).

View File

@@ -0,0 +1,87 @@
package apis
import (
"bytes"
"context"
"net/http"
"testing"
"time"
dto "github.com/prometheus/client_model/go"
"github.com/prometheus/common/expfmt"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/services/featuremgmt"
)
const zanzanaReconcileLastSuccessMetric = "grafana_zanzana_reconcile_last_success_timestamp_seconds"
// AwaitZanzanaReconcileNext waits for the next Zanzana reconciliation cycle to complete.
// It is a no-op unless the `zanzana` feature toggle is enabled for the running test env.
func AwaitZanzanaReconcileNext(t *testing.T, helper *K8sTestHelper) {
t.Helper()
enabled := false
if helper != nil {
enabled = helper.GetEnv().FeatureToggles.GetEnabled(context.Background())[featuremgmt.FlagZanzana]
}
if helper == nil || !enabled {
return
}
prev, ok := getZanzanaReconcileLastSuccessTimestampSeconds(t, helper)
if !ok {
prev = 0
}
require.EventuallyWithT(t, func(c *assert.CollectT) {
ts, ok := getZanzanaReconcileLastSuccessTimestampSeconds(t, helper)
assert.True(c, ok, "expected to find %s in /metrics", zanzanaReconcileLastSuccessMetric)
if !ok {
return
}
assert.Greater(c, ts, prev, "expected %s (%v) > %v", zanzanaReconcileLastSuccessMetric, ts, prev)
}, 30*time.Second, 50*time.Millisecond)
}
func getZanzanaReconcileLastSuccessTimestampSeconds(t *testing.T, helper *K8sTestHelper) (float64, bool) {
t.Helper()
rsp := DoRequest(helper, RequestParams{
User: helper.Org1.Admin,
Path: "/metrics",
Accept: "text/plain",
}, &struct{}{})
if rsp.Response == nil || rsp.Response.StatusCode != http.StatusOK {
return 0, false
}
parser := expfmt.NewTextParser(model.UTF8Validation)
metrics, err := parser.TextToMetricFamilies(bytes.NewReader(rsp.Body))
if err != nil {
return 0, false
}
metric := metrics[zanzanaReconcileLastSuccessMetric]
if metric == nil || len(metric.Metric) == 0 {
return 0, false
}
m := metric.Metric[0]
switch metric.GetType() {
case dto.MetricType_GAUGE:
if m.Gauge == nil {
return 0, false
}
return m.Gauge.GetValue(), true
case dto.MetricType_UNTYPED:
if m.Untyped == nil {
return 0, false
}
return m.Untyped.GetValue(), true
default:
return 0, false
}
}

View File

@@ -609,6 +609,20 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) {
require.NoError(t, err)
}
if opts.ZanzanaReconciliationInterval != 0 {
rbacSect, err := cfg.NewSection("rbac")
require.NoError(t, err)
_, err = rbacSect.NewKey("zanzana_reconciliation_interval", opts.ZanzanaReconciliationInterval.String())
require.NoError(t, err)
}
if opts.DisableZanzanaCache {
rbacSect, err := cfg.NewSection("rbac")
require.NoError(t, err)
_, err = rbacSect.NewKey("disable_zanzana_cache", "true")
require.NoError(t, err)
}
dashboardsSection, err := getOrCreateSection("dashboards")
require.NoError(t, err)
_, err = dashboardsSection.NewKey("min_refresh_interval", "10s")
@@ -687,6 +701,8 @@ type GrafanaOpts struct {
SecretsManagerEnableDBMigrations bool
OpenFeatureAPIEnabled bool
DisableAuthZClientCache bool
ZanzanaReconciliationInterval time.Duration
DisableZanzanaCache bool
// Allow creating grafana dir beforehand
Dir string

View File

@@ -33,11 +33,11 @@ func (ds *DataSource) parseResponse(ctx context.Context, metricDataOutputs []*cl
dataRes := backend.DataResponse{}
if response.HasArithmeticError {
dataRes.Error = fmt.Errorf("ArithmeticError in query %q: %s", queryRow.RefId, response.ArithmeticErrorMessage)
dataRes.Error = backend.DownstreamErrorf("ArithmeticError in query %q: %s", queryRow.RefId, response.ArithmeticErrorMessage)
}
if response.HasPermissionError {
dataRes.Error = fmt.Errorf("PermissionError in query %q: %s", queryRow.RefId, response.PermissionErrorMessage)
dataRes.Error = backend.DownstreamErrorf("PermissionError in query %q: %s", queryRow.RefId, response.PermissionErrorMessage)
}
var err error

View File

@@ -24,7 +24,7 @@ func (e *elasticsearchDataQuery) processQuery(q *Query, ms *es.MultiSearchReques
filters.AddDateRangeFilter(defaultTimeField, to, from, es.DateFormatEpochMS)
filters.AddQueryStringFilter(q.RawQuery, true)
if q.EditorType != nil && *q.EditorType == "code" && q.RawDSLQuery != "" {
if q.EditorType != nil && *q.EditorType == "code" {
cfg := backend.GrafanaConfigFromContext(e.ctx)
if !cfg.FeatureToggles().IsEnabled("elasticsearchRawDSLQuery") {
return backend.DownstreamError(fmt.Errorf("raw DSL query feature is disabled. Enable the elasticsearchRawDSLQuery feature toggle to use this query type"))

View File

@@ -7,7 +7,7 @@ import (
// isQueryWithError validates the query and returns an error if invalid
func isQueryWithError(query *Query) error {
// Skip validation for raw DSL queries because no easy way to see it is valid without just running it
if query.EditorType != nil && *query.EditorType == "code" && query.RawDSLQuery != "" {
if query.EditorType != nil && *query.EditorType == "code" {
return nil
}
if len(query.BucketAggs) == 0 {

View File

@@ -192,7 +192,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<UPlotConfigOptions> = (
});
const xField = frame.fields[0];
const xAxisHidden = xField.config.custom.axisPlacement === AxisPlacement.Hidden;
const xAxisHidden = xField.config.custom?.axisPlacement === AxisPlacement.Hidden;
builder.addAxis({
show: !xAxisHidden,

View File

@@ -4,7 +4,7 @@ import { DataFrame, DataTransformerID, standardTransformersRegistry, Transformer
import { selectors } from '@grafana/e2e-selectors';
import { t, Trans } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { Box, Button, Grid, Stack, Text } from '@grafana/ui';
import { Box, Button, Stack, Text } from '@grafana/ui';
import config from 'app/core/config';
import { SqlExpressionCard } from '../../../dashboard/components/TransformationsEditor/SqlExpressionCard';
@@ -26,9 +26,6 @@ const TRANSFORMATION_IDS = [
DataTransformerID.filterByValue,
];
const GRID_COLUMNS_WITH_SQL = 5;
const GRID_COLUMNS_WITHOUT_SQL = 4;
export function LegacyEmptyTransformationsMessage({ onShowPicker }: { onShowPicker: () => void }) {
return (
<Box alignItems="center" padding={4}>
@@ -94,13 +91,25 @@ export function NewEmptyTransformationsMessage(props: EmptyTransformationsProps)
};
const showSqlCard = hasGoToQueries && config.featureToggles.sqlExpressions;
const gridColumns = showSqlCard ? GRID_COLUMNS_WITH_SQL : GRID_COLUMNS_WITHOUT_SQL;
return (
<Box alignItems="center" padding={4}>
<Stack direction="column" alignItems="center" gap={4}>
<Box padding={2}>
<Stack direction="column" alignItems="start" gap={2}>
<Stack direction="column" alignItems="start" gap={1}>
<Text element="h3" textAlignment="start">
<Trans i18nKey="transformations.empty.add-transformation-header">Add a Transformation</Trans>
</Text>
<Text element="p" textAlignment="start" color="secondary">
<Trans i18nKey="transformations.empty.add-transformation-body">
Transformations allow data to be changed in various ways before your visualization is shown.
<br />
This includes joining data together, renaming fields, making calculations, formatting data for display,
and more.
</Trans>
</Text>
</Stack>
{(hasAddTransformation || hasGoToQueries) && (
<Grid columns={gridColumns} gap={1}>
<Stack direction="row" gap={1} wrap>
{showSqlCard && (
<SqlExpressionCard
name={t('dashboard-scene.empty-transformations-message.sql-name', 'Transform with SQL')}
@@ -125,19 +134,17 @@ export function NewEmptyTransformationsMessage(props: EmptyTransformationsProps)
data={props.data}
/>
))}
</Grid>
</Stack>
)}
<Stack direction="row" gap={2}>
<Button
icon="plus"
variant="primary"
size="md"
onClick={handleShowMoreClick}
data-testid={selectors.components.Transforms.addTransformationButton}
>
<Trans i18nKey="dashboard-scene.empty-transformations-message.show-more">Show more</Trans>
</Button>
</Stack>
<Button
icon="plus"
variant="primary"
size="md"
onClick={handleShowMoreClick}
data-testid={selectors.components.Transforms.addTransformationButton}
>
<Trans i18nKey="dashboard-scene.empty-transformations-message.show-more">Show more</Trans>
</Button>
</Stack>
</Box>
);

View File

@@ -112,6 +112,37 @@ describe('PanelEditor', () => {
});
});
describe('Entering panel edit', () => {
it('should clear edit pane selection', () => {
pluginPromise = Promise.resolve(getPanelPlugin({ id: 'text', skipDataQuery: true }));
const panel = new VizPanel({
key: 'panel-1',
pluginId: 'text',
title: 'original title',
});
const gridItem = new DashboardGridItem({ body: panel });
const panelEditor = buildPanelEditScene(panel);
const dashboard = new DashboardScene({
editPanel: panelEditor,
isEditing: true,
$timeRange: new SceneTimeRange({ from: 'now-6h', to: 'now' }),
body: new DefaultGridLayoutManager({
grid: new SceneGridLayout({
children: [gridItem],
}),
}),
});
dashboard.state.editPane.selectObject(panel, panel.state.key!, { force: true });
expect(dashboard.state.editPane.getSelection()).toBe(panel);
deactivate = activateFullSceneTree(dashboard);
expect(dashboard.state.editPane.getSelection()).toBeUndefined();
});
});
describe('When discarding', () => {
it('should discard changes revert all changes', async () => {
const { panelEditor, panel, dashboard } = await setup();

View File

@@ -51,6 +51,7 @@ export interface PanelEditorState extends SceneObjectState {
panelRef: SceneObjectRef<VizPanel>;
showLibraryPanelSaveModal?: boolean;
showLibraryPanelUnlinkModal?: boolean;
editPreview?: VizPanel;
tableView?: VizPanel;
pluginLoadErrror?: string;
/**
@@ -84,6 +85,11 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
private _activationHandler() {
const panel = this.state.panelRef.resolve();
const dashboard = getDashboardSceneFor(this);
// Clear any panel selection when entering panel edit mode.
// Need to clear selection here since selection is activated when panel edit mode is entered through the panel actions menu. This causes sidebar panel editor to be open when exiting panel edit mode
dashboard.state.editPane.clearSelection();
if (panel.state.pluginId === UNCONFIGURED_PANEL_PLUGIN_ID) {
if (config.featureToggles.newVizSuggestions) {
@@ -145,6 +151,9 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
const changedState = layoutItem.state;
const originalState = this._layoutItemState!;
this.setState({ editPreview: undefined });
this.state.optionsPane?.setState({ editPreviewRef: undefined });
// Temp fix for old edit mode
if (this._layoutItem instanceof DashboardGridItem && !config.featureToggles.dashboardNewLayouts) {
this._layoutItem.handleEditChange();
@@ -251,16 +260,40 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
);
// Setup options pane
const optionsPane = new PanelOptionsPane({
panelRef: this.state.panelRef,
editPreviewRef: this.state.editPreview?.getRef(),
searchQuery: '',
listMode: OptionFilter.All,
isVizPickerOpen: isUnconfigured,
isNewPanel: this.state.isNewPanel,
});
this.setState({
optionsPane: new PanelOptionsPane({
panelRef: this.state.panelRef,
searchQuery: '',
listMode: OptionFilter.All,
isVizPickerOpen: isUnconfigured,
isNewPanel: this.state.isNewPanel,
}),
optionsPane,
isInitializing: false,
});
this._subs.add(
optionsPane.subscribeToState((newState, oldState) => {
if (newState.isVizPickerOpen !== oldState.isVizPickerOpen) {
const panel = this.state.panelRef.resolve();
let editPreview: VizPanel | undefined;
if (newState.isVizPickerOpen) {
// we just "pick" timeseries, viz type will likely be overridden by Suggestions.
const editPreviewBuilder = PanelBuilders.timeseries()
.setTitle(panel.state.title)
.setDescription(panel.state.description);
if (panel.state.$data) {
editPreviewBuilder.setData(new DataProviderSharer({ source: panel.state.$data.getRef() }));
}
editPreview = editPreviewBuilder.build();
}
this.setState({ editPreview });
optionsPane.setState({ editPreviewRef: editPreview?.getRef() });
}
})
);
} else {
// plugin changed after first time initialization
// Just update data pane

View File

@@ -81,7 +81,7 @@ export function PanelEditorRenderer({ model }: SceneComponentProps<PanelEditor>)
function VizAndDataPane({ model }: SceneComponentProps<PanelEditor>) {
const dashboard = getDashboardSceneFor(model);
const { dataPane, showLibraryPanelSaveModal, showLibraryPanelUnlinkModal, tableView } = model.useState();
const { dataPane, showLibraryPanelSaveModal, showLibraryPanelUnlinkModal, tableView, editPreview } = model.useState();
const panel = model.getPanel();
const libraryPanel = getLibraryPanelBehavior(panel);
const { controls } = dashboard.useState();
@@ -113,7 +113,7 @@ function VizAndDataPane({ model }: SceneComponentProps<PanelEditor>) {
)}
<div {...containerProps}>
<div {...primaryProps} className={cx(primaryProps.className, isScrollingLayout && styles.fixedSizeViz)}>
<VizWrapper panel={panel} tableView={tableView} />
<VizWrapper panel={editPreview ?? panel} tableView={tableView} />
</div>
{showLibraryPanelSaveModal && libraryPanel && (
<SaveLibraryVizPanelModal

View File

@@ -27,7 +27,7 @@ describe('PanelOptionsPane', () => {
expect(panel.state.pluginId).toBe('timeseries');
optionsPane.onChangePanelPlugin({ pluginId: 'table' });
optionsPane.onChangePanel({ pluginId: 'table' });
expect(optionsPane['_cachedPluginOptions']['timeseries']?.options).toBe(panel.state.options);
expect(optionsPane['_cachedPluginOptions']['timeseries']?.fieldConfig).toBe(panel.state.fieldConfig);
@@ -52,7 +52,7 @@ describe('PanelOptionsPane', () => {
panel.setState({ $data: undefined });
panel.activate();
optionsPane.onChangePanelPlugin({
optionsPane.onChangePanel({
pluginId: 'table',
options: { showHeader: false },
fieldConfig: {
@@ -114,7 +114,7 @@ describe('PanelOptionsPane', () => {
expect(panel.state.fieldConfig.overrides[1].properties).toHaveLength(1);
expect(panel.state.fieldConfig.defaults.custom).toHaveProperty('axisBorderShow');
optionsPane.onChangePanelPlugin({ pluginId: 'table' });
optionsPane.onChangePanel({ pluginId: 'table' });
expect(mockFn).toHaveBeenCalled();
expect(mockFn.mock.calls[0][2].defaults.color?.mode).toBe('palette-classic');
@@ -146,8 +146,8 @@ describe('PanelOptionsPane', () => {
const mockOnFieldConfigChange = jest.fn();
panel.onFieldConfigChange = mockOnFieldConfigChange;
// Call onChangePanelPlugin with fieldConfig that has overrides
optionsPane.onChangePanelPlugin({
// Call onChangePanel with fieldConfig that has overrides
optionsPane.onChangePanel({
pluginId: 'table',
fieldConfig: {
defaults: { unit: 'percent' },
@@ -178,7 +178,7 @@ describe('PanelOptionsPane', () => {
panel.onFieldConfigChange = mockOnFieldConfigChange;
// Call without fieldConfig
optionsPane.onChangePanelPlugin({
optionsPane.onChangePanel({
pluginId: 'table',
options: { showHeader: false },
});

View File

@@ -41,6 +41,7 @@ export interface PanelOptionsPaneState extends SceneObjectState {
panelRef: SceneObjectRef<VizPanel>;
isNewPanel?: boolean;
hasPickedViz?: boolean;
editPreviewRef?: SceneObjectRef<VizPanel>;
}
interface PluginOptionsCache {
@@ -63,8 +64,7 @@ export class PanelOptionsPane extends SceneObjectBase<PanelOptionsPaneState> {
});
};
onChangePanelPlugin = (options: VizTypeChangeDetails) => {
const panel = this.state.panelRef.resolve();
onChangePanel = (options: VizTypeChangeDetails, panel = this.state.panelRef.resolve()) => {
const { options: prevOptions, fieldConfig: prevFieldConfig, pluginId: prevPluginId } = panel.state;
const pluginId = options.pluginId;
@@ -137,8 +137,10 @@ export class PanelOptionsPane extends SceneObjectBase<PanelOptionsPaneState> {
}
function PanelOptionsPaneComponent({ model }: SceneComponentProps<PanelOptionsPane>) {
const { isVizPickerOpen, searchQuery, listMode, panelRef, isNewPanel, hasPickedViz } = model.useState();
const { isVizPickerOpen, searchQuery, listMode, panelRef, isNewPanel, hasPickedViz, editPreviewRef } =
model.useState();
const panel = panelRef.resolve();
const editPreview = editPreviewRef?.resolve() ?? panel; // if something goes wrong, at least update the panel.
const { pluginId } = panel.useState();
const { data } = sceneGraph.getData(panel).useState();
const styles = useStyles2(getStyles);
@@ -229,7 +231,8 @@ function PanelOptionsPaneComponent({ model }: SceneComponentProps<PanelOptionsPa
{isVizPickerOpen && (
<PanelVizTypePicker
panel={panel}
onChange={model.onChangePanelPlugin}
editPreview={editPreview}
onChange={model.onChangePanel}
onClose={model.onToggleVizPicker}
data={data}
showBackButton={config.featureToggles.newVizSuggestions ? hasPickedViz || !isNewPanel : true}

View File

@@ -23,7 +23,8 @@ export interface Props {
data?: PanelData;
showBackButton?: boolean;
panel: VizPanel;
onChange: (options: VizTypeChangeDetails) => void;
editPreview: VizPanel;
onChange: (options: VizTypeChangeDetails, panel?: VizPanel) => void;
onClose: () => void;
}
@@ -41,7 +42,7 @@ const getTabs = (): Array<{ label: string; value: VisualizationSelectPaneTab }>
: [allVisualizationsTab, suggestionsTab];
};
export function PanelVizTypePicker({ panel, data, onChange, onClose, showBackButton }: Props) {
export function PanelVizTypePicker({ panel, editPreview, data, onChange, onClose, showBackButton }: Props) {
const styles = useStyles2(getStyles);
const panelModel = useMemo(() => new PanelModelCompatibilityWrapper(panel), [panel]);
const filterId = useId();
@@ -97,49 +98,55 @@ export function PanelVizTypePicker({ panel, data, onChange, onClose, showBackBut
</TabsBar>
<ScrollContainer>
<TabContent className={styles.tabContent}>
{listMode === VisualizationSelectPaneTab.Suggestions && (
<VisualizationSuggestions onChange={onChange} panel={panelModel} data={data} />
)}
{listMode === VisualizationSelectPaneTab.Visualizations && (
<Stack gap={1} direction="column">
<Field
tabIndex={0}
className={styles.searchField}
noMargin
htmlFor={filterId}
aria-label={t('dashboard-scene.panel-viz-type-picker.placeholder-search-for', 'Search for...')}
>
<Stack direction="row" gap={1}>
{showBackButton && (
<Button
aria-label={t('dashboard-scene.panel-viz-type-picker.title-close', 'Close')}
fill="text"
variant="secondary"
icon="arrow-left"
data-testid={selectors.components.PanelEditor.toggleVizPicker}
onClick={onClose}
>
<Trans i18nKey="dashboard-scene.panel-viz-type-picker.button.close">Back</Trans>
</Button>
)}
<FilterInput
id={filterId}
className={styles.filter}
value={searchQuery}
onChange={setSearchQuery}
placeholder={t('dashboard-scene.panel-viz-type-picker.placeholder-search-for', 'Search for...')}
/>
</Stack>
</Field>
<Stack gap={1} direction="column">
<Field
tabIndex={0}
className={styles.searchField}
noMargin
htmlFor={filterId}
aria-label={t('dashboard-scene.panel-viz-type-picker.placeholder-search-for', 'Search for...')}
>
<Stack direction="row" gap={1}>
{showBackButton && (
<Button
aria-label={t('dashboard-scene.panel-viz-type-picker.title-close', 'Close')}
fill="text"
variant="secondary"
icon="arrow-left"
data-testid={selectors.components.PanelEditor.toggleVizPicker}
onClick={onClose}
>
<Trans i18nKey="dashboard-scene.panel-viz-type-picker.button.close">Back</Trans>
</Button>
)}
<FilterInput
id={filterId}
className={styles.filter}
value={searchQuery}
onChange={setSearchQuery}
placeholder={t('dashboard-scene.panel-viz-type-picker.placeholder-search-for', 'Search for...')}
/>
</Stack>
</Field>
{listMode === VisualizationSelectPaneTab.Suggestions && (
<VisualizationSuggestions
onChange={onChange}
panel={panelModel}
editPreview={editPreview}
data={data}
searchQuery={searchQuery}
/>
)}
{listMode === VisualizationSelectPaneTab.Visualizations && (
<VizTypePicker
pluginId={panel.state.pluginId}
searchQuery={searchQuery}
trackSearch={trackSearch}
onChange={onChange}
/>
</Stack>
)}
)}
</Stack>
</TabContent>
</ScrollContainer>
</div>
@@ -155,7 +162,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
gap: theme.spacing(2),
}),
searchField: css({
marginTop: theme.spacing(0.5), // input glow with the boundary without this
margin: theme.spacing(0.5, 0, 1, 0), // input glow with the boundary without this
}),
tabs: css({
width: '100%',

View File

@@ -90,7 +90,6 @@ import { DashboardGridItem } from './layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager';
import { addNewRowTo } from './layouts-shared/addNew';
import { clearClipboard } from './layouts-shared/paste';
import { getIsLazy } from './layouts-shared/utils';
import { DashboardLayoutManager } from './types/DashboardLayoutManager';
import { LayoutParent } from './types/LayoutParent';
@@ -199,7 +198,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
meta: {},
editable: true,
$timeRange: state.$timeRange ?? new SceneTimeRange({}),
body: state.body ?? DefaultGridLayoutManager.fromVizPanels([], getIsLazy(state.preload)),
body: state.body ?? DefaultGridLayoutManager.fromVizPanels([]),
links: state.links ?? [],
...state,
editPane: new DashboardEditPane(),

View File

@@ -1,7 +1,7 @@
import React, { useContext, useEffect, useState } from 'react';
import { Trans } from '@grafana/i18n';
import { VizPanel } from '@grafana/scenes';
import { LazyLoader, VizPanel } from '@grafana/scenes';
import { Box, Spinner } from '@grafana/ui';
import { DashboardScene } from './DashboardScene';
@@ -51,11 +51,23 @@ export function useSoloPanelContext() {
return useContext(SoloPanelContext);
}
export function renderMatchingSoloPanels(soloPanelContext: SoloPanelContextValue, panels: VizPanel[]) {
export function renderMatchingSoloPanels(
soloPanelContext: SoloPanelContextValue,
panels: VizPanel[],
isLazy?: boolean
) {
const matches: React.ReactNode[] = [];
for (const panel of panels) {
if (soloPanelContext.matches(panel)) {
matches.push(<panel.Component model={panel} key={panel.state.key} />);
if (isLazy) {
matches.push(
<LazyLoader key={panel.state.key!}>
<panel.Component model={panel} />
</LazyLoader>
);
} else {
matches.push(<panel.Component model={panel} key={panel.state.key} />);
}
}
}

View File

@@ -8,6 +8,7 @@ import { useStyles2 } from '@grafana/ui';
import { ConditionalRenderingGroup } from '../../conditional-rendering/group/ConditionalRenderingGroup';
import { useIsConditionallyHidden } from '../../conditional-rendering/hooks/useIsConditionallyHidden';
import { useDashboardState } from '../../utils/utils';
import { SoloPanelContextValueWithSearchStringFilter } from '../PanelSearchLayout';
import { renderMatchingSoloPanels, useSoloPanelContext } from '../SoloPanelContext';
import { getIsLazy } from '../layouts-shared/utils';
@@ -89,7 +90,11 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps<AutoGridItem
);
if (soloPanelContext) {
return renderMatchingSoloPanels(soloPanelContext, [body, ...repeatedPanels]);
// Use lazy loading only for panel search layout (SoloPanelContextValueWithSearchStringFilter)
// as it renders multiple panels in a grid. Skip lazy loading for viewPanel URL param
// (SoloPanelContextWithPathIdFilter) since single panels should render immediately.
const useLazyForSoloPanel = isLazy && soloPanelContext instanceof SoloPanelContextValueWithSearchStringFilter;
return renderMatchingSoloPanels(soloPanelContext, [body, ...repeatedPanels], useLazyForSoloPanel);
}
const isDragging = !!draggingKey;

View File

@@ -1,17 +1,43 @@
import { css } from '@emotion/css';
import { useMemo } from 'react';
import { RefObject, useMemo } from 'react';
import { config } from '@grafana/runtime';
import { SceneComponentProps } from '@grafana/scenes';
import { LazyLoader, SceneComponentProps, VizPanel } from '@grafana/scenes';
import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN } from 'app/core/constants';
import { useDashboardState } from '../../utils/utils';
import { SoloPanelContextValueWithSearchStringFilter } from '../PanelSearchLayout';
import { renderMatchingSoloPanels, useSoloPanelContext } from '../SoloPanelContext';
import { getIsLazy } from '../layouts-shared/utils';
import { DashboardGridItem, RepeatDirection } from './DashboardGridItem';
interface PanelWrapperProps {
panel: VizPanel;
isLazy: boolean;
containerRef?: RefObject<HTMLDivElement>;
}
function PanelWrapper({ panel, isLazy, containerRef }: PanelWrapperProps) {
if (isLazy) {
return (
<LazyLoader key={panel.state.key!} ref={containerRef} className={panelWrapper}>
<panel.Component model={panel} />
</LazyLoader>
);
}
return (
<div className={panelWrapper} ref={containerRef}>
<panel.Component model={panel} />
</div>
);
}
export function DashboardGridItemRenderer({ model }: SceneComponentProps<DashboardGridItem>) {
const { repeatedPanels = [], itemHeight, variableName, body } = model.useState();
const soloPanelContext = useSoloPanelContext();
const { preload } = useDashboardState(model);
const isLazy = useMemo(() => getIsLazy(preload), [preload]);
const layoutStyle = useLayoutStyle(
model.getRepeatDirection(),
model.getChildCount(),
@@ -20,26 +46,22 @@ export function DashboardGridItemRenderer({ model }: SceneComponentProps<Dashboa
);
if (soloPanelContext) {
return renderMatchingSoloPanels(soloPanelContext, [body, ...repeatedPanels]);
// Use lazy loading only for panel search layout (SoloPanelContextValueWithSearchStringFilter)
// as it renders multiple panels in a grid. Skip lazy loading for viewPanel URL param
// (SoloPanelContextWithPathIdFilter) since single panels should render immediately.
const useLazyForSoloPanel = isLazy && soloPanelContext instanceof SoloPanelContextValueWithSearchStringFilter;
return renderMatchingSoloPanels(soloPanelContext, [body, ...repeatedPanels], useLazyForSoloPanel);
}
if (!variableName) {
return (
<div className={panelWrapper} ref={model.containerRef}>
<body.Component model={body} key={body.state.key} />
</div>
);
return <PanelWrapper panel={body} isLazy={isLazy} containerRef={model.containerRef} />;
}
return (
<div className={layoutStyle} ref={model.containerRef}>
<div className={panelWrapper} key={body.state.key}>
<body.Component model={body} key={body.state.key} />
</div>
<PanelWrapper panel={body} isLazy={isLazy} />
{repeatedPanels.map((panel) => (
<div className={panelWrapper} key={panel.state.key}>
<panel.Component model={panel} key={panel.state.key} />
</div>
<PanelWrapper key={panel.state.key!} panel={panel} isLazy={isLazy} />
))}
</div>
);

View File

@@ -47,7 +47,6 @@ import { AutoGridItem } from '../layout-auto-grid/AutoGridItem';
import { CanvasGridAddActions } from '../layouts-shared/CanvasGridAddActions';
import { clearClipboard, getDashboardGridItemFromClipboard } from '../layouts-shared/paste';
import { dashboardCanvasAddButtonHoverStyles } from '../layouts-shared/styles';
import { getIsLazy } from '../layouts-shared/utils';
import { DashboardLayoutGrid } from '../types/DashboardLayoutGrid';
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
import { LayoutRegistryItem } from '../types/LayoutRegistryItem';
@@ -565,11 +564,10 @@ export class DefaultGridLayoutManager
public static createFromLayout(currentLayout: DashboardLayoutManager): DefaultGridLayoutManager {
const panels = currentLayout.getVizPanels();
const isLazy = getIsLazy(getDashboardSceneFor(currentLayout).state.preload)!;
return DefaultGridLayoutManager.fromVizPanels(panels, isLazy);
return DefaultGridLayoutManager.fromVizPanels(panels);
}
public static fromVizPanels(panels: VizPanel[] = [], isLazy?: boolean | undefined): DefaultGridLayoutManager {
public static fromVizPanels(panels: VizPanel[] = []): DefaultGridLayoutManager {
const children: DashboardGridItem[] = [];
const panelHeight = 10;
const panelWidth = GRID_COLUMN_COUNT / 3;
@@ -607,7 +605,6 @@ export class DefaultGridLayoutManager
children: children,
isDraggable: true,
isResizable: true,
isLazy,
}),
});
}
@@ -615,8 +612,7 @@ export class DefaultGridLayoutManager
public static fromGridItems(
gridItems: SceneGridItemLike[],
isDraggable?: boolean,
isResizable?: boolean,
isLazy?: boolean | undefined
isResizable?: boolean
): DefaultGridLayoutManager {
const children = gridItems.reduce<SceneGridItemLike[]>((acc, gridItem) => {
gridItem.clearParent();
@@ -630,7 +626,6 @@ export class DefaultGridLayoutManager
children,
isDraggable,
isResizable,
isLazy,
}),
});
}

View File

@@ -358,8 +358,7 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
layout: DefaultGridLayoutManager.fromGridItems(
rowConfig.children,
rowConfig.isDraggable ?? layout.state.grid.state.isDraggable,
rowConfig.isResizable ?? layout.state.grid.state.isResizable,
layout.state.grid.state.isLazy
rowConfig.isResizable ?? layout.state.grid.state.isResizable
),
})
);

View File

@@ -59,7 +59,11 @@ export function CanvasGridAddActions({ layoutManager }: Props) {
}, [layoutManager]);
return (
<div className={cx(styles.addAction, 'dashboard-canvas-add-button')}>
<div
className={cx(styles.addAction, 'dashboard-canvas-add-button')}
onPointerUp={(evt) => evt.stopPropagation()}
onPointerDown={(evt) => evt.stopPropagation()}
>
<Button
variant="primary"
fill="text"
@@ -189,7 +193,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
height: theme.spacing(5),
bottom: 0,
left: 0,
right: 0,
opacity: 0,
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
transition: theme.transitions.create('opacity'),

View File

@@ -345,12 +345,10 @@ export class V2DashboardSerializer
// initialize autossigned variable ds references map
if (saveModel?.variables) {
for (const variable of saveModel.variables) {
if (variable) {
// for query variables that dont have a ds defined add them to the list
if (variable.kind === 'QueryVariable' && !variable.spec.query.datasource?.name) {
const datasourceType = variable.spec.query.group || undefined;
this.defaultDsReferencesMap.variables.set(variable.spec.name, datasourceType);
}
// for query variables that dont have a ds defined add them to the list
if (variable.kind === 'QueryVariable' && !variable.spec.query.datasource?.name) {
const datasourceType = variable.spec.query.group || undefined;
this.defaultDsReferencesMap.variables.set(variable.spec.name, datasourceType);
}
}
}

View File

@@ -1,7 +1,6 @@
import { css } from '@emotion/css';
import { Card, Text, useStyles2 } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
import { Card, useStyles2 } from '@grafana/ui';
import { getCardStyles } from './getCardStyles';
export interface SqlExpressionCardProps {
name: string;
@@ -12,60 +11,15 @@ export interface SqlExpressionCardProps {
}
export function SqlExpressionCard({ name, description, imageUrl, onClick, testId }: SqlExpressionCardProps) {
const styles = useStyles2(getSqlExpressionCardStyles);
const styles = useStyles2(getCardStyles);
return (
<Card className={styles.card} data-testid={testId} onClick={onClick} noMargin>
<Card.Heading className={styles.heading}>
<div className={styles.titleRow}>
<span>{name}</span>
</div>
</Card.Heading>
<Card.Description className={styles.description}>
<span>{description}</span>
{imageUrl && (
<span>
<img className={styles.image} src={imageUrl} alt={name} />
</span>
)}
<Card className={styles.baseCard} data-testid={testId} onClick={onClick} noMargin>
<Card.Heading>{name}</Card.Heading>
<Card.Description>
<Text variant="bodySmall">{description}</Text>
{imageUrl && <img className={styles.image} src={imageUrl} alt={name} />}
</Card.Description>
</Card>
);
}
function getSqlExpressionCardStyles(theme: GrafanaTheme2) {
return {
card: css({
gridTemplateRows: 'min-content 0 1fr 0',
marginBottom: 0,
}),
heading: css({
fontWeight: 400,
'> button': {
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: theme.spacing(1),
},
}),
titleRow: css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'nowrap',
width: '100%',
}),
description: css({
fontSize: theme.typography.bodySmall.fontSize,
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
}),
image: css({
display: 'block',
maxWidth: '100%',
marginTop: theme.spacing(2),
}),
};
}

View File

@@ -1,35 +1,38 @@
import { cx, css } from '@emotion/css';
import { cx } from '@emotion/css';
import {
DataFrame,
GrafanaTheme2,
TransformerRegistryItem,
TransformationApplicabilityLevels,
standardTransformersRegistry,
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Badge, Card, IconButton, useStyles2, useTheme2 } from '@grafana/ui';
import { Badge, Card, IconButton, Stack, Text, useStyles2, useTheme2 } from '@grafana/ui';
import { PluginStateInfo } from 'app/features/plugins/components/PluginStateInfo';
import { getCardStyles } from './getCardStyles';
export interface TransformationCardProps {
transform: TransformerRegistryItem;
data?: DataFrame[];
fullWidth?: boolean;
onClick: (id: string) => void;
showIllustrations?: boolean;
data?: DataFrame[];
showPluginState?: boolean;
showTags?: boolean;
transform: TransformerRegistryItem;
}
export function TransformationCard({
transform,
showIllustrations,
onClick,
data = [],
fullWidth = false,
onClick,
showIllustrations,
showPluginState = true,
showTags = true,
transform,
}: TransformationCardProps) {
const theme = useTheme2();
const styles = useStyles2(getTransformationCardStyles);
const styles = useStyles2(getCardStyles, fullWidth);
// Check to see if the transform is applicable to the given data
let applicabilityScore = TransformationApplicabilityLevels.Applicable;
@@ -47,7 +50,7 @@ export function TransformationCard({
}
}
const cardClasses = !isApplicable && data.length > 0 ? cx(styles.newCard, styles.cardDisabled) : styles.newCard;
const cardClasses = cx(styles.baseCard, { [styles.cardDisabled]: !isApplicable });
const imageUrl = theme.isDark ? transform.imageDark : transform.imageLight;
const description = standardTransformersRegistry.getIfExists(transform.id)?.description;
@@ -58,15 +61,11 @@ export function TransformationCard({
onClick={() => onClick(transform.id)}
noMargin
>
<Card.Heading className={styles.heading}>
<div className={styles.titleRow}>
<span>{transform.name}</span>
{showPluginState && (
<span className={styles.pluginStateInfoWrapper}>
<PluginStateInfo state={transform.state} />
</span>
)}
</div>
<Card.Heading>
<Stack alignItems="center" justifyContent="space-between">
{transform.name}
{showPluginState && <PluginStateInfo state={transform.state} />}
</Stack>
{showTags && transform.tags && transform.tags.size > 0 && (
<div className={styles.tagsWrapper}>
{Array.from(transform.tags).map((tag) => (
@@ -75,74 +74,13 @@ export function TransformationCard({
</div>
)}
</Card.Heading>
<Card.Description className={styles.description}>
<span>{description}</span>
{showIllustrations && imageUrl && (
<span>
<img className={styles.image} src={imageUrl} alt={transform.name} />
</span>
)}
<Card.Description>
<Text variant="bodySmall">{description || ''}</Text>
{showIllustrations && imageUrl && <img className={styles.image} src={imageUrl} alt={transform.name} />}
{!isApplicable && applicabilityDescription !== null && (
<IconButton className={styles.cardApplicableInfo} name="info-circle" tooltip={applicabilityDescription} />
<IconButton className={styles.applicableInfoButton} name="info-circle" tooltip={applicabilityDescription} />
)}
</Card.Description>
</Card>
);
}
function getTransformationCardStyles(theme: GrafanaTheme2) {
return {
heading: css({
fontWeight: 400,
'> button': {
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: theme.spacing(1),
},
}),
titleRow: css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'nowrap',
width: '100%',
}),
description: css({
fontSize: theme.typography.bodySmall.fontSize,
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
}),
image: css({
display: 'block',
maxWidth: '100%',
marginTop: theme.spacing(2),
}),
cardDisabled: css({
backgroundColor: theme.colors.action.disabledBackground,
img: {
filter: 'grayscale(100%)',
opacity: 0.33,
},
}),
cardApplicableInfo: css({
position: 'absolute',
bottom: theme.spacing(1),
right: theme.spacing(1),
}),
newCard: css({
gridTemplateRows: 'min-content 0 1fr 0',
marginBottom: 0,
}),
pluginStateInfoWrapper: css({
marginLeft: theme.spacing(0.5),
}),
tagsWrapper: css({
display: 'flex',
flexWrap: 'wrap',
gap: theme.spacing(0.5),
}),
};
}

View File

@@ -165,11 +165,12 @@ function TransformationsGrid({ showIllustrations, transformations, onClick, data
<Grid columns={3} gap={1}>
{transformations.map((transform) => (
<TransformationCard
key={transform.id}
transform={transform}
showIllustrations={showIllustrations}
onClick={onClick}
data={data}
fullWidth
key={transform.id}
onClick={onClick}
showIllustrations={showIllustrations}
transform={transform}
/>
))}
</Grid>

View File

@@ -0,0 +1,34 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
export const getCardStyles = (theme: GrafanaTheme2, fullWidth?: boolean) => ({
baseCard: css({
maxWidth: fullWidth ? 'none' : '200px',
width: fullWidth ? '100%' : 'auto',
marginBottom: 0,
}),
image: css({
display: 'block',
maxWidth: '100%',
marginTop: theme.spacing(2),
}),
cardDisabled: css({
backgroundColor: theme.colors.action.disabledBackground,
img: {
filter: 'grayscale(100%)',
opacity: 0.33,
},
}),
applicableInfoButton: css({
position: 'absolute',
bottom: theme.spacing(1),
right: theme.spacing(1),
}),
tagsWrapper: css({
display: 'flex',
flexWrap: 'wrap',
gap: theme.spacing(0.5),
marginTop: theme.spacing(0.5),
}),
});

View File

@@ -9,8 +9,10 @@ import {
PanelPluginMeta,
PanelPluginVisualizationSuggestion,
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { VizPanel } from '@grafana/scenes';
import { Alert, Button, Icon, Spinner, Text, useStyles2 } from '@grafana/ui';
import { UNCONFIGURED_PANEL_PLUGIN_ID } from 'app/features/dashboard-scene/scene/UnconfiguredPanel';
@@ -23,25 +25,47 @@ import { VisualizationSuggestionCard } from './VisualizationSuggestionCard';
import { VizTypeChangeDetails } from './types';
export interface Props {
onChange: (options: VizTypeChangeDetails) => void;
onChange: (options: VizTypeChangeDetails, panel?: VizPanel) => void;
editPreview?: VizPanel;
data?: PanelData;
panel?: PanelModel;
searchQuery?: string;
}
const useSuggestions = (data: PanelData | undefined) => {
const useSuggestions = (data: PanelData | undefined, searchQuery: string | undefined) => {
const [hasFetched, setHasFetched] = useState(false);
const { value, loading, error, retry } = useAsyncRetry(async () => {
await new Promise((resolve) => setTimeout(resolve, hasFetched ? 75 : 0));
setHasFetched(true);
return await getAllSuggestions(data);
}, [hasFetched, data]);
return { value, loading, error, retry };
const filteredValue = useMemo(() => {
if (!value || !searchQuery) {
return value;
}
const lowerCaseQuery = searchQuery.toLowerCase();
const filteredSuggestions = value.suggestions.filter(
(suggestion) =>
suggestion.name.toLowerCase().includes(lowerCaseQuery) ||
suggestion.pluginId.toLowerCase().includes(lowerCaseQuery) ||
suggestion.description?.toLowerCase().includes(lowerCaseQuery)
);
return {
...value,
suggestions: filteredSuggestions,
};
}, [value, searchQuery]);
return { value: filteredValue, loading, error, retry };
};
export function VisualizationSuggestions({ onChange, data, panel }: Props) {
export function VisualizationSuggestions({ onChange, editPreview, data, panel, searchQuery }: Props) {
const styles = useStyles2(getStyles);
const { value: result, loading, error, retry } = useSuggestions(data);
const { value: result, loading, error, retry } = useSuggestions(data, searchQuery);
const suggestions = result?.suggestions;
const hasLoadingErrors = result?.hasErrors ?? false;
@@ -73,18 +97,21 @@ export function VisualizationSuggestions({ onChange, data, panel }: Props) {
const applySuggestion = useCallback(
(suggestion: PanelPluginVisualizationSuggestion, isPreview?: boolean) => {
onChange({
pluginId: suggestion.pluginId,
options: suggestion.options,
fieldConfig: suggestion.fieldConfig,
withModKey: isPreview,
});
onChange(
{
pluginId: suggestion.pluginId,
options: suggestion.options,
fieldConfig: suggestion.fieldConfig,
withModKey: isPreview,
},
isPreview ? editPreview : undefined
);
if (isPreview) {
setSuggestionHash(suggestion.hash);
}
},
[onChange]
[onChange, editPreview]
);
useEffect(() => {
@@ -185,17 +212,13 @@ export function VisualizationSuggestions({ onChange, data, panel }: Props) {
variant="primary"
size={'md'}
className={styles.applySuggestionButton}
data-testid={selectors.components.VisualizationPreview.confirm(suggestion.name)}
aria-label={t(
'panel.visualization-suggestions.apply-suggestion-aria-label',
'Apply {{suggestionName}} visualization',
{ suggestionName: suggestion.name }
)}
onClick={() =>
onChange({
pluginId: suggestion.pluginId,
withModKey: false,
})
}
onClick={() => applySuggestion(suggestion, false)}
>
{t('panel.visualization-suggestions.use-this-suggestion', 'Use this suggestion')}
</Button>

View File

@@ -7,7 +7,7 @@ import {
import { defaultBucketAgg } from '../../../../queryDef';
import { reducerTester } from '../../../reducerTester';
import { changeMetricType } from '../../MetricAggregationsEditor/state/actions';
import { initQuery } from '../../state';
import { changeEditorTypeAndResetQuery, initQuery } from '../../state';
import { bucketAggregationConfig } from '../utils';
import {
@@ -180,4 +180,27 @@ describe('Bucket Aggregations Reducer', () => {
.thenStateShouldEqual([bucketAgg]);
});
});
describe('When switching editor type', () => {
it('Should reset bucket aggregations to default when switching editor types', () => {
const defaultTimeField = '@timestamp';
const initialState: BucketAggregation[] = [
{
id: '1',
type: 'date_histogram',
field: '@timestamp',
},
{
id: '2',
type: 'terms',
field: 'status',
},
];
reducerTester<ElasticsearchDataQuery['bucketAggs']>()
.givenReducer(createReducer(defaultTimeField), initialState)
.whenActionIsDispatched(changeEditorTypeAndResetQuery('code'))
.thenStateShouldEqual([{ ...defaultBucketAgg('2'), field: defaultTimeField }]);
});
});
});

View File

@@ -6,7 +6,7 @@ import { defaultBucketAgg } from '../../../../queryDef';
import { removeEmpty } from '../../../../utils';
import { changeMetricType } from '../../MetricAggregationsEditor/state/actions';
import { metricAggregationConfig } from '../../MetricAggregationsEditor/utils';
import { initQuery } from '../../state';
import { changeEditorTypeAndResetQuery, initQuery } from '../../state';
import { bucketAggregationConfig } from '../utils';
import {
@@ -87,6 +87,11 @@ export const createReducer =
return state;
}
if (changeEditorTypeAndResetQuery.match(action)) {
// Returns the default bucket agg. We will always want to set the default when switching types
return [{ ...defaultBucketAgg('2'), field: defaultTimeField }];
}
if (changeBucketAggregationSetting.match(action)) {
return state!.map((bucketAgg) => {
if (bucketAgg.id !== action.payload.bucketAgg.id) {

View File

@@ -7,7 +7,7 @@ import {
import { defaultMetricAgg } from '../../../../queryDef';
import { reducerTester } from '../../../reducerTester';
import { initQuery } from '../../state';
import { changeEditorTypeAndResetQuery, initQuery } from '../../state';
import { metricAggregationConfig } from '../utils';
import {
@@ -248,4 +248,26 @@ describe('Metric Aggregations Reducer', () => {
.whenActionIsDispatched(initQuery())
.thenStateShouldEqual([defaultMetricAgg('1')]);
});
describe('When switching editor type', () => {
it('Should reset to single default metric when switching to code editor', () => {
const initialState: MetricAggregation[] = [
{
id: '1',
type: 'avg',
field: 'value',
},
{
id: '2',
type: 'max',
field: 'value',
},
];
reducerTester<ElasticsearchDataQuery['metrics']>()
.givenReducer(reducer, initialState)
.whenActionIsDispatched(changeEditorTypeAndResetQuery('code'))
.thenStateShouldEqual([defaultMetricAgg('1')]);
});
});
});

View File

@@ -4,7 +4,7 @@ import { ElasticsearchDataQuery, MetricAggregation } from 'app/plugins/datasourc
import { defaultMetricAgg, queryTypeToMetricType } from '../../../../queryDef';
import { removeEmpty } from '../../../../utils';
import { initQuery } from '../../state';
import { changeEditorTypeAndResetQuery, initQuery } from '../../state';
import { isMetricAggregationWithMeta, isMetricAggregationWithSettings, isPipelineAggregation } from '../aggregations';
import { getChildren, metricAggregationConfig } from '../utils';
@@ -65,6 +65,11 @@ export const reducer = (
});
}
if (changeEditorTypeAndResetQuery.match(action)) {
// Reset to default metric when switching to editor types
return [defaultMetricAgg('1')];
}
if (changeMetricField.match(action)) {
return state!.map((metric) => {
if (metric.id !== action.payload.id) {

View File

@@ -1,7 +1,15 @@
import { ElasticsearchDataQuery } from '../../dataquery.gen';
import { reducerTester } from '../reducerTester';
import { aliasPatternReducer, changeAliasPattern, changeQuery, initQuery, queryReducer } from './state';
import {
aliasPatternReducer,
changeAliasPattern,
changeEditorTypeAndResetQuery,
changeQuery,
initQuery,
queryReducer,
rawDSLQueryReducer,
} from './state';
describe('Query Reducer', () => {
describe('On Init', () => {
@@ -42,6 +50,17 @@ describe('Query Reducer', () => {
.whenActionIsDispatched({ type: 'THIS ACTION SHOULD NOT HAVE ANY EFFECT IN THIS REDUCER' })
.thenStateShouldEqual(initialState);
});
describe('When switching editor type', () => {
it('Should clear query when switching editor types', () => {
const initialQuery: ElasticsearchDataQuery['query'] = 'Some lucene query';
reducerTester<ElasticsearchDataQuery['query']>()
.givenReducer(queryReducer, initialQuery)
.whenActionIsDispatched(changeEditorTypeAndResetQuery('code'))
.thenStateShouldEqual('');
});
});
});
describe('Alias Pattern Reducer', () => {
@@ -62,4 +81,26 @@ describe('Alias Pattern Reducer', () => {
.whenActionIsDispatched({ type: 'THIS ACTION SHOULD NOT HAVE ANY EFFECT IN THIS REDUCER' })
.thenStateShouldEqual(initialState);
});
describe('When switching editor type', () => {
it('Should clear alias when switching editor types', () => {
const initialAlias: ElasticsearchDataQuery['alias'] = 'Some alias pattern';
reducerTester<ElasticsearchDataQuery['alias']>()
.givenReducer(aliasPatternReducer, initialAlias)
.whenActionIsDispatched(changeEditorTypeAndResetQuery('code'))
.thenStateShouldEqual('');
});
});
});
describe('Raw DSL Query Reducer', () => {
it('Should clear raw DSL query when switching editor types', () => {
const initialRawQuery: ElasticsearchDataQuery['rawDSLQuery'] = '{"query": {"match_all": {}}}';
reducerTester<ElasticsearchDataQuery['rawDSLQuery']>()
.givenReducer(rawDSLQueryReducer, initialRawQuery)
.whenActionIsDispatched(changeEditorTypeAndResetQuery('builder'))
.thenStateShouldEqual('');
});
});

Some files were not shown because too many files have changed in this diff Show More