Merge branch 'main' into kristina/rtk-corr

This commit is contained in:
Kristina Durivage
2025-12-15 20:51:11 -06:00
67 changed files with 2602 additions and 306 deletions
@@ -530,7 +530,7 @@
"kind": "RowsLayoutRow",
"spec": {
"title": "",
"collapse": true,
"collapse": false,
"hideHeader": true,
"layout": {
"kind": "GridLayout",
@@ -546,7 +546,7 @@
"kind": "RowsLayoutRow",
"spec": {
"title": "",
"collapse": true,
"collapse": false,
"hideHeader": true,
"layout": {
"kind": "GridLayout",
@@ -548,7 +548,7 @@
"kind": "RowsLayoutRow",
"spec": {
"title": "",
"collapse": true,
"collapse": false,
"hideHeader": true,
"layout": {
"kind": "GridLayout",
@@ -574,7 +574,7 @@
"kind": "RowsLayoutRow",
"spec": {
"title": "",
"collapse": true,
"collapse": false,
"hideHeader": true,
"layout": {
"kind": "GridLayout",
@@ -1663,7 +1663,7 @@
"kind": "RowsLayoutRow",
"spec": {
"title": "",
"collapse": true,
"collapse": false,
"hideHeader": true,
"layout": {
"kind": "GridLayout",
@@ -1727,7 +1727,7 @@
"kind": "RowsLayoutRow",
"spec": {
"title": "",
"collapse": true,
"collapse": false,
"hideHeader": true,
"layout": {
"kind": "GridLayout",
@@ -328,7 +328,7 @@
"kind": "RowsLayoutRow",
"spec": {
"title": "",
"collapse": true,
"collapse": false,
"hideHeader": true,
"layout": {
"kind": "GridLayout",
@@ -335,7 +335,7 @@
"kind": "RowsLayoutRow",
"spec": {
"title": "",
"collapse": true,
"collapse": false,
"hideHeader": true,
"layout": {
"kind": "GridLayout",
@@ -501,11 +501,9 @@ func convertToRowsLayout(ctx context.Context, panels []interface{}, dsIndexProvi
if currentRow != nil {
// If currentRow is a hidden-header row (panels before first explicit row),
// set its collapse to match the first explicit row's collapsed value
// This matches frontend behavior: collapse: panel.collapsed
// it should not be collapsed because it will disappear and be visible only in edit mode
if currentRow.Spec.HideHeader != nil && *currentRow.Spec.HideHeader {
rowCollapsed := getBoolField(panelMap, "collapsed", false)
currentRow.Spec.Collapse = &rowCollapsed
currentRow.Spec.Collapse = &[]bool{false}[0]
}
// Flush current row to layout
rows = append(rows, *currentRow)
@@ -75,9 +75,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": true,
"spotlight": false,
"gradient": false
"spotlight": false
},
"orientation": "auto",
"reduceOptions": {
@@ -154,9 +154,9 @@
"effects": {
"barGlow": false,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": false,
"gradient": false
"spotlight": false
},
"orientation": "auto",
"reduceOptions": {
@@ -233,9 +233,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": false,
"gradient": false
"spotlight": false
},
"orientation": "auto",
"reduceOptions": {
@@ -312,9 +312,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true,
"gradient": false
"spotlight": true
},
"orientation": "auto",
"reduceOptions": {
@@ -391,9 +391,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true,
"gradient": false
"spotlight": true
},
"orientation": "auto",
"reduceOptions": {
@@ -470,9 +470,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": false,
"spotlight": true,
"gradient": false
"spotlight": true
},
"orientation": "auto",
"reduceOptions": {
@@ -549,9 +549,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": false,
"spotlight": true,
"gradient": false
"spotlight": true
},
"orientation": "auto",
"reduceOptions": {
@@ -641,9 +641,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true,
"gradient": false
"spotlight": true
},
"orientation": "auto",
"reduceOptions": {
@@ -720,9 +720,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true,
"gradient": false
"spotlight": true
},
"orientation": "auto",
"reduceOptions": {
@@ -799,9 +799,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true,
"gradient": false
"spotlight": true
},
"orientation": "auto",
"reduceOptions": {
@@ -878,9 +878,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": false,
"rounded": true,
"spotlight": true,
"gradient": false
"spotlight": true
},
"orientation": "auto",
"reduceOptions": {
@@ -974,9 +974,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": false,
"spotlight": false,
"gradient": false
"spotlight": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1053,9 +1053,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": false,
"spotlight": false,
"gradient": false
"spotlight": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1132,9 +1132,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": true,
"rounded": false,
"spotlight": false,
"gradient": true
"spotlight": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1211,9 +1211,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": false,
"spotlight": false,
"gradient": false
"spotlight": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1290,9 +1290,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": false,
"spotlight": false,
"gradient": false
"spotlight": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1386,9 +1386,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": true,
"rounded": false,
"spotlight": false,
"gradient": true
"spotlight": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1469,9 +1469,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": true,
"rounded": false,
"spotlight": false,
"gradient": true
"spotlight": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1552,9 +1552,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": true,
"rounded": false,
"spotlight": false,
"gradient": true
"spotlight": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1643,9 +1643,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true,
"gradient": true
"spotlight": true
},
"glow": "both",
"orientation": "auto",
@@ -1727,9 +1727,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true,
"gradient": true
"spotlight": true
},
"glow": "both",
"orientation": "auto",
@@ -1825,9 +1825,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true,
"gradient": true
"spotlight": true
},
"glow": "both",
"orientation": "auto",
@@ -1910,9 +1910,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true,
"gradient": true
"spotlight": true
},
"glow": "both",
"orientation": "auto",
@@ -1994,9 +1994,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true,
"gradient": true
"spotlight": true
},
"glow": "both",
"orientation": "auto",
@@ -2078,9 +2078,9 @@
"effects": {
"barGlow": true,
"centerGlow": true,
"gradient": true,
"rounded": true,
"spotlight": true,
"gradient": true
"spotlight": true
},
"glow": "both",
"orientation": "auto",
@@ -2172,7 +2172,9 @@
},
"orientation": "auto",
"reduceOptions": {
"calcs": ["lastNotNull"],
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
@@ -2238,7 +2240,9 @@
},
"orientation": "auto",
"reduceOptions": {
"calcs": ["lastNotNull"],
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
@@ -2275,4 +2279,4 @@
"title": "Panel tests - Gauge (new)",
"uid": "panel-tests-gauge-new",
"weekStart": ""
}
}
@@ -955,9 +955,9 @@
"effects": {
"barGlow": false,
"centerGlow": false,
"gradient": false,
"rounded": false,
"spotlight": false,
"gradient": false
"spotlight": false
},
"orientation": "auto",
"reduceOptions": {
@@ -1162,4 +1162,4 @@
"title": "Panel tests - Old gauge to new",
"uid": "panel-tests-old-gauge-to-new",
"weekStart": ""
}
}
@@ -44,7 +44,7 @@ refs:
destination: /docs/grafana-cloud/alerting-and-irm/oncall/user-and-team-management/#available-grafana-oncall-rbac-roles--granted-actions
---
# RBAC role definitions
# Grafana RBAC role definitions
{{< admonition type="note" >}}
Available in [Grafana Enterprise](/docs/grafana/<GRAFANA_VERSION>/introduction/grafana-enterprise/) and [Grafana Cloud](/docs/grafana-cloud).
@@ -59,7 +59,7 @@ The following tables list permissions associated with basic and fixed roles. Thi
| Grafana Admin | `basic_grafana_admin` |
| `fixed:authentication.config:writer`<br>`fixed:general.auth.config:writer`<br>`fixed:ldap:writer`<br>`fixed:licensing:writer`<br>`fixed:migrationassistant:migrator`<br>`fixed:org.users:writer`<br>`fixed:organization:maintainer`<br>`fixed:plugins:maintainer`<br>`fixed:provisioning:writer`<br>`fixed:roles:writer`<br>`fixed:settings:reader`<br>`fixed:settings:writer`<br>`fixed:stats:reader`<br>`fixed:support.bundles:writer`<br>`fixed:usagestats:reader`<br>`fixed:users:writer` | Default [Grafana server administrator](/docs/grafana/<GRAFANA_VERSION>/administration/roles-and-permissions/#grafana-server-administrators) assignments. |
| Admin | `basic_admin` | All roles assigned to Editor and `fixed:reports:writer` <br>`fixed:datasources:writer`<br>`fixed:organization:writer`<br>`fixed:datasources.permissions:writer`<br>`fixed:teams:writer`<br>`fixed:dashboards:writer`<br>`fixed:dashboards.permissions:writer`<br>`fixed:dashboards.public:writer`<br>`fixed:folders:writer`<br>`fixed:folders.permissions:writer`<br>`fixed:alerting:writer`<br>`fixed:alerting.provisioning.secrets:reader`<br>`fixed:alerting.provisioning:writer`<br>`fixed:datasources.caching:writer`<br>`fixed:plugins:writer`<br>`fixed:library.panels:writer` | Default [Grafana organization administrator](ref:rbac-basic-roles) assignments. |
| Editor | `basic_editor` | All roles assigned to Viewer and `fixed:datasources:explorer` <br>`fixed:dashboards:creator`<br>`fixed:folders:creator`<br>`fixed:annotations:writer`<br>`fixed:alerting:writer`<br>`fixed:library.panels:creator`<br>`fixed:library.panels:general.writer`<br>`fixed:alerting.provisioning.status:writer` | Default [Editor](ref:rbac-basic-roles) assignments. |
| Editor | `basic_editor` | All roles assigned to Viewer and `fixed:datasources:explorer` <br>`fixed:dashboards:creator`<br>`fixed:folders:creator`<br>`fixed:annotations:writer`<br>`fixed:alerting:writer`<br>`fixed:library.panels:creator`<br>`fixed:library.panels:general.writer`<br>`fixed:alerting.provisioning.provenance:writer` | Default [Editor](ref:rbac-basic-roles) assignments. |
| Viewer | `basic_viewer` | `fixed:datasources.id:reader`<br>`fixed:organization:reader`<br>`fixed:annotations:reader`<br>`fixed:annotations.dashboard:writer`<br>`fixed:alerting:reader`<br>`fixed:plugins.app:reader`<br>`fixed:dashboards.insights:reader`<br>`fixed:datasources.insights:reader`<br>`fixed:library.panels:general.reader`<br>`fixed:folders.general:reader`<br>`fixed:datasources.builtin:reader` | Default [Viewer](ref:rbac-basic-roles) assignments. |
| No Basic Role | n/a | | Default [No Basic Role](ref:rbac-basic-roles) |
@@ -74,86 +74,86 @@ These UUIDs won't be available if your instance was created before Grafana v10.2
To learn how to use the roles API to determine the role UUIDs, refer to [Manage RBAC roles](ref:rbac-manage-rbac-roles).
{{< /admonition >}}
| Fixed role | UUID | Permissions | Description |
| -------------------------------------------- | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `fixed:alerting:reader` | `fixed_O2oP1_uBFozI2i93klAkcvEWR30` | All permissions from `fixed:alerting.rules:reader` <br>`fixed:alerting.instances:reader`<br>`fixed:alerting.notifications:reader` | Read-only permissions for all Grafana, Mimir, Loki and Alertmanager alert rules\*, alerts, contact points, and notification policies.[\*](#alerting-roles) |
| `fixed:alerting:writer` | `fixed_-PAZgSJsDlRD8NUg-PFSeH_BkJY` | All permissions from `fixed:alerting.rules:writer` <br>`fixed:alerting.instances:writer`<br>`fixed:alerting.notifications:writer` | Create, update, and delete Grafana, Mimir, Loki and Alertmanager alert rules\*, silences, contact points, templates, mute timings, and notification policies.[\*](#alerting-roles) |
| `fixed:alerting.instances:reader` | `fixed_ut5fVS-Ulh_ejFoskFhJT_rYg0Y` | `alert.instances:read` for organization scope <br> `alert.instances.external:read` for scope `datasources:*` | Read all alerts and silences in the organization produced by Grafana Alerts and Mimir and Loki alerts and silences.[\*](#alerting-roles) |
| `fixed:alerting.instances:writer` | `fixed_pKOBJE346uyqMLdgWbk1NsQfEl0` | All permissions from `fixed:alerting.instances:reader` and<br> `alert.instances:create`<br>`alert.instances:write` for organization scope <br> `alert.instances.external:write` for scope `datasources:*` | Create, update and expire all silences in the organization produced by Grafana, Mimir, and Loki.[\*](#alerting-roles) |
| `fixed:alerting.notifications:reader` | `fixed_hmBn0lX5h1RZXB9Vaot420EEdA0` | `alert.notifications:read` for organization scope<br>`alert.notifications.external:read` for scope `datasources:*` | Read all Grafana and Alertmanager contact points, templates, and notification policies.[\*](#alerting-roles) |
| `fixed:alerting.notifications:writer` | `fixed_XplK6HPNxf9AP5IGTdB5Iun4tJc` | All permissions from `fixed:alerting.notifications:reader` and<br>`alert.notifications:write`for organization scope<br>`alert.notifications.external:read` for scope `datasources:*` | Create, update, and delete contact points, templates, mute timings and notification policies for Grafana and external Alertmanager.[\*](#alerting-roles) |
| `fixed:alerting.provisioning:writer` | `fixed_y7pFjdEkxpx5ETdcxPvp0AgRuUo` | `alert.provisioning:read` and `alert.provisioning:write` | Create, update and delete Grafana alert rules, notification policies, contact points, templates, etc via provisioning API. [\*](#alerting-roles) |
| `fixed:alerting.provisioning.secrets:reader` | `fixed_9fmzXXZZG-Od0Amy2ofEG8Uk--c` | `alert.provisioning:read` and `alert.provisioning.secrets:read` | Read-only permissions for Provisioning API and let export resources with decrypted secrets [\*](#alerting-roles) |
| `fixed:alerting.provisioning.status:writer` | `fixed_eAxlzfkTuobvKEgXHveFMBZrOj8` | `alert.provisioning.provenance:write` | Set provenance status to alert rules, notification policies, contact points, etc. Should be used together with regular writer roles. [\*](#alerting-roles) |
| `fixed:alerting.rules:reader` | `fixed_fRGKL_vAqUsmUWq5EYKnOha9DcA` | `alert.rule:read`, `alert.silences:read` for scope `folders:*` <br> `alert.rules.external:read` for scope `datasources:*` <br> `alert.notifications.time-intervals:read` <br> `alert.notifications.receivers:list` | Read all\* Grafana, Mimir, and Loki alert rules.[\*](#alerting-roles) and read rule-specific silences |
| `fixed:alerting.rules:writer` | `fixed_YJJGwAalUwDZPrXSyFH8GfYBXAc` | All permissions from `fixed:alerting.rules:reader` and <br> `alert.rule:create` <br> `alert.rule:write` <br> `alert.rule:delete` <br> `alert.silences:create` <br> `alert.silences:write` for scope `folders:*` <br> `alert.rules.external:write` for scope `datasources:*` | Create, update, and delete all\* Grafana, Mimir, and Loki alert rules.[\*](#alerting-roles) and manage rule-specific silences |
| `fixed:annotations:reader` | `fixed_hpZnoizrfAJsrceNcNQqWYV-xNU` | `annotations:read` for scopes `annotations:type:*` | Read all annotations and annotation tags. |
| `fixed:annotations:writer` | `fixed_ZVW-Aa9Tzle6J4s2aUFcq1StKWE` | All permissions from `fixed:annotations:reader` <br>`annotations:write` <br>`annotations.create`<br> `annotations:delete` for scope `annotations:type:*` | Read, create, update and delete all annotations and annotation tags. |
| `fixed:annotations.dashboard:writer` | `fixed_8A775xenXeKaJk4Cr7bchP9yXOA` | `annotations:write` <br>`annotations.create`<br> `annotations:delete` for scope `annotations:type:dashboard` | Create, update and delete dashboard annotations and annotation tags. |
| `fixed:authentication.config:writer` | `fixed_0rYhZ2Qnzs8AdB1nX7gexk3fHDw` | `settings:read` for scope `settings:auth.saml:*` <br> `settings:write` for scope `settings:auth.saml:*` | Read and update authentication and SAML settings. |
| `fixed:general.auth.config:writer` | `fixed_QFxIT_FGtBqbIVJIwx1bLgI5z6c` | `settings:read` for scope `settings:auth:oauth_allow_insecure_email_lookup` <br> `settings:write` for scope `settings:auth:oauth_allow_insecure_email_lookup` | Read and update the Grafana instance's general authentication configuration settings. |
| `fixed:dashboards:creator` | `fixed_ZorKUcEPCM01A1fPakEzGBUyU64` | `dashboards:create`<br>`folders:read` | Create dashboards. |
| `fixed:dashboards:reader` | `fixed_Sgr67JTOhjQGFlzYRahOe45TdWM` | `dashboards:read` | Read all dashboards. |
| `fixed:dashboards:writer` | `fixed_OK2YOQGIoI1G031hVzJB6rAJQAs` | All permissions from `fixed:dashboards:reader` and <br>`dashboards:write`<br>`dashboards:delete`<br>`dashboards:create`<br>`dashboards.permissions:read`<br>`dashboards.permissions:write` | Read, create, update, and delete all dashboards. |
| `fixed:dashboards.insights:reader` | `fixed_JlBJ2_gizP8zhgaeGE2rjyZe2Rs` | `dashboards.insights:read` | Read dashboard insights data and see presence indicators. |
| `fixed:dashboards.permissions:reader` | `fixed_f17oxuXW_58LL8mYJsm4T_mCeIw` | `dashboards.permissions:read` | Read all dashboard permissions. |
| `fixed:dashboards.permissions:writer` | `fixed_CcznxhWX_Yqn8uWMXMQ-b5iFW9k` | All permissions from `fixed:dashboards.permissions:reader` and <br>`dashboards.permissions:write` | Read and update all dashboard permissions. |
| `fixed:dashboards.public:writer` | `fixed_f_GHHRBciaqESXfGz2oCcooqHxs` | `dashboards.public:write` | Create, update, delete or pause a shared dashboard. |
| `fixed:datasources:creator` | `fixed_XX8jHREgUt-wo1A-rPXIiFlX6Zw` | `datasources:create` | Create data sources. |
| `fixed:datasources:explorer` | `fixed_qDzW9mzx9yM91T5Bi8dHUM2muTw` | `datasources:explore` | Enable the Explore feature. Data source permissions still apply, you can only query data sources for which you have query permissions. |
| `fixed:datasources:reader` | `fixed_C2x8IxkiBc1KZVjyYH775T9jNMQ` | `datasources:read`<br>`datasources:query` | Read and query data sources. |
| `fixed:datasources:writer` | `fixed_q8HXq8kjjA5IlHHgBJlKlUyaNik` | All permissions from `fixed:datasources:reader` and <br>`datasources:create`<br>`datasources:write`<br>`datasources:delete` | Read, query, create, delete, or update a data source. |
| `fixed:datasources.builtin:reader` | `fixed_q8HXq8kjjA5IlHHgBJlKlUyaNik` | `datasources:read` and `datasources:query` scoped to `datasources:uid:grafana` | An internal role used to grant Viewers access to the builtin example data source in Grafana. |
| `fixed:datasources.caching:reader` | `fixed_D2ddpGxJYlw0mbsTS1ek9fj0kj4` | `datasources.caching:read` | Read data source query caching settings. |
| `fixed:datasources.caching:writer` | `fixed_JtFjHr7jd7hSqUYcktKvRvIOGRE` | `datasources.caching:read`<br>`datasources.caching:write` | Enable, disable, or update query caching settings. |
| `fixed:datasources.id:reader` | `fixed_entg--fHmDqWY2-69N0ocawK0Os` | `datasources.id:read` | Read the ID of a data source based on its name. |
| `fixed:datasources.insights:reader` | `fixed_EBZ3NwlfecNPp2p0XcZRC1nfEYk` | `datasources.insights:read` | Read data source insights data. |
| `fixed:datasources.permissions:reader` | `fixed_ErYA-cTN3yn4h4GxaVPcawRhiOY` | `datasources.permissions:read` | Read data source permissions. |
| `fixed:datasources.permissions:writer` | `fixed_aiQh9YDfLOKjQhYasF9_SFUjQiw` | All permissions from `fixed:datasources.permissions:reader` and <br>`datasources.permissions:write` | Create, read, or delete permissions of a data source. |
| `fixed:folders:creator` | `fixed_gGLRbZGAGB6n9uECqSh_W382RlQ` | `folders:create` | Create folders in the root level. |
| `fixed:folders:reader` | `fixed_yeW-5QPeo-i5PZUIUXMlAA97GnQ` | `folders:read`<br>`dashboards:read` | Read all folders and dashboards. |
| `fixed:folders:writer` | `fixed_wJXLoTzgE7jVuz90dryYoiogL0o` | All permissions from `fixed:dashboards:writer` and <br>`folders:read`<br>`folders:write`<br>`folders:create`<br>`folders:delete`<br>`folders.permissions:read`<br>`folders.permissions:write` | Read, update, and delete all folders and dashboards. Create folders and subfolders. |
| `fixed:folders.general:reader` | `fixed_rSASbkg8DvpG_gTX5s41d7uxRvI` | `folders:read` scoped to `folders:uid:general` | An internal role used to correctly display access to the folder tree for Viewer role. |
| `fixed:folders.permissions:reader` | `fixed_E06l4cx0JFm47EeLBE4nmv3pnSo` | `folders.permissions:read` | Read all folder permissions. |
| `fixed:folders.permissions:writer` | `fixed_3GAgpQ_hWG8o7-lwNb86_VB37eI` | All permissions from `fixed:folders.permissions:reader` and <br>`folders.permissions:write` | Read and update all folder permissions. |
| `fixed:ldap:reader` | `fixed_lMcOPwSkxKY-qCK8NMJc5k6izLE` | `ldap.user:read`<br>`ldap.status:read` | Read the LDAP configuration and LDAP status information. |
| `fixed:ldap:writer` | `fixed_p6AvnU4GCQyIh7-hbwI-bk3GYnU` | All permissions from `fixed:ldap:reader` and <br>`ldap.user:sync`<br>`ldap.config:reload` | Read and update the LDAP configuration, and read LDAP status information. |
| `fixed:library.panels:creator` | `fixed_6eX6ItfegCIY5zLmPqTDW8ZV7KY` | `library.panels:create`<br>`folders:read` | Create library panel at the root level. |
| `fixed:library.panels:general.reader` | `fixed_ct0DghiBWR_2BiQm3EvNPDVmpio` | `library.panels:read` | Read all library panels at the root level. |
| `fixed:library.panels:general.writer` | `fixed_DgprkmqfN_1EhZ2v1_d1fYG8LzI` | All permissions from `fixed:library.panels:general.reader` plus<br>`library.panels:create`<br>`library.panels:delete`<br>`library.panels:write` | Create, read, write or delete all library panels and their permissions at the root level. |
| `fixed:library.panels:reader` | `fixed_tvTr9CnZ6La5vvUO_U_X1LPnhUs` | `library.panels:read` | Read all library panels. |
| `fixed:library.panels:writer` | `fixed_JTljAr21LWLTXCkgfBC4H0lhBC8` | All permissions from `fixed:library.panels:reader` plus<br>`library.panels:create`<br>`library.panels:delete`<br>`library.panels:write` | Create, read, write or delete all library panels and their permissions. |
| `fixed:licensing:reader` | `fixed_OADpuXvNEylO2Kelu3GIuBXEAYE` | `licensing:read`<br>`licensing.reports:read` | Read licensing information and licensing reports. |
| `fixed:licensing:writer` | `fixed_gzbz3rJpQMdaKHt-E4q0PVaKMoE` | All permissions from `fixed:licensing:reader` and <br>`licensing:write`<br>`licensing:delete` | Read licensing information and licensing reports, update and delete the license token. |
| `fixed:migrationassistant:migrator` | `fixed_LLk2p7TRuBztOAksTQb1Klc8YTk` | `migrationassistant:migrate` | Execute on-prem to cloud migrations through the Migration Assistant. |
| `fixed:org.users:reader` | `fixed_oCqNwlVHLOpw7-jAlwp4HzYqwGY` | `org.users:read` | Read users within a single organization. |
| `fixed:org.users:writer` | `fixed_VERj5nayasjgf_Yh0sWqqCkxWlw` | All permissions from `fixed:org.users:reader` and <br>`org.users:add`<br>`org.users:remove`<br>`org.users:write` | Within a single organization, add a user, invite a new user, read information about a user and their role, remove a user from that organization, or change the role of a user. |
| `fixed:organization:maintainer` | `fixed_CMm-uuBaPUBf4r8XG3jIvxo55bg` | All permissions from `fixed:organization:reader` and <br> `orgs:write`<br>`orgs:create`<br>`orgs:delete`<br>`orgs.quotas:write` | Create, read, write, or delete an organization. Read or write its quotas. This role needs to be assigned globally. |
| `fixed:organization:reader` | `fixed_0SZPJlTHdNEe8zO91zv7Zwiwa2w` | `orgs:read`<br>`orgs.quotas:read` | Read an organization and its quotas. |
| `fixed:organization:writer` | `fixed_Y4jGqDd8w1yCrPwlik8z5Iu8-3M` | All permissions from `fixed:organization:reader` and <br> `orgs:write`<br>`orgs.preferences:read`<br>`orgs.preferences:write` | Read an organization, its quotas, or its preferences. Update organization properties, or its preferences. |
| `fixed:plugins:maintainer` | `fixed_yEOKidBcWgbm74x-nTa3lW5lOyY` | `plugins:install` | Install and uninstall plugins. Needs to be assigned globally. |
| `fixed:plugins:writer` | `fixed_MRYpGk7kpNNwt2VoVOXFiPnQziE` | `plugins:write` | Enable and disable plugins and edit plugins' settings. |
| `fixed:plugins.app:reader` | `fixed_AcZRiNYx7NueYkUqzw1o2OGGUAA` | `plugins.app:access` | Access application plugins (still enforcing the organization role). |
| `fixed:provisioning:writer` | `fixed_bgk1FCyR6OEDwhgirZlQgu5LlCA` | `provisioning:reload` | Reload provisioning. |
| `fixed:reports:reader` | `fixed_72_8LU_0ukfm6BdblOw8Z9q-GQ8` | `reports:read`<br>`reports:send`<br>`reports.settings:read` | Read all reports and shared report settings. |
| `fixed:reports:writer` | `fixed_jBW3_7g1EWOjGVBYeVRwtFxhUNw` | All permissions from `fixed:reports:reader` and <br>`reports:create`<br>`reports:write`<br>`reports:delete`<br>`reports.settings:write` | Create, read, update, or delete all reports and shared report settings. |
| `fixed:roles:reader` | `fixed_GkfG-1NSwEGb4hpK3-E3qHyNltc` | `roles:read`<br>`teams.roles:read`<br>`users.roles:read`<br>`users.permissions:read` | Read all access control roles, roles and permissions assigned to users, teams. |
| `fixed:roles:resetter` | `fixed_WgPpC3qJRmVpVTJavFNwfS5RuzQ` | `roles:write` with scope `permissions:type:escalate` | Reset basic roles to their default. |
| `fixed:roles:writer` | `fixed_W5aFaw8isAM27x_eWfElBhZ0iOc` | All permissions from `fixed:roles:reader` and <br>`roles:write`<br>`roles:delete`<br>`teams.roles:add`<br>`teams.roles:remove`<br>`users.roles:add`<br>`users.roles:remove` | Create, read, update, or delete all roles, assign or unassign roles to users, teams. |
| `fixed:serviceaccounts:creator` | `fixed_Ikw60fckA0MyiiZ73BawSfOULy4` | `serviceaccounts:create` | Create Grafana service accounts. |
| `fixed:serviceaccounts:reader` | `fixed_QFjJAZ88iawMLInYOxPA1DB1w6I` | `serviceaccounts:read` | Read Grafana service accounts. |
| `fixed:serviceaccounts:writer` | `fixed_iBvUNUEZBZ7PUW0vdkN5iojc2sk` | `serviceaccounts:read`<br>`serviceaccounts:create`<br>`serviceaccounts:write`<br>`serviceaccounts:delete`<br>`serviceaccounts.permissions:read`<br>`serviceaccounts.permissions:write` | Create, update, read and delete all Grafana service accounts and manage service account permissions. |
| `fixed:settings:reader` | `fixed_0LaUt1x6PP8hsZzEBhqPQZFUd8Q` | `settings:read` | Read Grafana instance settings. |
| `fixed:settings:writer` | `fixed_joIHDgMrGg790hMhUufVzcU4j44` | All permissions from `fixed:settings:reader` and<br>`settings:write` | Read and update Grafana instance settings. |
| `fixed:stats:reader` | `fixed_OnRCXxZVINWpcKvTF5A1gecJ7pA` | `server.stats:read` | Read Grafana instance statistics. |
| `fixed:support.bundles:reader` | `fixed_gcPjI3PTUJwRx-GJZwDhNa7zbos` | `support.bundles:read` | List and download support bundles. |
| `fixed:support.bundles:writer` | `fixed_dTgCv9Wxrp_WHAhwHYIgeboxKpE` | `support.bundles:read`<br>`support.bundles:create`<br>`support.bundles:delete` | Create, delete, list and download support bundles. |
| `fixed:teams:creator` | `fixed_nzVQoNSDSn0fg1MDgO6XnZX2RZI` | `teams:create`<br>`org.users:read` | Create a team and list organization users (required to manage the created team). |
| `fixed:teams:read` | `fixed_Z8pB0GQlrqRt8IZBCJQxPWvJPgQ` | `teams:read` | List all teams. |
| `fixed:teams:writer` | `fixed_xw1T0579h620MOYi4L96GUs7fZY` | `teams:create`<br>`teams:delete`<br>`teams:read`<br>`teams:write`<br>`teams.permissions:read`<br>`teams.permissions:write` | Create, read, update and delete teams and manage team memberships. |
| `fixed:usagestats:reader` | `fixed_eAM0azEvnWFCJAjNkUKnGL_1-bU` | `server.usagestats.report:read` | View usage statistics report. |
| `fixed:users:reader` | `fixed_buZastUG3reWyQpPemcWjGqPAd0` | `users:read`<br>`users.quotas:read`<br>`users.authtoken:read` | Read all users and their information, such as team memberships, authentication tokens, and quotas. |
| `fixed:users:writer` | `fixed_wjzgHHo_Ux25DJuELn_oiAdB_yM` | All permissions from `fixed:users:reader` and <br>`users:write`<br>`users:create`<br>`users:delete`<br>`users:enable`<br>`users:disable`<br>`users.password:write`<br>`users.permissions:write`<br>`users:logout`<br>`users.authtoken:write`<br>`users.quotas:write` | Read and update all attributes and settings for all users in Grafana: update user information, read user information, create or enable or disable a user, make a user a Grafana administrator, sign out a user, update a user’s authentication token, or update quotas for all users. |
| Fixed role | UUID | Permissions | Description |
| ----------------------------------------------- | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `fixed:alerting:reader` | `fixed_O2oP1_uBFozI2i93klAkcvEWR30` | All permissions from `fixed:alerting.rules:reader` <br>`fixed:alerting.instances:reader`<br>`fixed:alerting.notifications:reader` | Read-only permissions for all Grafana, Mimir, Loki and Alertmanager alert rules\*, alerts, contact points, and notification policies.[\*](#alerting-roles) |
| `fixed:alerting:writer` | `fixed_-PAZgSJsDlRD8NUg-PFSeH_BkJY` | All permissions from `fixed:alerting.rules:writer` <br>`fixed:alerting.instances:writer`<br>`fixed:alerting.notifications:writer` | Create, update, and delete Grafana, Mimir, Loki and Alertmanager alert rules\*, silences, contact points, templates, mute timings, and notification policies.[\*](#alerting-roles) |
| `fixed:alerting.instances:reader` | `fixed_ut5fVS-Ulh_ejFoskFhJT_rYg0Y` | `alert.instances:read` for organization scope <br> `alert.instances.external:read` for scope `datasources:*` | Read all alerts and silences in the organization produced by Grafana Alerts and Mimir and Loki alerts and silences.[\*](#alerting-roles) |
| `fixed:alerting.instances:writer` | `fixed_pKOBJE346uyqMLdgWbk1NsQfEl0` | All permissions from `fixed:alerting.instances:reader` and<br> `alert.instances:create`<br>`alert.instances:write` for organization scope <br> `alert.instances.external:write` for scope `datasources:*` | Create, update and expire all silences in the organization produced by Grafana, Mimir, and Loki.[\*](#alerting-roles) |
| `fixed:alerting.notifications:reader` | `fixed_hmBn0lX5h1RZXB9Vaot420EEdA0` | `alert.notifications:read` for organization scope<br>`alert.notifications.external:read` for scope `datasources:*` | Read all Grafana and Alertmanager contact points, templates, and notification policies.[\*](#alerting-roles) |
| `fixed:alerting.notifications:writer` | `fixed_XplK6HPNxf9AP5IGTdB5Iun4tJc` | All permissions from `fixed:alerting.notifications:reader` and<br>`alert.notifications:write`for organization scope<br>`alert.notifications.external:read` for scope `datasources:*` | Create, update, and delete contact points, templates, mute timings and notification policies for Grafana and external Alertmanager.[\*](#alerting-roles) |
| `fixed:alerting.provisioning:writer` | `fixed_y7pFjdEkxpx5ETdcxPvp0AgRuUo` | `alert.provisioning:read` and `alert.provisioning:write` | Create, update and delete Grafana alert rules, notification policies, contact points, templates, etc via provisioning API. [\*](#alerting-roles) |
| `fixed:alerting.provisioning.secrets:reader` | `fixed_9fmzXXZZG-Od0Amy2ofEG8Uk--c` | `alert.provisioning:read` and `alert.provisioning.secrets:read` | Read-only permissions for Provisioning API and let export resources with decrypted secrets [\*](#alerting-roles) |
| `fixed:alerting.provisioning.provenance:writer` | `fixed_eAxlzfkTuobvKEgXHveFMBZrOj8` | `alert.provisioning.provenance:write` | Set provenance status to alert rules, notification policies, contact points, etc. Should be used together with regular writer roles. [\*](#alerting-roles) |
| `fixed:alerting.rules:reader` | `fixed_fRGKL_vAqUsmUWq5EYKnOha9DcA` | `alert.rule:read`, `alert.silences:read` for scope `folders:*` <br> `alert.rules.external:read` for scope `datasources:*` <br> `alert.notifications.time-intervals:read` <br> `alert.notifications.receivers:list` | Read all\* Grafana, Mimir, and Loki alert rules.[\*](#alerting-roles) and read rule-specific silences |
| `fixed:alerting.rules:writer` | `fixed_YJJGwAalUwDZPrXSyFH8GfYBXAc` | All permissions from `fixed:alerting.rules:reader` and <br> `alert.rule:create` <br> `alert.rule:write` <br> `alert.rule:delete` <br> `alert.silences:create` <br> `alert.silences:write` for scope `folders:*` <br> `alert.rules.external:write` for scope `datasources:*` | Create, update, and delete all\* Grafana, Mimir, and Loki alert rules.[\*](#alerting-roles) and manage rule-specific silences |
| `fixed:annotations:reader` | `fixed_hpZnoizrfAJsrceNcNQqWYV-xNU` | `annotations:read` for scopes `annotations:type:*` | Read all annotations and annotation tags. |
| `fixed:annotations:writer` | `fixed_ZVW-Aa9Tzle6J4s2aUFcq1StKWE` | All permissions from `fixed:annotations:reader` <br>`annotations:write` <br>`annotations.create`<br> `annotations:delete` for scope `annotations:type:*` | Read, create, update and delete all annotations and annotation tags. |
| `fixed:annotations.dashboard:writer` | `fixed_8A775xenXeKaJk4Cr7bchP9yXOA` | `annotations:write` <br>`annotations.create`<br> `annotations:delete` for scope `annotations:type:dashboard` | Create, update and delete dashboard annotations and annotation tags. |
| `fixed:authentication.config:writer` | `fixed_0rYhZ2Qnzs8AdB1nX7gexk3fHDw` | `settings:read` for scope `settings:auth.saml:*` <br> `settings:write` for scope `settings:auth.saml:*` | Read and update authentication and SAML settings. |
| `fixed:general.auth.config:writer` | `fixed_QFxIT_FGtBqbIVJIwx1bLgI5z6c` | `settings:read` for scope `settings:auth:oauth_allow_insecure_email_lookup` <br> `settings:write` for scope `settings:auth:oauth_allow_insecure_email_lookup` | Read and update the Grafana instance's general authentication configuration settings. |
| `fixed:dashboards:creator` | `fixed_ZorKUcEPCM01A1fPakEzGBUyU64` | `dashboards:create`<br>`folders:read` | Create dashboards. |
| `fixed:dashboards:reader` | `fixed_Sgr67JTOhjQGFlzYRahOe45TdWM` | `dashboards:read` | Read all dashboards. |
| `fixed:dashboards:writer` | `fixed_OK2YOQGIoI1G031hVzJB6rAJQAs` | All permissions from `fixed:dashboards:reader` and <br>`dashboards:write`<br>`dashboards:delete`<br>`dashboards:create`<br>`dashboards.permissions:read`<br>`dashboards.permissions:write` | Read, create, update, and delete all dashboards. |
| `fixed:dashboards.insights:reader` | `fixed_JlBJ2_gizP8zhgaeGE2rjyZe2Rs` | `dashboards.insights:read` | Read dashboard insights data and see presence indicators. |
| `fixed:dashboards.permissions:reader` | `fixed_f17oxuXW_58LL8mYJsm4T_mCeIw` | `dashboards.permissions:read` | Read all dashboard permissions. |
| `fixed:dashboards.permissions:writer` | `fixed_CcznxhWX_Yqn8uWMXMQ-b5iFW9k` | All permissions from `fixed:dashboards.permissions:reader` and <br>`dashboards.permissions:write` | Read and update all dashboard permissions. |
| `fixed:dashboards.public:writer` | `fixed_f_GHHRBciaqESXfGz2oCcooqHxs` | `dashboards.public:write` | Create, update, delete or pause a shared dashboard. |
| `fixed:datasources:creator` | `fixed_XX8jHREgUt-wo1A-rPXIiFlX6Zw` | `datasources:create` | Create data sources. |
| `fixed:datasources:explorer` | `fixed_qDzW9mzx9yM91T5Bi8dHUM2muTw` | `datasources:explore` | Enable the Explore feature. Data source permissions still apply, you can only query data sources for which you have query permissions. |
| `fixed:datasources:reader` | `fixed_C2x8IxkiBc1KZVjyYH775T9jNMQ` | `datasources:read`<br>`datasources:query` | Read and query data sources. |
| `fixed:datasources:writer` | `fixed_q8HXq8kjjA5IlHHgBJlKlUyaNik` | All permissions from `fixed:datasources:reader` and <br>`datasources:create`<br>`datasources:write`<br>`datasources:delete` | Read, query, create, delete, or update a data source. |
| `fixed:datasources.builtin:reader` | `fixed_q8HXq8kjjA5IlHHgBJlKlUyaNik` | `datasources:read` and `datasources:query` scoped to `datasources:uid:grafana` | An internal role used to grant Viewers access to the builtin example data source in Grafana. |
| `fixed:datasources.caching:reader` | `fixed_D2ddpGxJYlw0mbsTS1ek9fj0kj4` | `datasources.caching:read` | Read data source query caching settings. |
| `fixed:datasources.caching:writer` | `fixed_JtFjHr7jd7hSqUYcktKvRvIOGRE` | `datasources.caching:read`<br>`datasources.caching:write` | Enable, disable, or update query caching settings. |
| `fixed:datasources.id:reader` | `fixed_entg--fHmDqWY2-69N0ocawK0Os` | `datasources.id:read` | Read the ID of a data source based on its name. |
| `fixed:datasources.insights:reader` | `fixed_EBZ3NwlfecNPp2p0XcZRC1nfEYk` | `datasources.insights:read` | Read data source insights data. |
| `fixed:datasources.permissions:reader` | `fixed_ErYA-cTN3yn4h4GxaVPcawRhiOY` | `datasources.permissions:read` | Read data source permissions. |
| `fixed:datasources.permissions:writer` | `fixed_aiQh9YDfLOKjQhYasF9_SFUjQiw` | All permissions from `fixed:datasources.permissions:reader` and <br>`datasources.permissions:write` | Create, read, or delete permissions of a data source. |
| `fixed:folders:creator` | `fixed_gGLRbZGAGB6n9uECqSh_W382RlQ` | `folders:create` | Create folders in the root level. |
| `fixed:folders:reader` | `fixed_yeW-5QPeo-i5PZUIUXMlAA97GnQ` | `folders:read`<br>`dashboards:read` | Read all folders and dashboards. |
| `fixed:folders:writer` | `fixed_wJXLoTzgE7jVuz90dryYoiogL0o` | All permissions from `fixed:dashboards:writer` and <br>`folders:read`<br>`folders:write`<br>`folders:create`<br>`folders:delete`<br>`folders.permissions:read`<br>`folders.permissions:write` | Read, update, and delete all folders and dashboards. Create folders and subfolders. |
| `fixed:folders.general:reader` | `fixed_rSASbkg8DvpG_gTX5s41d7uxRvI` | `folders:read` scoped to `folders:uid:general` | An internal role used to correctly display access to the folder tree for Viewer role. |
| `fixed:folders.permissions:reader` | `fixed_E06l4cx0JFm47EeLBE4nmv3pnSo` | `folders.permissions:read` | Read all folder permissions. |
| `fixed:folders.permissions:writer` | `fixed_3GAgpQ_hWG8o7-lwNb86_VB37eI` | All permissions from `fixed:folders.permissions:reader` and <br>`folders.permissions:write` | Read and update all folder permissions. |
| `fixed:ldap:reader` | `fixed_lMcOPwSkxKY-qCK8NMJc5k6izLE` | `ldap.user:read`<br>`ldap.status:read` | Read the LDAP configuration and LDAP status information. |
| `fixed:ldap:writer` | `fixed_p6AvnU4GCQyIh7-hbwI-bk3GYnU` | All permissions from `fixed:ldap:reader` and <br>`ldap.user:sync`<br>`ldap.config:reload` | Read and update the LDAP configuration, and read LDAP status information. |
| `fixed:library.panels:creator` | `fixed_6eX6ItfegCIY5zLmPqTDW8ZV7KY` | `library.panels:create`<br>`folders:read` | Create library panel at the root level. |
| `fixed:library.panels:general.reader` | `fixed_ct0DghiBWR_2BiQm3EvNPDVmpio` | `library.panels:read` | Read all library panels at the root level. |
| `fixed:library.panels:general.writer` | `fixed_DgprkmqfN_1EhZ2v1_d1fYG8LzI` | All permissions from `fixed:library.panels:general.reader` plus<br>`library.panels:create`<br>`library.panels:delete`<br>`library.panels:write` | Create, read, write or delete all library panels and their permissions at the root level. |
| `fixed:library.panels:reader` | `fixed_tvTr9CnZ6La5vvUO_U_X1LPnhUs` | `library.panels:read` | Read all library panels. |
| `fixed:library.panels:writer` | `fixed_JTljAr21LWLTXCkgfBC4H0lhBC8` | All permissions from `fixed:library.panels:reader` plus<br>`library.panels:create`<br>`library.panels:delete`<br>`library.panels:write` | Create, read, write or delete all library panels and their permissions. |
| `fixed:licensing:reader` | `fixed_OADpuXvNEylO2Kelu3GIuBXEAYE` | `licensing:read`<br>`licensing.reports:read` | Read licensing information and licensing reports. |
| `fixed:licensing:writer` | `fixed_gzbz3rJpQMdaKHt-E4q0PVaKMoE` | All permissions from `fixed:licensing:reader` and <br>`licensing:write`<br>`licensing:delete` | Read licensing information and licensing reports, update and delete the license token. |
| `fixed:migrationassistant:migrator` | `fixed_LLk2p7TRuBztOAksTQb1Klc8YTk` | `migrationassistant:migrate` | Execute on-prem to cloud migrations through the Migration Assistant. |
| `fixed:org.users:reader` | `fixed_oCqNwlVHLOpw7-jAlwp4HzYqwGY` | `org.users:read` | Read users within a single organization. |
| `fixed:org.users:writer` | `fixed_VERj5nayasjgf_Yh0sWqqCkxWlw` | All permissions from `fixed:org.users:reader` and <br>`org.users:add`<br>`org.users:remove`<br>`org.users:write` | Within a single organization, add a user, invite a new user, read information about a user and their role, remove a user from that organization, or change the role of a user. |
| `fixed:organization:maintainer` | `fixed_CMm-uuBaPUBf4r8XG3jIvxo55bg` | All permissions from `fixed:organization:reader` and <br> `orgs:write`<br>`orgs:create`<br>`orgs:delete`<br>`orgs.quotas:write` | Create, read, write, or delete an organization. Read or write its quotas. This role needs to be assigned globally. |
| `fixed:organization:reader` | `fixed_0SZPJlTHdNEe8zO91zv7Zwiwa2w` | `orgs:read`<br>`orgs.quotas:read` | Read an organization and its quotas. |
| `fixed:organization:writer` | `fixed_Y4jGqDd8w1yCrPwlik8z5Iu8-3M` | All permissions from `fixed:organization:reader` and <br> `orgs:write`<br>`orgs.preferences:read`<br>`orgs.preferences:write` | Read an organization, its quotas, or its preferences. Update organization properties, or its preferences. |
| `fixed:plugins:maintainer` | `fixed_yEOKidBcWgbm74x-nTa3lW5lOyY` | `plugins:install` | Install and uninstall plugins. Needs to be assigned globally. |
| `fixed:plugins:writer` | `fixed_MRYpGk7kpNNwt2VoVOXFiPnQziE` | `plugins:write` | Enable and disable plugins and edit plugins' settings. |
| `fixed:plugins.app:reader` | `fixed_AcZRiNYx7NueYkUqzw1o2OGGUAA` | `plugins.app:access` | Access application plugins (still enforcing the organization role). |
| `fixed:provisioning:writer` | `fixed_bgk1FCyR6OEDwhgirZlQgu5LlCA` | `provisioning:reload` | Reload provisioning. |
| `fixed:reports:reader` | `fixed_72_8LU_0ukfm6BdblOw8Z9q-GQ8` | `reports:read`<br>`reports:send`<br>`reports.settings:read` | Read all reports and shared report settings. |
| `fixed:reports:writer` | `fixed_jBW3_7g1EWOjGVBYeVRwtFxhUNw` | All permissions from `fixed:reports:reader` and <br>`reports:create`<br>`reports:write`<br>`reports:delete`<br>`reports.settings:write` | Create, read, update, or delete all reports and shared report settings. |
| `fixed:roles:reader` | `fixed_GkfG-1NSwEGb4hpK3-E3qHyNltc` | `roles:read`<br>`teams.roles:read`<br>`users.roles:read`<br>`users.permissions:read` | Read all access control roles, roles and permissions assigned to users, teams. |
| `fixed:roles:resetter` | `fixed_WgPpC3qJRmVpVTJavFNwfS5RuzQ` | `roles:write` with scope `permissions:type:escalate` | Reset basic roles to their default. |
| `fixed:roles:writer` | `fixed_W5aFaw8isAM27x_eWfElBhZ0iOc` | All permissions from `fixed:roles:reader` and <br>`roles:write`<br>`roles:delete`<br>`teams.roles:add`<br>`teams.roles:remove`<br>`users.roles:add`<br>`users.roles:remove` | Create, read, update, or delete all roles, assign or unassign roles to users, teams. |
| `fixed:serviceaccounts:creator` | `fixed_Ikw60fckA0MyiiZ73BawSfOULy4` | `serviceaccounts:create` | Create Grafana service accounts. |
| `fixed:serviceaccounts:reader` | `fixed_QFjJAZ88iawMLInYOxPA1DB1w6I` | `serviceaccounts:read` | Read Grafana service accounts. |
| `fixed:serviceaccounts:writer` | `fixed_iBvUNUEZBZ7PUW0vdkN5iojc2sk` | `serviceaccounts:read`<br>`serviceaccounts:create`<br>`serviceaccounts:write`<br>`serviceaccounts:delete`<br>`serviceaccounts.permissions:read`<br>`serviceaccounts.permissions:write` | Create, update, read and delete all Grafana service accounts and manage service account permissions. |
| `fixed:settings:reader` | `fixed_0LaUt1x6PP8hsZzEBhqPQZFUd8Q` | `settings:read` | Read Grafana instance settings. |
| `fixed:settings:writer` | `fixed_joIHDgMrGg790hMhUufVzcU4j44` | All permissions from `fixed:settings:reader` and<br>`settings:write` | Read and update Grafana instance settings. |
| `fixed:stats:reader` | `fixed_OnRCXxZVINWpcKvTF5A1gecJ7pA` | `server.stats:read` | Read Grafana instance statistics. |
| `fixed:support.bundles:reader` | `fixed_gcPjI3PTUJwRx-GJZwDhNa7zbos` | `support.bundles:read` | List and download support bundles. |
| `fixed:support.bundles:writer` | `fixed_dTgCv9Wxrp_WHAhwHYIgeboxKpE` | `support.bundles:read`<br>`support.bundles:create`<br>`support.bundles:delete` | Create, delete, list and download support bundles. |
| `fixed:teams:creator` | `fixed_nzVQoNSDSn0fg1MDgO6XnZX2RZI` | `teams:create`<br>`org.users:read` | Create a team and list organization users (required to manage the created team). |
| `fixed:teams:read` | `fixed_Z8pB0GQlrqRt8IZBCJQxPWvJPgQ` | `teams:read` | List all teams. |
| `fixed:teams:writer` | `fixed_xw1T0579h620MOYi4L96GUs7fZY` | `teams:create`<br>`teams:delete`<br>`teams:read`<br>`teams:write`<br>`teams.permissions:read`<br>`teams.permissions:write` | Create, read, update and delete teams and manage team memberships. |
| `fixed:usagestats:reader` | `fixed_eAM0azEvnWFCJAjNkUKnGL_1-bU` | `server.usagestats.report:read` | View usage statistics report. |
| `fixed:users:reader` | `fixed_buZastUG3reWyQpPemcWjGqPAd0` | `users:read`<br>`users.quotas:read`<br>`users.authtoken:read` | Read all users and their information, such as team memberships, authentication tokens, and quotas. |
| `fixed:users:writer` | `fixed_wjzgHHo_Ux25DJuELn_oiAdB_yM` | All permissions from `fixed:users:reader` and <br>`users:write`<br>`users:create`<br>`users:delete`<br>`users:enable`<br>`users:disable`<br>`users.password:write`<br>`users.permissions:write`<br>`users:logout`<br>`users.authtoken:write`<br>`users.quotas:write` | Read and update all attributes and settings for all users in Grafana: update user information, read user information, create or enable or disable a user, make a user a Grafana administrator, sign out a user, update a user’s authentication token, or update quotas for all users. |
### Alerting roles
@@ -164,10 +164,20 @@ Access to Grafana alert rules is an intersection of many permissions:
- Permission to read a folder. For example, the fixed role `fixed:folders:reader` includes the action `folders:read` and a folder scope `folders:id:`.
- Permission to query **all** data sources that a given alert rule uses. If a user cannot query a given data source, they cannot see any alert rules that query that data source.
There is only one exclusion at this moment. Role `fixed:alerting.provisioning:writer` does not require user to have any additional permissions and provides access to all aspects of the alerting configuration via special provisioning API.
There is only one exclusion. Role `fixed:alerting.provisioning:writer` does not require user to have any additional permissions and provides access to all aspects of the alerting configuration via special provisioning API.
For more information about the permissions required to access alert rules, refer to [Create a custom role to access alerts in a folder](ref:plan-rbac-rollout-strategy-create-a-custom-role-to-access-alerts-in-a-folder).
#### Alerting basic roles
The following table lists the default RBAC alerting role assignments to the basic roles:
| Basic role | Associated fixed roles | Description |
| ---------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| Admin | `fixed:alerting:writer`<br>`fixed:alerting.provisioning.secrets:reader`<br>`fixed:alerting.provisioning:writer` | Default [Grafana organization administrator](ref:rbac-basic-roles) assignments. |
| Editor | `fixed:alerting:writer`<br>`fixed:alerting.provisioning.provenance:writer` | Default [Editor](ref:rbac-basic-roles) assignments. |
| Viewer | `fixed:alerting:reader` | Default [Viewer](ref:rbac-basic-roles) assignments. |
### Grafana OnCall roles
If you are using [Grafana OnCall](ref:oncall), you can try out the integration between Grafana OnCall and RBAC.
@@ -62,6 +62,9 @@ The following steps describe a basic configuration:
# The URL of the Loki server
loki_remote_url = http://localhost:3100
[feature_toggles]
enable = alertingCentralAlertHistory
```
1. **Configure the Loki data source in Grafana**
@@ -17,55 +17,166 @@ weight: 155
# Configure RBAC
Role-based access control (RBAC) for Grafana Enterprise and Grafana Cloud provides a standardized way of granting, changing, and revoking access, so that users can view and modify Grafana resources.
[Role-based access control (RBAC)](/docs/grafana/latest/administration/roles-and-permissions/access-control/plan-rbac-rollout-strategy/) for Grafana Enterprise and Grafana Cloud provides a standardized way of granting, changing, and revoking access, so that users can view and modify Grafana resources.
A user is any individual who can log in to Grafana. Each user is associated with a role that includes permissions. Permissions determine the tasks a user can perform in the system.
A user is any individual who can log in to Grafana. Each user has a role that includes permissions. Permissions determine the tasks a user can perform in the system.
Each permission contains one or more actions and a scope.
## Role types
Grafana has three types of roles for managing access:
- **Basic roles**: Admin, Editor, Viewer, and No basic role. These are assigned to users and provide default access levels.
- **Fixed roles**: Predefined groups of permissions for specific use cases. Basic roles automatically include certain fixed roles.
- **Custom roles**: User-defined roles that combine specific permissions for granular access control.
## Basic role permissions
The following table summarizes the default alerting permissions for each basic role.
| Capability | Admin | Editor | Viewer |
| ----------------------------------------- | :---: | :----: | :----: |
| View alert rules | ✓ | ✓ | ✓ |
| Create, edit, and delete alert rules | ✓ | ✓ | |
| View silences | ✓ | ✓ | ✓ |
| Create, edit, and expire silences | ✓ | ✓ | |
| View contact points and templates | ✓ | ✓ | ✓ |
| Create, edit, and delete contact points | ✓ | ✓ | |
| View notification policies | ✓ | ✓ | ✓ |
| Create, edit, and delete policies | ✓ | ✓ | |
| View mute timings | ✓ | ✓ | ✓ |
| Create, edit, and delete timing intervals | ✓ | ✓ | |
| Access provisioning API | ✓ | ✓ | |
| Export with decrypted secrets | ✓ | | |
{{< admonition type="note" >}}
Access to alert rules also requires permission to read the folder containing the rules and permission to query the data sources used in the rules.
{{< /admonition >}}
## Permissions
Grafana Alerting has the following permissions.
Grafana Alerting has the following permissions organized by resource type.
| Action | Applicable scope | Description |
| -------------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `alert.instances.external:read` | `datasources:*`<br>`datasources:uid:*` | Read alerts and silences in data sources that support alerting. |
| `alert.instances.external:write` | `datasources:*`<br>`datasources:uid:*` | Manage alerts and silences in data sources that support alerting. |
| `alert.instances:create` | n/a | Create silences in the current organization. |
| `alert.instances:read` | n/a | Read alerts and silences in the current organization. |
| `alert.instances:write` | n/a | Update and expire silences in the current organization. |
| `alert.notifications.external:read` | `datasources:*`<br>`datasources:uid:*` | Read templates, contact points, notification policies, and mute timings in data sources that support alerting. |
| `alert.notifications.external:write` | `datasources:*`<br>`datasources:uid:*` | Manage templates, contact points, notification policies, and mute timings in data sources that support alerting. |
| `alert.notifications:write` | n/a | Manage templates, contact points, notification policies, and mute timings in the current organization. |
| `alert.notifications:read` | n/a | Read all templates, contact points, notification policies, and mute timings in the current organization. |
| `alert.rules.external:read` | `datasources:*`<br>`datasources:uid:*` | Read alert rules in data sources that support alerting (Prometheus, Mimir, and Loki) |
| `alert.rules.external:write` | `datasources:*`<br>`datasources:uid:*` | Create, update, and delete alert rules in data sources that support alerting (Mimir and Loki). |
| `alert.rules:create` | `folders:*`<br>`folders:uid:*` | Create Grafana alert rules in a folder and its subfolders. Combine this permission with `folders:read` in a scope that includes the folder and `datasources:query` in the scope of data sources the user can query. |
| `alert.rules:delete` | `folders:*`<br>`folders:uid:*` | Delete Grafana alert rules in a folder and its subfolders. Combine this permission with `folders:read` in a scope that includes the folder. |
| `alert.rules:read` | `folders:*`<br>`folders:uid:*` | Read Grafana alert rules in a folder and its subfolders. Combine this permission with `folders:read` in a scope that includes the folder. |
| `alert.rules:write` | `folders:*`<br>`folders:uid:*` | Update Grafana alert rules in a folder and its subfolders. Combine this permission with `folders:read` in a scope that includes the folder. To allow query modifications add `datasources:query` in the scope of data sources the user can query. |
| `alert.silences:create` | `folders:*`<br>`folders:uid:*` | Create rule-specific silences in a folder and its subfolders. |
| `alert.silences:read` | `folders:*`<br>`folders:uid:*` | Read all general silences and rule-specific silences in a folder and its subfolders. |
| `alert.silences:write` | `folders:*`<br>`folders:uid:*` | Update and expire rule-specific silences in a folder and its subfolders. |
| `alert.provisioning:read` | n/a | Read all Grafana alert rules, notification policies, etc via provisioning API. Permissions to folders and data source are not required. |
| `alert.provisioning.secrets:read` | n/a | Same as `alert.provisioning:read` plus ability to export resources with decrypted secrets. |
| `alert.provisioning:write` | n/a | Update all Grafana alert rules, notification policies, etc via provisioning API. Permissions to folders and data source are not required. |
| `alert.provisioning.provenance:write` | n/a | Set provisioning status for alerting resources. Cannot be used alone. Requires user to have permissions to access resources |
| `alert.notifications.receivers:read` | `receivers:*`<br>`receivers:uid:*` | Read contact points. |
| `alert.notifications.receivers.secrets:read` | `receivers:*`<br>`receivers:uid:*` | Export contact points with decrypted secrets. |
| `alert.notifications.receivers:create` | n/a | Create a new contact points. The creator is automatically granted full access to the created contact point. |
| `alert.notifications.receivers:write` | `receivers:*`<br>`receivers:uid:*` | Update existing contact points. |
| `alert.notifications.receivers:delete` | `receivers:*`<br>`receivers:uid:*` | Update and delete existing contact points. |
| `receivers.permissions:read` | `receivers:*`<br>`receivers:uid:*` | Read permissions for contact points. |
| `receivers.permissions:write` | `receivers:*`<br>`receivers:uid:*` | Manage permissions for contact points. |
| `alert.notifications.time-intervals:read` | n/a | Read mute time intervals. |
| `alert.notifications.time-intervals:write` | n/a | Create new or update existing mute time intervals. |
| `alert.notifications.time-intervals:delete` | n/a | Delete existing time intervals. |
| `alert.notifications.templates:read` | n/a | Read templates. |
| `alert.notifications.templates:write` | n/a | Create new or update existing templates. |
| `alert.notifications.templates:delete` | n/a | Delete existing templates. |
| `alert.notifications.templates.test:write` | n/a | Test templates with custom payloads (preview and payload editor functionality). |
| `alert.notifications.routes:read` | n/a | Read notification policies. |
| `alert.notifications.routes:write` | n/a | Create new, update and update notification policies. |
### Alert rules
Permissions for managing Grafana-managed alert rules.
| Action | Applicable scope | Description |
| -------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `alert.rules:create` | `folders:*`<br>`folders:uid:*` | Create Grafana alert rules in a folder and its subfolders. Combine this permission with `folders:read` in a scope that includes the folder and `datasources:query` in the scope of data sources the user can query. |
| `alert.rules:read` | `folders:*`<br>`folders:uid:*` | Read Grafana alert rules in a folder and its subfolders. Combine this permission with `folders:read` in a scope that includes the folder. |
| `alert.rules:write` | `folders:*`<br>`folders:uid:*` | Update Grafana alert rules in a folder and its subfolders. Combine this permission with `folders:read` in a scope that includes the folder. To allow query modifications add `datasources:query` in the scope of data sources the user can query. |
| `alert.rules:delete` | `folders:*`<br>`folders:uid:*` | Delete Grafana alert rules in a folder and its subfolders. Combine this permission with `folders:read` in a scope that includes the folder. |
### External alert rules
Permissions for managing alert rules in external data sources that support alerting.
| Action | Applicable scope | Description |
| ---------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `alert.rules.external:read` | `datasources:*`<br>`datasources:uid:*` | Read alert rules in data sources that support alerting (Prometheus, Mimir, and Loki). |
| `alert.rules.external:write` | `datasources:*`<br>`datasources:uid:*` | Create, update, and delete alert rules in data sources that support alerting (Mimir and Loki). |
### Alert instances and silences
Permissions for managing alert instances and silences in Grafana.
| Action | Applicable scope | Description |
| ------------------------ | ------------------------------ | ------------------------------------------------------------------------------------ |
| `alert.instances:read` | n/a | Read alerts and silences in the current organization. |
| `alert.instances:create` | n/a | Create silences in the current organization. |
| `alert.instances:write` | n/a | Update and expire silences in the current organization. |
| `alert.silences:read` | `folders:*`<br>`folders:uid:*` | Read all general silences and rule-specific silences in a folder and its subfolders. |
| `alert.silences:create` | `folders:*`<br>`folders:uid:*` | Create rule-specific silences in a folder and its subfolders. |
| `alert.silences:write` | `folders:*`<br>`folders:uid:*` | Update and expire rule-specific silences in a folder and its subfolders. |
### External alert instances
Permissions for managing alert instances in external data sources.
| Action | Applicable scope | Description |
| -------------------------------- | -------------------------------------- | ----------------------------------------------------------------- |
| `alert.instances.external:read` | `datasources:*`<br>`datasources:uid:*` | Read alerts and silences in data sources that support alerting. |
| `alert.instances.external:write` | `datasources:*`<br>`datasources:uid:*` | Manage alerts and silences in data sources that support alerting. |
### Contact points
Permissions for managing contact points (notification receivers).
| Action | Applicable scope | Description |
| -------------------------------------------- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `alert.notifications.receivers:list` | n/a | List contact points in the current organization. |
| `alert.notifications.receivers:read` | `receivers:*`<br>`receivers:uid:*` | Read contact points. |
| `alert.notifications.receivers.secrets:read` | `receivers:*`<br>`receivers:uid:*` | Export contact points with decrypted secrets. |
| `alert.notifications.receivers:create` | n/a | Create a new contact points. The creator is automatically granted full access to the created contact point. |
| `alert.notifications.receivers:write` | `receivers:*`<br>`receivers:uid:*` | Update existing contact points. |
| `alert.notifications.receivers:delete` | `receivers:*`<br>`receivers:uid:*` | Update and delete existing contact points. |
| `alert.notifications.receivers:test` | `receivers:*`<br>`receivers:uid:*` | Test contact points to verify their configuration. |
| `receivers.permissions:read` | `receivers:*`<br>`receivers:uid:*` | Read permissions for contact points. |
| `receivers.permissions:write` | `receivers:*`<br>`receivers:uid:*` | Manage permissions for contact points. |
### Notification policies
Permissions for managing notification policies (routing rules).
| Action | Applicable scope | Description |
| ---------------------------------- | ---------------- | ----------------------------------------------------- |
| `alert.notifications.routes:read` | n/a | Read notification policies. |
| `alert.notifications.routes:write` | n/a | Create new, update, and delete notification policies. |
### Time intervals
Permissions for managing mute time intervals.
| Action | Applicable scope | Description |
| ------------------------------------------- | ---------------- | -------------------------------------------------- |
| `alert.notifications.time-intervals:read` | n/a | Read mute time intervals. |
| `alert.notifications.time-intervals:write` | n/a | Create new or update existing mute time intervals. |
| `alert.notifications.time-intervals:delete` | n/a | Delete existing time intervals. |
### Templates
Permissions for managing notification templates.
| Action | Applicable scope | Description |
| ------------------------------------------ | ---------------- | ------------------------------------------------------------------------------- |
| `alert.notifications.templates:read` | n/a | Read templates. |
| `alert.notifications.templates:write` | n/a | Create new or update existing templates. |
| `alert.notifications.templates:delete` | n/a | Delete existing templates. |
| `alert.notifications.templates.test:write` | n/a | Test templates with custom payloads (preview and payload editor functionality). |
### General notifications
Legacy permissions for managing all notification resources.
| Action | Applicable scope | Description |
| --------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------- |
| `alert.notifications:read` | n/a | Read all templates, contact points, notification policies, and mute timings in the current organization. |
| `alert.notifications:write` | n/a | Manage templates, contact points, notification policies, and mute timings in the current organization. |
### External notifications
Permissions for managing notification resources in external data sources.
| Action | Applicable scope | Description |
| ------------------------------------ | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `alert.notifications.external:read` | `datasources:*`<br>`datasources:uid:*` | Read templates, contact points, notification policies, and mute timings in data sources that support alerting. |
| `alert.notifications.external:write` | `datasources:*`<br>`datasources:uid:*` | Manage templates, contact points, notification policies, and mute timings in data sources that support alerting. |
### Provisioning
Permissions for managing alerting resources via the provisioning API.
| Action | Applicable scope | Description |
| ---------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `alert.provisioning:read` | n/a | Read all Grafana alert rules, notification policies, etc via provisioning API. Permissions to folders and data source are not required. |
| `alert.provisioning.secrets:read` | n/a | Same as `alert.provisioning:read` plus ability to export resources with decrypted secrets. |
| `alert.provisioning:write` | n/a | Update all Grafana alert rules, notification policies, etc via provisioning API. Permissions to folders and data source are not required. |
| `alert.rules.provisioning:read` | n/a | Read Grafana alert rules via provisioning API. More specific than `alert.provisioning:read`. |
| `alert.rules.provisioning:write` | n/a | Create, update, and delete Grafana alert rules via provisioning API. More specific than `alert.provisioning:write`. |
| `alert.notifications.provisioning:read` | n/a | Read notification resources (contact points, notification policies, templates, time intervals) via provisioning API. More specific than `alert.provisioning:read`. |
| `alert.notifications.provisioning:write` | n/a | Create, update, and delete notification resources via provisioning API. More specific than `alert.provisioning:write`. |
| `alert.provisioning.provenance:write` | n/a | Set provisioning status for alerting resources. Cannot be used alone. Requires user to have permissions to access resources. |
To help plan your RBAC rollout strategy, refer to [Plan your RBAC rollout strategy](https://grafana.com/docs/grafana/next/administration/roles-and-permissions/access-control/plan-rbac-rollout-strategy/).
@@ -16,7 +16,7 @@ title: Manage access using folders or data sources
weight: 200
---
## Manage access using folders or data sources
# Manage access using folders or data sources
You can extend the access provided by a role to alert rules and rule-specific silences by assigning permissions to individual folders or data sources.
@@ -55,7 +55,7 @@ Details of the fixed roles and the access they provide for Grafana Alerting are
| Full read-only access: `fixed:alerting:reader` | All permissions from `fixed:alerting.rules:reader` <br>`fixed:alerting.instances:reader`<br>`fixed:alerting.notifications:reader` | Read alert rules, alert instances, silences, contact points, and notification policies in Grafana and external providers. |
| Read via Provisioning API + Export Secrets: `fixed:alerting.provisioning.secrets:reader` | `alert.provisioning:read` and `alert.provisioning.secrets:read` | Read alert rules, alert instances, silences, contact points, and notification policies using the provisioning API and use export with decrypted secrets. |
| Access to alert rules provisioning API: `fixed:alerting.provisioning:writer` | `alert.provisioning:read` and `alert.provisioning:write` | Manage all alert rules, notification policies, contact points, templates, in the organization using the provisioning API. |
| Set provisioning status: `fixed:alerting.provisioning.status:writer` | `alert.provisioning.provenance:write` | Set provisioning rules for Alerting resources. Should be used together with other regular roles (Notifications Writer and/or Rules Writer.) |
| Set provisioning status: `fixed:alerting.provisioning.provenance:writer` | `alert.provisioning.provenance:write` | Set provisioning rules for Alerting resources. Should be used together with other regular roles (Notifications Writer and/or Rules Writer.) |
| Contact Point Reader: `fixed:alerting.receivers:reader` | `alert.notifications.receivers:read` for scope `receivers:*` | Read all contact points. |
| Contact Point Creator: `fixed:alerting.receivers:creator` | `alert.notifications.receivers:create` | Create a new contact point. The user is automatically granted full access to the created contact point. |
| Contact Point Writer: `fixed:alerting.receivers:writer` | `alert.notifications.receivers:read`, `alert.notifications.receivers:write`, `alert.notifications.receivers:delete` for scope `receivers:*` and <br> `alert.notifications.receivers:create` | Create a new contact point and manage all existing contact points. |
@@ -63,8 +63,8 @@ Details of the fixed roles and the access they provide for Grafana Alerting are
| Templates Writer: `fixed:alerting.templates:writer` | `alert.notifications.templates:read`, `alert.notifications.templates:write`, `alert.notifications.templates:delete`, `alert.notifications.templates.test:write` | Create new and manage existing notification templates. Test templates with custom payloads. |
| Time Intervals Reader: `fixed:alerting.time-intervals:reader` | `alert.notifications.time-intervals:read` | Read all time intervals. |
| Time Intervals Writer: `fixed:alerting.time-intervals:writer` | `alert.notifications.time-intervals:read`, `alert.notifications.time-intervals:write`, `alert.notifications.time-intervals:delete` | Create new and manage existing time intervals. |
| Notification Policies Reader: `fixed:alerting.routes:reader` | `alert.notifications.routes:read` | Read all time intervals. |
| Notification Policies Writer: `fixed:alerting.routes:writer` | `alert.notifications.routes:read` `alert.notifications.routes:write` | Create new and manage existing time intervals. |
| Notification Policies Reader: `fixed:alerting.routes:reader` | `alert.notifications.routes:read` | Read all notification policies. |
| Notification Policies Writer: `fixed:alerting.routes:writer` | `alert.notifications.routes:read`<br>`alert.notifications.routes:write` | Create new and manage existing notification policies. |
## Create custom roles
@@ -16,25 +16,27 @@ weight: 150
# Configure roles and permissions
This guide explains how to configure roles and permissions for Grafana Alerting for Grafana OSS users. You'll learn how to manage access using roles, folder permissions, and contact point permissions.
A user is any individual who can log in to Grafana. Each user is associated with a role that includes permissions. Permissions determine the tasks a user can perform in the system. For example, the Admin role includes permissions for an administrator to create and delete users.
For more information, refer to [Organization roles](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/administration/roles-and-permissions/#organization-roles).
## Manage access using roles
For Grafana OSS, there are three roles: Admin, Editor, and Viewer.
Grafana OSS has three roles: Admin, Editor, and Viewer.
Details of the roles and the access they provide for Grafana Alerting are below.
The following table describes the access each role provides for Grafana Alerting.
| Role | Access |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Admin | Write access to alert rules, notification resources (notification API, contact points, templates, time intervals, notification policies, and silences), and provisioning. |
| Editor | Write access to alert rules, notification resources (notification API, contact points, templates, time intervals, notification policies, and silences), and provisioning. |
| Viewer | Read access to alert rules, notification resources (notification API, contact points, templates, time intervals, notification policies, and silences). |
| Role | Access |
| ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Viewer | Read access to alert rules, notification resources (notification API, contact points, templates, time intervals, notification policies, and silences). |
| Editor | Write access to alert rules, notification resources (notification API, contact points, templates, time intervals, notification policies, and silences), and provisioning. |
| Admin | Write access to alert rules, notification resources (notification API, contact points, templates, time intervals, notification policies, and silences), and provisioning, as well as assign roles. |
## Assign roles
To assign roles, admins need to complete the following steps.
To assign roles, an admin needs to complete the following steps.
1. Navigate to **Administration** > **Users and access** > **Users, Teams, or Service Accounts**.
1. Search for the user, team or service account you want to add a role for.
@@ -58,32 +60,30 @@ Refer to the following table for details on the additional access provided by fo
You can't use folders to customize access to notification resources.
{{< /admonition >}}
To manage folder permissions, complete the following steps.
To manage folder permissions, complete the following steps:
1. In the left-side menu, click **Dashboards**.
1. Hover your mouse cursor over a folder and click **Go to folder**.
1. Click **Manage permissions** from the Folder actions menu.
1. Update or add permissions as required.
## Manage access using contact point permissions
## Manage access to contact points
### Before you begin
Extend or limit the access provided by a role to contact points by assigning permissions to individual contact point.
Extend or limit the access provided by a role to contact points by assigning permissions to individual contact points.
This allows different users, teams, or service accounts to have customized access to read or modify specific contact points.
Refer to the following table for details on the additional access provided by contact point permissions.
| Folder permission | Additional Access |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| View | View and export contact point as well as select it on the Alert rule edit page |
| Edit | Update or delete the contact point |
| Admin | Same additional access as Edit and manage permissions for the contact point. User should have additional permissions to read users and teams. |
| Contact point permission | Additional Access |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
| View | View and export contact point as well as select it on the Alert rule edit page |
| Edit | Update or delete the contact point |
| Admin | Same additional access as Edit and manage permissions for the contact point. User should have additional permissions to read users and teams. |
### Steps
### Assign contact point permissions
To contact point permissions, complete the following steps.
To manage contact point permissions, complete the following steps:
1. In the left-side menu, click **Contact points**.
1. Hover your mouse cursor over a contact point and click **More**.
@@ -43,24 +43,36 @@ If the data source doesn't support loading the full range logs volume, the logs
The following sections provide detailed explanations on how to visualize and interact with individual logs in Explore.
### Logs navigation
### Infinite scroll
Logs navigation, located at the right side of the log lines, can be used to easily request additional logs by clicking **Older logs** at the bottom of the navigation. This is especially useful when you reach the line limit and you want to see more logs. Each request run from the navigation displays in the navigation as separate page. Every page shows `from` and `to` timestamps of the incoming log lines. You can see previous results by clicking on each page. Explore caches the last five requests run from the logs navigation so you're not re-running the same queries when clicking on the pages, saving time and resources.
<!-- vale Grafana.GoogleWill = NO -->
![Navigate logs in Explore](/static/img/docs/explore/navigate-logs-8-0.png)
When you reach the bottom of the list of logs, you will see the message `Scroll to load more`. If you continue scrolling and the displayed logs are within the selected time interval, Grafana will load more logs. When the sort order is "newest first" you receive older logs, and when the sort order is "oldest first" you get newer logs.
<!-- vale Grafana.GoogleWill = YES -->
### Visualization options
You have the option to customize the display of logs and choose which columns to show. Following is a list of available options.
| Option | Description |
| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Time** | Shows or hides the time column. This is the timestamp associated with the log line as reported from the data source. |
| **Unique labels** | Shows or hides the unique labels column that includes only non-common labels. All common labels are displayed above. |
| **Wrap lines** | Set this to `true` if you want the display to use line wrapping. If set to `false`, it will result in horizontal scrolling. |
| **Prettify JSON** | Set this to `true` to pretty print all JSON logs. This setting does not affect logs in any format other than JSON. |
| **Deduplication** | Log data can be very repetitive. Explore hides duplicate log lines using a few different deduplication algorithms. **Exact** matches are done on the whole line except for date fields. **Numbers** matches are done on the line after stripping out numbers such as durations, IP addresses, and so on. **Signature** is the most aggressive deduplication as it strips all letters and numbers and matches on the remaining whitespace and punctuation. |
| **Display results order** | You can change the order of received logs from the default descending order (newest first) to ascending order (oldest first). |
<!-- vale Grafana.Spelling = NO -->
| Option | Description |
| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Expand / Collapse | Expand or collapse the controls toolbar. |
| Scroll to bottom | Jump to the bottom of the logs table. |
| Oldest Logs First / Newest logs first | Sort direction (ascending or descending). |
| Search logs / Close search | Click to open/close the client side string search of the displayed logs result. |
| Deduplication | **None** does not perform any deduplication, **Exact** matches are done on the whole line except for date fields. **Numbers** matches are done on the line after stripping out numbers such as durations, IP addresses, and so on. **Signature** is the most aggressive deduplication as it strips all letters and numbers and matches on the remaining whitespace and punctuation. |
| Filter levels | Filter logs in display by log level: All levels, Info, Debut, Warning, Error. |
| Set Timestamp format | Hide timestamps (disabled), Show milliseconds timestamps, Show nanoseconds timestamps. |
| Set line wrap | Disable line wrapping, Enable line wrapping, Enable line wrapping and prettify JSON. |
| Enable highlighting | Plain text, Highlight text. |
| Font size | Small font (default), Large font. |
| Unescaped newlines | Only displayed if the logs contain unescaped new lines. Click to unescape and display as new lines. |
| Download logs | Plain text (txt), JavaScript Object Notation (JSON), Comma-separated values (CSV) |
<!-- vale Grafana.Spelling = YES -->
### Download log lines
@@ -143,16 +155,31 @@ Click the **eye icon** to select a subset of fields to visualize in the logs lis
Each field has a **stats icon**, which displays ad-hoc statistics in relation to all displayed logs.
For data sources that support log types, such as Loki, instead of a single view containing all fields, fields will be displayed grouped by their type: Indexed Labels, Parsed fields, and Structured Metadata.
#### Links
Grafana provides data links or correlations, allowing you to convert any part of a log message into an internal or external link. These links enable you to navigate to related data or external resources, offering a seamless and convenient way to explore additional information.
{{< figure src="/static/img/docs/explore/data-link-9-4.png" max-width="800px" caption="Data link in Explore" >}}
#### Log details modes
There are two modes available to view log details:
- **Inline** The default, displays log details below the log line.
- **Sidebar** Displays log details in a sidebar view.
No matter which display mode you are currently viewing, you can change it by clicking the mode control icon.
### Log context
Log context is a feature that displays additional lines of context surrounding a log entry that matches a specific search query. This helps in understanding the context of the log entry and is similar to the `-C` parameter in the `grep` command.
If you're using Loki for your logs, to modify your log context queries, you can use the Loki log context query editor at the top of the table. You can activate this editor by clicking the menu for the log line, and selecting **Show context**. Within the **Log Context** view, you have the option to modify your search by removing one or more label filters from the log stream. If your original query used a parser, you can refine your search by leveraging extracted label filters.
Change the **Context time window** option to look for logs within a specific time interval around your log line.
Toggle **Wrap lines** if you encounter long lines of text that make it difficult to read and analyze the context around log entries. By enabling this toggle, Grafana automatically wraps long lines of text to fit within the visible width of the viewer, making the log entries easier to read and understand.
Click **Open in split view** to execute the context query for a log entry in a split screen in the Explore view. Clicking this button opens a new Explore pane with the context query displayed alongside the log entry, making it easier to analyze and understand the surrounding context.
@@ -31,7 +31,7 @@ refs:
_Logs_ are structured records of events or messages generated by a system or application&mdash;that is, a series of text records with status updates from your system or app. They generally include timestamps, messages, and context information like the severity of the logged event.
The logs visualization displays these records from data sources that support logs, such as Elastic, Influx, and Loki. The logs visualization has colored indicators of log status, as well as collapsible log events that help you analyze the information generated.
The logs visualization displays these records from data sources that support logs, such as Elastic, Influx, and Loki. The logs visualization shows, by default, the timestamp, a colored string representing the log status, the log line body, as well as collapsible log events that help you analyze the information generated.
{{< figure src="/media/docs/grafana/panels-visualizations/screenshot-logs-v12.3.png" max-width="750px" alt="Logs visualization" >}}
@@ -100,16 +100,16 @@ Use these settings to refine your visualization:
| Option | Description |
| --------------- | --------------- |
| Time | Show or hide the time column. This is the timestamp associated with the log line as reported from the data source. |
| Show timestamps | Show or hide the time column. This is the timestamp associated with the log line as reported from the data source. |
| Unique labels | Show or hide the unique labels column, which shows only non-common labels. |
| Common labels | Show or hide the common labels. |
| Wrap lines | Turn line wrapping on or off. |
| Enable logs highlighting | Experimental. Use a predefined coloring scheme to highlight relevant parts of the log lines. Subtle colors are added to the log lines to improve readability and help with identifying important information faster. |
| Prettify JSON | Toggle the switch on to pretty print all JSON logs. This setting does not affect logs in any format other than JSON. |
| Enable highlighting | Use a predefined syntax coloring grammar to highlight relevant parts of the log lines |
| Enable log details | Toggle the switch on to see an extendable area with log details including labels and detected fields. Each field or label has a stats icon to display ad-hoc statistics in relation to all displayed logs. The default setting is on. |
| Log details panel mode | Choose to display the log details in a sidebar panel or inline, below the log line. The default mode depends on viewport size: the default mode for smaller viewports is inline, while for larger ones, it's sidebar. You can also change mode dynamically in the panel by clicking the mode control. |
| Enable infinite scrolling | Request more results by scrolling to the bottom of the logs list. When you reach the bottom of the list of logs, if you continue scrolling and the displayed logs are within the selected time interval, you can request to load more logs. When the sort order is **Newest first**, you receive older logs, and when the sort order is **Oldest first** you get newer logs. |
| Show controls | Display controls to jump to the last or first log line, and filter by log level. |
| Font size | Select between the **Default** font size and **Small** font sizes.|
| Log Details panel mode | Choose to display the log details in a sidebar panel or inline, below the log line. |
| Enable infinite scrolling | Request more results by scrolling to the bottom of the logs list. |
| Show controls | Display controls to jump to the last or first log line, and filters by log level |
| Font size | Select between the default font size and small font size. |
| Deduplication | Hide log messages that are duplicates of others shown, according to your selected criteria. Choose from: <ul><li>**Exact** - Ignoring ISO datetimes.</li><li>**Numerical** - Ignoring only those that differ by numbers such as IPs or latencies.</li><li>**Signatures** - Removing successive lines with identical punctuation and white space.</li></ul> |
| Order | Set whether to show results **Newest first** or **Oldest first**. |
+5
View File
@@ -1189,6 +1189,11 @@ export interface FeatureToggles {
*/
panelTimeSettings?: boolean;
/**
* Enables the raw DSL query editor in the Elasticsearch data source
* @default false
*/
elasticsearchRawDSLQuery?: boolean;
/**
* Enables app platform API for annotations
* @default false
*/
@@ -387,6 +387,10 @@ export interface ElasticsearchDataQuery extends common.DataQuery {
* List of bucket aggregations
*/
bucketAggs?: Array<BucketAggregation>;
/**
* Editor type
*/
editorType?: string;
/**
* List of metric aggregations
*/
@@ -395,6 +399,10 @@ export interface ElasticsearchDataQuery extends common.DataQuery {
* Lucene query
*/
query?: string;
/**
* Raw DSL query
*/
rawDSLQuery?: string;
/**
* Name of time field
*/
@@ -1,6 +1,6 @@
import { Chance } from 'chance';
import { DashboardsTreeItem, DashboardViewItem, UIDashboardViewItem } from '../types/browse-dashboards';
import { DashboardsTreeItem, DashboardViewItem, ManagerKind, UIDashboardViewItem } from '../types/browse-dashboards';
function wellFormedEmptyFolder(
seed = 1,
@@ -64,13 +64,14 @@ function wellFormedFolder(
}
export function treeViewersCanEdit() {
const [, { folderA, folderC }] = wellFormedTree();
const [, { folderA, folderC, folderD }] = wellFormedTree();
return [
[folderA, folderC],
[folderA, folderC, folderD],
{
folderA,
folderC,
folderD,
},
] as const;
}
@@ -90,6 +91,8 @@ export function wellFormedTree() {
const folderB = wellFormedFolder(seed++);
const folderB_empty = wellFormedEmptyFolder(seed++);
const folderC = wellFormedFolder(seed++);
// folderD is marked as managed by repo (git-synced) for testing disabled folder behavior
const folderD = wellFormedFolder(seed++, {}, { managedBy: ManagerKind.Repo });
const dashbdD = wellFormedDashboard(seed++);
const dashbdE = wellFormedDashboard(seed++);
@@ -107,6 +110,7 @@ export function wellFormedTree() {
folderB,
folderB_empty,
folderC,
folderD,
dashbdD,
dashbdE,
],
@@ -123,6 +127,7 @@ export function wellFormedTree() {
folderB,
folderB_empty,
folderC,
folderD,
dashbdD,
dashbdE,
},
@@ -4,6 +4,7 @@ import { HttpResponse, http } from 'msw';
import { treeViewersCanEdit, wellFormedTree } from '../../../fixtures/folders';
const [mockTree, { folderB }] = wellFormedTree();
// folderD is included in mockTree and will be returned by the handlers with managedBy: 'repo'
const [mockTreeThatViewersCanEdit] = treeViewersCanEdit();
const collator = new Intl.Collator();
@@ -48,6 +49,7 @@ const listFoldersHandler = () =>
id: random.integer({ min: 1, max: 1000 }),
uid: folder.item.uid,
title: folder.item.kind === 'folder' ? folder.item.title : "invalid - this shouldn't happen",
...('managedBy' in folder.item && folder.item.managedBy ? { managedBy: folder.item.managedBy } : {}),
};
})
.sort((a, b) => collator.compare(a.title, b.title)) // API always sorts by title
@@ -76,6 +78,7 @@ const getFolderHandler = () =>
uid: folder?.item.uid,
...additionalProperties,
...(accessControlQueryParam ? { accessControl: mockAccessControl } : {}),
...('managedBy' in folder.item && folder.item.managedBy ? { managedBy: folder.item.managedBy } : {}),
});
});
@@ -5,6 +5,7 @@ import { wellFormedTree } from '../../../../fixtures/folders';
import { getErrorResponse } from '../../../helpers';
const [mockTree, { folderB }] = wellFormedTree();
// folderD is included in mockTree and will be returned by the handlers with managedBy: 'repo'
const baseResponse = {
kind: 'Folder',
@@ -24,7 +25,7 @@ const folderToAppPlatform = (folder: (typeof mockTree)[number]['item'], id?: num
// TODO: Generalise annotations in fixture data
'grafana.app/createdBy': 'user:1',
'grafana.app/updatedBy': 'user:2',
'grafana.app/managedBy': 'user',
'grafana.app/managedBy': 'managedBy' in folder ? folder.managedBy : 'user',
'grafana.app/updatedTimestamp': '2024-01-01T00:00:00Z',
'grafana.app/folder': folder.kind === 'folder' ? folder.parentUID : undefined,
},
@@ -3,7 +3,7 @@
// @grafana/schema?
// New package @grafana/core? @grafana/types?
enum ManagerKind {
export enum ManagerKind {
Repo = 'repo',
Terraform = 'terraform',
Kubectl = 'kubectl',
+2 -1
View File
@@ -222,7 +222,7 @@ func RegisterAPIService(
return builder
}
func NewAPIService(ac authlib.AccessClient, features featuremgmt.FeatureToggles, folderClientProvider client.K8sHandlerProvider, datasourceProvider schemaversion.DataSourceIndexProvider, libraryElementProvider schemaversion.LibraryElementIndexProvider, resourcePermissionsSvc *dynamic.NamespaceableResourceInterface) *DashboardsAPIBuilder {
func NewAPIService(ac authlib.AccessClient, features featuremgmt.FeatureToggles, folderClientProvider client.K8sHandlerProvider, datasourceProvider schemaversion.DataSourceIndexProvider, libraryElementProvider schemaversion.LibraryElementIndexProvider, resourcePermissionsSvc *dynamic.NamespaceableResourceInterface, search *SearchHandler) *DashboardsAPIBuilder {
migration.Initialize(datasourceProvider, libraryElementProvider, migration.DefaultCacheTTL)
return &DashboardsAPIBuilder{
minRefreshInterval: "10s",
@@ -231,6 +231,7 @@ func NewAPIService(ac authlib.AccessClient, features featuremgmt.FeatureToggles,
dashboardService: &dashsvc.DashboardServiceImpl{}, // for validation helpers only
folderClientProvider: folderClientProvider,
resourcePermissionsSvc: resourcePermissionsSvc,
search: search,
isStandalone: true,
}
}
+7
View File
@@ -1962,6 +1962,13 @@ var (
RequiresRestart: false,
HideFromDocs: false,
},
{
Name: "elasticsearchRawDSLQuery",
Description: "Enables the raw DSL query editor in the Elasticsearch data source",
Stage: FeatureStageExperimental,
Owner: grafanaPartnerPluginsSquad,
Expression: "false",
},
{
Name: "kubernetesAnnotations",
Description: "Enables app platform API for annotations",
+1
View File
@@ -266,6 +266,7 @@ pluginStoreServiceLoading,experimental,@grafana/plugins-platform-backend,false,f
newPanelPadding,preview,@grafana/dashboards-squad,false,false,true
onlyStoreActionSets,GA,@grafana/identity-access-team,false,false,false
panelTimeSettings,experimental,@grafana/dashboards-squad,false,false,false
elasticsearchRawDSLQuery,experimental,@grafana/partner-datasources,false,false,false
kubernetesAnnotations,experimental,@grafana/grafana-backend-services-squad,false,false,false
awsDatasourcesHttpProxy,experimental,@grafana/aws-datasources,false,false,false
transformationsEmptyPlaceholder,preview,@grafana/datapro,false,false,true
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
266 newPanelPadding preview @grafana/dashboards-squad false false true
267 onlyStoreActionSets GA @grafana/identity-access-team false false false
268 panelTimeSettings experimental @grafana/dashboards-squad false false false
269 elasticsearchRawDSLQuery experimental @grafana/partner-datasources false false false
270 kubernetesAnnotations experimental @grafana/grafana-backend-services-squad false false false
271 awsDatasourcesHttpProxy experimental @grafana/aws-datasources false false false
272 transformationsEmptyPlaceholder preview @grafana/datapro false false true
+4
View File
@@ -758,6 +758,10 @@ const (
// Enables a new panel time settings drawer
FlagPanelTimeSettings = "panelTimeSettings"
// FlagElasticsearchRawDSLQuery
// Enables the raw DSL query editor in the Elasticsearch data source
FlagElasticsearchRawDSLQuery = "elasticsearchRawDSLQuery"
// FlagKubernetesAnnotations
// Enables app platform API for annotations
FlagKubernetesAnnotations = "kubernetesAnnotations"
+13
View File
@@ -1206,6 +1206,19 @@
"codeowner": "@grafana/partner-datasources"
}
},
{
"metadata": {
"name": "elasticsearchRawDSLQuery",
"resourceVersion": "1763508396079",
"creationTimestamp": "2025-11-18T23:26:36Z"
},
"spec": {
"description": "Enables the raw DSL query editor in the Elasticsearch data source",
"stage": "experimental",
"codeowner": "@grafana/partner-datasources",
"expression": "false"
}
},
{
"metadata": {
"name": "enableAppChromeExtensions",
@@ -457,6 +457,7 @@ type paginationContext struct {
labelOptions []ngmodels.LabelOption
limitAlertsPerRule int64
limitRulesPerGroup int64
compact bool
}
// pageResult is the result of fetching and filtering of one page
@@ -492,6 +493,7 @@ func (ctx *paginationContext) fetchAndFilterPage(log log.Logger, store ListAlert
Limit: remainingGroups,
RuleLimit: remainingRules,
ContinueToken: token,
Compact: ctx.compact,
}
ruleList, newToken, err := store.ListAlertRulesByGroup(ctx.opts.Ctx, &byGroupQuery)
@@ -519,7 +521,7 @@ func (ctx *paginationContext) fetchAndFilterPage(log log.Logger, store ListAlert
log, rg.GroupKey, rg.Folder, rg.Rules,
ctx.provenanceRecords, ctx.limitAlertsPerRule,
ctx.stateFilterSet, ctx.matchers, ctx.labelOptions,
ctx.ruleStatusMutator, ctx.alertStateMutator,
ctx.ruleStatusMutator, ctx.alertStateMutator, ctx.compact,
)
ruleGroup.Totals = totals
accumulateTotals(result.totalsDelta, totals)
@@ -785,6 +787,8 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt
}
span.SetAttributes(attribute.Int("rule_name_count", len(ruleNamesSet)))
compact := getBoolWithDefault(opts.Query, "compact", false)
span.SetAttributes(attribute.Bool("compact", compact))
pagCtx := &paginationContext{
opts: opts,
provenanceRecords: provenanceRecords,
@@ -807,6 +811,7 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt
labelOptions: labelOptions,
limitAlertsPerRule: limitAlertsPerRule,
limitRulesPerGroup: limitRulesPerGroup,
compact: compact,
}
groups, rulesTotals, continueToken, err := paginateRuleGroups(log, store, pagCtx, span, maxGroups, maxRules, nextToken)
@@ -959,7 +964,7 @@ func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts Ru
break
}
ruleGroup, totals := toRuleGroup(log, rg.GroupKey, rg.Folder, rg.Rules, provenanceRecords, limitAlertsPerRule, stateFilterSet, matchers, labelOptions, ruleStatusMutator, alertStateMutator)
ruleGroup, totals := toRuleGroup(log, rg.GroupKey, rg.Folder, rg.Rules, provenanceRecords, limitAlertsPerRule, stateFilterSet, matchers, labelOptions, ruleStatusMutator, alertStateMutator, false)
ruleGroup.Totals = totals
for k, v := range totals {
rulesTotals[k] += v
@@ -1110,7 +1115,7 @@ func matchersMatch(matchers []*labels.Matcher, labels map[string]string) bool {
return true
}
func toRuleGroup(log log.Logger, groupKey ngmodels.AlertRuleGroupKey, folderFullPath string, rules []*ngmodels.AlertRule, provenanceRecords map[string]ngmodels.Provenance, limitAlerts int64, stateFilterSet map[eval.State]struct{}, matchers labels.Matchers, labelOptions []ngmodels.LabelOption, ruleStatusMutator RuleStatusMutator, ruleAlertStateMutator RuleAlertStateMutator) (*apimodels.RuleGroup, map[string]int64) {
func toRuleGroup(log log.Logger, groupKey ngmodels.AlertRuleGroupKey, folderFullPath string, rules []*ngmodels.AlertRule, provenanceRecords map[string]ngmodels.Provenance, limitAlerts int64, stateFilterSet map[eval.State]struct{}, matchers labels.Matchers, labelOptions []ngmodels.LabelOption, ruleStatusMutator RuleStatusMutator, ruleAlertStateMutator RuleAlertStateMutator, compact bool) (*apimodels.RuleGroup, map[string]int64) {
newGroup := &apimodels.RuleGroup{
Name: groupKey.RuleGroup,
// file is what Prometheus uses for provisioning, we replace it with namespace which is the folder in Grafana.
@@ -1126,10 +1131,14 @@ func toRuleGroup(log log.Logger, groupKey ngmodels.AlertRuleGroupKey, folderFull
if prov, exists := provenanceRecords[rule.ResourceID()]; exists {
provenance = prov
}
var query string
if !compact {
query = ruleToQuery(log, rule)
}
alertingRule := apimodels.AlertingRule{
State: "inactive",
Name: rule.Title,
Query: ruleToQuery(log, rule),
Query: query,
QueriedDatasourceUIDs: extractDatasourceUIDs(rule),
Duration: rule.For.Seconds(),
KeepFiringFor: rule.KeepFiringFor.Seconds(),
@@ -110,6 +110,12 @@ func (aq *AlertQuery) String() string {
}
func (aq *AlertQuery) setModelProps() error {
if aq.Model == nil {
// No data to extract, use an empty map.
aq.modelProps = map[string]any{}
return nil
}
aq.modelProps = make(map[string]any)
err := json.Unmarshal(aq.Model, &aq.modelProps)
if err != nil {
@@ -1022,6 +1022,7 @@ type ListAlertRulesExtendedQuery struct {
Limit int64
RuleLimit int64
ContinueToken string
Compact bool
}
// CountAlertRulesQuery is the query for counting alert rules
+7 -1
View File
@@ -631,7 +631,13 @@ func (st DBstore) ListAlertRulesByGroup(ctx context.Context, query *ngmodels.Lis
continue
}
converted, err := alertRuleToModelsAlertRule(*rule, st.Logger)
var converted ngmodels.AlertRule
if query.Compact {
converted, err = alertRuleToModelsAlertRuleCompact(*rule, st.Logger)
} else {
converted, err = alertRuleToModelsAlertRule(*rule, st.Logger)
}
if err != nil {
st.Logger.Error("Invalid rule found in DB store, cannot convert, ignoring it", "func", "ListAlertRulesByGroup", "error", err)
continue
+33 -5
View File
@@ -10,11 +10,38 @@ import (
"github.com/grafana/grafana/pkg/services/ngalert/models"
)
// We only care about the data source UIDs.
type compactQuery struct {
DatasourceUID string `json:"datasourceUid"`
}
func alertRuleToModelsAlertRule(ar alertRule, l log.Logger) (models.AlertRule, error) {
return convertAlertRuleToModel(ar, l, false)
}
// alertRuleToModelsAlertRuleCompact transforms an alertRule to a models.AlertRule
// ignoring alert queries (except for data source UIDs), notification settings, and metadata.
func alertRuleToModelsAlertRuleCompact(ar alertRule, l log.Logger) (models.AlertRule, error) {
return convertAlertRuleToModel(ar, l, true)
}
// convertAlertRuleToModel creates a models.AlertRule from an alertRule.
// When 'compact' is set to 'true', it skips parsing the alert queries (except for the data source UID), notification
// settings, and metadata, thus reducing the number of JSON serializations needed.
func convertAlertRuleToModel(ar alertRule, l log.Logger, compact bool) (models.AlertRule, error) {
var data []models.AlertQuery
err := json.Unmarshal([]byte(ar.Data), &data)
if err != nil {
return models.AlertRule{}, fmt.Errorf("failed to parse data: %w", err)
if compact {
var cqs []compactQuery
if err := json.Unmarshal([]byte(ar.Data), &cqs); err != nil {
return models.AlertRule{}, fmt.Errorf("failed to parse data: %w", err)
}
for _, cq := range cqs {
data = append(data, models.AlertQuery{DatasourceUID: cq.DatasourceUID})
}
} else {
if err := json.Unmarshal([]byte(ar.Data), &data); err != nil {
return models.AlertRule{}, fmt.Errorf("failed to parse data: %w", err)
}
}
result := models.AlertRule{
@@ -52,6 +79,7 @@ func alertRuleToModelsAlertRule(ar alertRule, l log.Logger) (models.AlertRule, e
result.UpdatedBy = util.Pointer(models.UserUID(*ar.UpdatedBy))
}
var err error
if ar.NoDataState != "" {
result.NoDataState, err = models.NoDataStateFromString(ar.NoDataState)
if err != nil {
@@ -90,7 +118,7 @@ func alertRuleToModelsAlertRule(ar alertRule, l log.Logger) (models.AlertRule, e
}
}
if ar.NotificationSettings != "" {
if !compact && ar.NotificationSettings != "" {
ns, err := parseNotificationSettings(ar.NotificationSettings)
if err != nil {
return models.AlertRule{}, fmt.Errorf("failed to parse notification settings: %w", err)
@@ -98,7 +126,7 @@ func alertRuleToModelsAlertRule(ar alertRule, l log.Logger) (models.AlertRule, e
result.NotificationSettings = ns
}
if ar.Metadata != "" {
if !compact && ar.Metadata != "" {
err = json.Unmarshal([]byte(ar.Metadata), &result.Metadata)
if err != nil {
return models.AlertRule{}, fmt.Errorf("failed to metadata: %w", err)
+79
View File
@@ -65,6 +65,85 @@ func TestAlertRuleToModelsAlertRule(t *testing.T) {
})
}
func TestAlertRuleToModelsAlertRuleCompact(t *testing.T) {
t.Run("should only extract datasource UIDs in compact mode", func(t *testing.T) {
rule := alertRule{
ID: 1,
OrgID: 1,
UID: "test-uid",
Title: "Test Rule",
Condition: "A",
Data: `[{"datasourceUid":"ds1","refId":"A","queryType":"test","model":{"expr":"up"}},{"datasourceUid":"ds2","refId":"B","queryType":"test","model":{"expr":"down"}}]`,
IntervalSeconds: 60,
Version: 1,
NamespaceUID: "ns-uid",
RuleGroup: "test-group",
NoDataState: "NoData",
ExecErrState: "Error",
NotificationSettings: `[{"receiver":"test-receiver"}]`,
Metadata: `{"editor_settings":{"simplified_query_and_expressions_section":true}}`,
}
compactResult, err := alertRuleToModelsAlertRuleCompact(rule, &logtest.Fake{})
require.NoError(t, err)
// Should have datasource UIDs.
require.Len(t, compactResult.Data, 2)
require.Equal(t, "ds1", compactResult.Data[0].DatasourceUID)
require.Equal(t, "ds2", compactResult.Data[1].DatasourceUID)
// But should not have full query data (RefID, QueryType, Model should be empty).
require.Empty(t, compactResult.Data[0].RefID)
require.Empty(t, compactResult.Data[0].QueryType)
require.Nil(t, compactResult.Data[0].Model)
require.Empty(t, compactResult.Data[1].RefID)
require.Empty(t, compactResult.Data[1].QueryType)
require.Nil(t, compactResult.Data[1].Model)
// Should not have notification settings.
require.Empty(t, compactResult.NotificationSettings)
// Should not have metadata (should be zero value).
require.Equal(t, ngmodels.AlertRuleMetadata{}, compactResult.Metadata)
})
t.Run("should parse full data in non-compact mode", func(t *testing.T) {
rule := alertRule{
ID: 1,
OrgID: 1,
UID: "test-uid",
Title: "Test Rule",
Condition: "A",
Data: `[{"datasourceUid":"ds1","refId":"A","queryType":"test","model":{"expr":"up"}},{"datasourceUid":"ds2","refId":"B","queryType":"test","model":{"expr":"down"}}]`,
IntervalSeconds: 60,
Version: 1,
NamespaceUID: "ns-uid",
RuleGroup: "test-group",
NoDataState: "NoData",
ExecErrState: "Error",
NotificationSettings: `[{"receiver":"test-receiver"}]`,
Metadata: `{"editor_settings":{"simplified_query_and_expressions_section":true}}`,
}
fullResult, err := alertRuleToModelsAlertRule(rule, &logtest.Fake{})
require.NoError(t, err)
// Should have full query data.
require.Len(t, fullResult.Data, 2)
require.Equal(t, "ds1", fullResult.Data[0].DatasourceUID)
require.Equal(t, "A", fullResult.Data[0].RefID)
require.Equal(t, "test", fullResult.Data[0].QueryType)
require.NotNil(t, fullResult.Data[0].Model)
// Should have notification settings.
require.Len(t, fullResult.NotificationSettings, 1)
require.Equal(t, "test-receiver", fullResult.NotificationSettings[0].Receiver)
// Should have metadata (metadata is parsed from JSON to struct).
require.NotEqual(t, ngmodels.AlertRuleMetadata{}, fullResult.Metadata)
})
}
func TestAlertRuleVersionToAlertRule(t *testing.T) {
g := ngmodels.RuleGen
+8
View File
@@ -20,10 +20,18 @@ type SearchRequest struct {
Aggs AggArray
CustomProps map[string]interface{}
TimeRange backend.TimeRange
// RawBody contains the raw Elasticsearch Query DSL JSON for raw DSL queries
// When set, this takes precedence over all other fields during marshaling
RawBody map[string]interface{}
}
// MarshalJSON returns the JSON encoding of the request.
func (r *SearchRequest) MarshalJSON() ([]byte, error) {
// If RawBody is set, use it directly for raw DSL queries
if len(r.RawBody) > 0 {
return json.Marshal(r.RawBody)
}
root := make(map[string]interface{})
root["size"] = r.Size
@@ -3,6 +3,7 @@ package es
import (
"bytes"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
@@ -25,6 +26,9 @@ func newRequestEncoder(logger log.Logger) *requestEncoder {
// encodeBatchRequests encodes multiple requests into NDJSON format
func (e *requestEncoder) encodeBatchRequests(requests []*multiRequest) ([]byte, error) {
start := time.Now()
defer func() {
e.logger.Debug("Completed encoding of batch requests to json", "duration", time.Since(start))
}()
payload := bytes.Buffer{}
for _, r := range requests {
@@ -34,20 +38,25 @@ func (e *requestEncoder) encodeBatchRequests(requests []*multiRequest) ([]byte,
}
payload.WriteString(string(reqHeader) + "\n")
reqBody, err := json.Marshal(r.body)
if err != nil {
return nil, err
body := ""
switch r.body.(type) {
case *SearchRequest:
reqBody, err := json.Marshal(r.body)
if err != nil {
return nil, err
}
body = string(reqBody)
case string:
body = r.body.(string)
default:
return nil, fmt.Errorf("unknown request type: %T", r.body)
}
body := string(reqBody)
body = strings.ReplaceAll(body, "$__interval_ms", strconv.FormatInt(r.interval.Milliseconds(), 10))
body = strings.ReplaceAll(body, "$__interval", r.interval.String())
payload.WriteString(body + "\n")
}
elapsed := time.Since(start)
e.logger.Debug("Completed encoding of batch requests to json", "duration", elapsed)
return payload.Bytes(), nil
}
@@ -30,6 +30,8 @@ type SearchRequestBuilder struct {
aggBuilders []AggBuilder
customProps map[string]any
timeRange backend.TimeRange
// rawBody contains the raw Elasticsearch Query DSL JSON for raw DSL queries
rawBody map[string]any
}
// NewSearchRequestBuilder create a new search request builder
@@ -53,6 +55,12 @@ func (b *SearchRequestBuilder) Build() (*SearchRequest, error) {
Size: b.size,
Sort: b.sort,
CustomProps: b.customProps,
RawBody: b.rawBody,
}
// If RawBody is set, skip building query and aggs as they're in the raw body
if len(b.rawBody) > 0 {
return &sr, nil
}
if b.queryBuilder != nil {
@@ -141,6 +149,19 @@ func (b *SearchRequestBuilder) AddSearchAfter(value any) *SearchRequestBuilder {
return b
}
// AddCustomProp adds a custom property to the search request
func (b *SearchRequestBuilder) AddCustomProp(key string, value any) *SearchRequestBuilder {
b.customProps[key] = value
return b
}
// SetRawBody sets the raw Elasticsearch Query DSL body directly
// This bypasses all builder logic and sends the query as-is to Elasticsearch
func (b *SearchRequestBuilder) SetRawBody(rawBody map[string]any) *SearchRequestBuilder {
b.rawBody = rawBody
return b
}
// Query creates and return a query builder
func (b *SearchRequestBuilder) Query() *QueryBuilder {
if b.queryBuilder == nil {
+8 -5
View File
@@ -20,11 +20,12 @@ const (
)
type elasticsearchDataQuery struct {
client es.Client
dataQueries []backend.DataQuery
logger log.Logger
ctx context.Context
keepLabelsInResponse bool
client es.Client
dataQueries []backend.DataQuery
logger log.Logger
ctx context.Context
keepLabelsInResponse bool
aggregationParserDSLRawQuery AggregationParser
}
var newElasticsearchDataQuery = func(ctx context.Context, client es.Client, req *backend.QueryDataRequest, logger log.Logger) *elasticsearchDataQuery {
@@ -39,6 +40,8 @@ var newElasticsearchDataQuery = func(ctx context.Context, client es.Client, req
// To maintain backward compatibility, it is necessary to keep labels in responses for alerting and expressions queries.
// Historically, these labels have been used in alerting rules and transformations.
keepLabelsInResponse: fromAlert || fromExpression,
aggregationParserDSLRawQuery: NewAggregationParser(),
}
}
@@ -1,6 +1,7 @@
package elasticsearch
import (
"encoding/json"
"fmt"
"strconv"
@@ -23,6 +24,17 @@ 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 != "" {
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"))
}
if err := e.processRawDSLQuery(q, b); err != nil {
return err
}
}
if isLogsQuery(q) {
processLogsQuery(q, b, from, to, defaultTimeField)
} else if isDocumentQuery(q) {
@@ -184,6 +196,46 @@ func processTimeSeriesQuery(q *Query, b *es.SearchRequestBuilder, from, to int64
}
}
func (e *elasticsearchDataQuery) processRawDSLQuery(q *Query, b *es.SearchRequestBuilder) error {
if q.RawDSLQuery == "" {
return backend.DownstreamError(fmt.Errorf("raw DSL query is empty"))
}
// Parse the raw DSL query JSON
var queryBody map[string]any
if err := json.Unmarshal([]byte(q.RawDSLQuery), &queryBody); err != nil {
return backend.DownstreamError(fmt.Errorf("invalid raw DSL query JSON: %w", err))
}
if len(q.Metrics) > 0 {
firstMetricType := q.Metrics[0].Type
if firstMetricType != logsType && firstMetricType != rawDataType && firstMetricType != rawDocumentType {
bucketAggs, metricAggs, err := e.aggregationParserDSLRawQuery.Parse(q.RawDSLQuery)
if err != nil {
return backend.DownstreamError(fmt.Errorf("failed to parse aggregations: %w", err))
}
// If there is no metric agg in the query, it is a count agg
if len(metricAggs) == 0 {
metricAggs = append(metricAggs, &MetricAgg{Type: "count"})
}
q.BucketAggs = bucketAggs
q.Metrics = metricAggs
if queryPart, ok := queryBody["query"].(map[string]any); ok {
queryJSON, _ := json.Marshal(queryPart)
q.RawQuery = string(queryJSON)
}
return nil
}
}
// For non-time-series queries (logs, raw data), pass through the raw body directly
b.SetRawBody(queryBody)
return nil
}
// getPipelineAggField returns the pipeline aggregation field
func getPipelineAggField(m *MetricAgg) string {
// In frontend we are using Field as pipelineAggField
+99 -1
View File
@@ -8,6 +8,7 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
"github.com/grafana/grafana-plugin-sdk-go/experimental/featuretoggles"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -1887,6 +1888,11 @@ func newDataQuery(body string) (backend.QueryDataRequest, error) {
}
func executeElasticsearchDataQuery(c es.Client, body string, from, to time.Time) (
*backend.QueryDataResponse, error) {
return executeElasticsearchDataQueryWithContext(c, body, from, to, context.Background())
}
func executeElasticsearchDataQueryWithContext(c es.Client, body string, from, to time.Time, ctx context.Context) (
*backend.QueryDataResponse, error) {
timeRange := backend.TimeRange{
From: from,
@@ -1901,6 +1907,98 @@ func executeElasticsearchDataQuery(c es.Client, body string, from, to time.Time)
},
},
}
query := newElasticsearchDataQuery(context.Background(), c, &dataRequest, log.New())
query := newElasticsearchDataQuery(ctx, c, &dataRequest, log.New())
return query.execute()
}
func TestRawDSLQuery(t *testing.T) {
from := time.Date(2018, 5, 15, 17, 50, 0, 0, time.UTC)
to := time.Date(2018, 5, 15, 17, 55, 0, 0, time.UTC)
// Create context with raw DSL query feature toggle enabled
cfg := backend.NewGrafanaCfg(map[string]string{
featuretoggles.EnabledFeatures: "elasticsearchRawDSLQuery",
})
ctx := backend.WithGrafanaConfig(context.Background(), cfg)
t.Run("With raw DSL query", func(t *testing.T) {
t.Run("Basic raw DSL query with aggregations", func(t *testing.T) {
c := newFakeClient()
_, err := executeElasticsearchDataQueryWithContext(c, `{
"editorType": "code",
"rawDSLQuery": "{\"query\":{\"bool\":{\"filter\":[{\"range\":{\"@timestamp\":{\"gte\":1526405400000,\"lte\":1526405700000,\"format\":\"epoch_millis\"}}}]}},\"aggs\":{\"date_histogram\":{\"date_histogram\":{\"field\":\"@timestamp\",\"interval\":\"1m\"}}},\"size\":0}"
}`, from, to, ctx)
require.NoError(t, err)
require.Len(t, c.multisearchRequests, 1)
require.Len(t, c.multisearchRequests[0].Requests, 1)
sr := c.multisearchRequests[0].Requests[0]
// Verify RawBody contains the entire DSL query
require.NotNil(t, sr.RawBody)
require.Contains(t, sr.RawBody, "query")
require.Contains(t, sr.RawBody, "aggs")
// Verify size from raw body
size, ok := sr.RawBody["size"].(float64)
require.True(t, ok)
require.Equal(t, float64(0), size)
})
t.Run("Raw DSL query with query_string", func(t *testing.T) {
c := newFakeClient()
_, err := executeElasticsearchDataQueryWithContext(c, `{
"editorType": "code",
"rawDSLQuery": "{\"query\":{\"query_string\":{\"query\":\"status:200\",\"analyze_wildcard\":true}},\"size\":100}"
}`, from, to, ctx)
require.NoError(t, err)
require.Len(t, c.multisearchRequests, 1)
sr := c.multisearchRequests[0].Requests[0]
// Verify RawBody contains the entire DSL query
require.NotNil(t, sr.RawBody)
require.Contains(t, sr.RawBody, "query")
// Verify size from raw body
size, ok := sr.RawBody["size"].(float64)
require.True(t, ok)
require.Equal(t, float64(100), size)
// Verify query object exists in raw body
query, ok := sr.RawBody["query"].(map[string]any)
require.True(t, ok)
require.Contains(t, query, "query_string")
})
t.Run("Raw DSL query with sort", func(t *testing.T) {
c := newFakeClient()
_, err := executeElasticsearchDataQueryWithContext(c, `{
"editorType": "code",
"rawDSLQuery": "{\"query\":{\"match_all\":{}},\"sort\":[{\"@timestamp\":{\"order\":\"desc\"}}],\"size\":50}"
}`, from, to, ctx)
require.NoError(t, err)
require.Len(t, c.multisearchRequests, 1)
sr := c.multisearchRequests[0].Requests[0]
// Verify RawBody contains the entire DSL query
require.NotNil(t, sr.RawBody)
require.Contains(t, sr.RawBody, "query")
require.Contains(t, sr.RawBody, "sort")
// Verify sort in raw body
sort, ok := sr.RawBody["sort"].([]any)
require.True(t, ok)
require.NotEmpty(t, sort)
})
t.Run("Invalid JSON in raw DSL query returns error", func(t *testing.T) {
c := newFakeClient()
response, err := executeElasticsearchDataQueryWithContext(c, `{
"editorType": "code",
"rawDSLQuery": "{ invalid json }"
}`, from, to, ctx)
require.NoError(t, err)
require.NotNil(t, response.Responses["A"].Error)
require.Contains(t, response.Responses["A"].Error.Error(), "invalid raw DSL query JSON")
})
})
}
@@ -6,6 +6,10 @@ 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 != "" {
return nil
}
if len(query.BucketAggs) == 0 {
// If no aggregations, only document and logs queries are valid
if len(query.Metrics) == 0 || (!isLogsQuery(query) && !isDocumentQuery(query)) {
@@ -775,8 +775,12 @@ type ElasticsearchDataQuery struct {
Alias *string `json:"alias,omitempty"`
// Lucene query
Query *string `json:"query,omitempty"`
// Raw DSL query
RawDSLQuery *string `json:"rawDSLQuery,omitempty"`
// Name of time field
TimeField *string `json:"timeField,omitempty"`
// Editor type
EditorType *string `json:"editorType,omitempty"`
// List of bucket aggregations
BucketAggs []BucketAggregation `json:"bucketAggs,omitempty"`
// List of metric aggregations
+2
View File
@@ -10,6 +10,7 @@ import (
// Query represents the time series query model of the datasource
type Query struct {
RawQuery string `json:"query"`
RawDSLQuery string `json:"rawDSLQuery"`
BucketAggs []*BucketAgg `json:"bucketAggs"`
Metrics []*MetricAgg `json:"metrics"`
Alias string `json:"alias"`
@@ -18,6 +19,7 @@ type Query struct {
RefID string
MaxDataPoints int64
TimeRange backend.TimeRange
EditorType *string `json:"editorType"`
}
// BucketAgg represents a bucket aggregation of the time series query model of the datasource
+9
View File
@@ -21,6 +21,13 @@ func parseQuery(tsdbQuery []backend.DataQuery, logger log.Logger) ([]*Query, err
// please do not create a new field with that name, to avoid potential problems with old, persisted queries.
rawQuery := model.Get("query").MustString()
rawDSLQuery := model.Get("rawDSLQuery").MustString()
var editorType *string
if et := model.Get("editorType").MustString(); et != "" {
editorType = &et
}
bucketAggs, err := parseBucketAggs(model)
if err != nil {
logger.Error("Failed to parse bucket aggs in query", "error", err, "model", string(q.JSON))
@@ -37,6 +44,7 @@ func parseQuery(tsdbQuery []backend.DataQuery, logger log.Logger) ([]*Query, err
queries = append(queries, &Query{
RawQuery: rawQuery,
RawDSLQuery: rawDSLQuery,
BucketAggs: bucketAggs,
Metrics: metrics,
Alias: alias,
@@ -45,6 +53,7 @@ func parseQuery(tsdbQuery []backend.DataQuery, logger log.Logger) ([]*Query, err
RefID: q.RefID,
MaxDataPoints: q.MaxDataPoints,
TimeRange: q.TimeRange,
EditorType: editorType,
})
}
@@ -0,0 +1,628 @@
package elasticsearch
import (
"encoding/json"
"fmt"
"strconv"
"github.com/grafana/grafana/pkg/components/simplejson"
)
// AggregationParser parses raw Elasticsearch DSL aggregations
type AggregationParser interface {
Parse(rawQuery string) ([]*BucketAgg, []*MetricAgg, error)
}
// aggregationTypeParser handles parsing of specific aggregation types
type aggregationTypeParser interface {
CanParse(aggType string) bool
Parse(id, aggType string, aggValue map[string]any) (*dslAgg, error)
}
type AggType string
const (
aggTypeBucket = AggType("bucket")
aggTypeMetric = AggType("metric")
)
type dslAgg struct {
Field string `json:"field"`
Hide bool `json:"hide"`
ID string `json:"id"`
PipelineAggregate string `json:"pipelineAgg"`
PipelineVariables map[string]string `json:"pipelineVariables"`
Settings *simplejson.Json `json:"settings"`
Meta *simplejson.Json `json:"meta"`
Type string `json:"type"`
AggType AggType
}
func (a *dslAgg) toBucketAgg() *BucketAgg {
return &BucketAgg{
Field: a.Field,
ID: a.ID,
Settings: a.Settings,
Type: a.Type,
}
}
func (a *dslAgg) toMetricAgg() *MetricAgg {
return &MetricAgg{
Field: a.Field,
Hide: a.Hide,
ID: a.ID,
PipelineAggregate: a.PipelineAggregate,
PipelineVariables: a.PipelineVariables,
Settings: a.Settings,
Meta: a.Meta,
Type: a.Type,
}
}
// fieldExtractor handles extracting and converting field values
type fieldExtractor struct{}
func (e *fieldExtractor) getString(data map[string]any, key string) string {
if val, ok := data[key]; ok {
if str, ok := val.(string); ok {
return str
}
}
return ""
}
func (e *fieldExtractor) getInt(data map[string]any, key string) int {
if val, ok := data[key]; ok {
switch v := val.(type) {
case float64:
return int(v)
case int:
return v
case string:
if i, err := strconv.Atoi(v); err == nil {
return i
}
}
}
return 0
}
func (e *fieldExtractor) getFloat(data map[string]any, key string) float64 {
if val, ok := data[key]; ok {
switch v := val.(type) {
case float64:
return v
case int:
return float64(v)
case string:
if f, err := strconv.ParseFloat(v, 64); err == nil {
return f
}
}
}
return 0
}
func (e *fieldExtractor) getMap(data map[string]any, key string) map[string]any {
if val, ok := data[key]; ok {
if m, ok := val.(map[string]any); ok {
return m
}
}
return nil
}
func (e *fieldExtractor) getSettings(data map[string]any) *simplejson.Json {
settings := make(map[string]any)
for k, v := range data {
// Skip known non-setting fields
if k == "field" || k == "buckets_path" {
continue
}
settings[k] = v
}
return simplejson.NewFromAny(settings)
}
// dateHistogramParser handles date_histogram aggregations
type dateHistogramParser struct {
extractor *fieldExtractor
}
func (p *dateHistogramParser) CanParse(aggType string) bool {
return aggType == dateHistType
}
func (p *dateHistogramParser) Parse(id, aggType string, aggValue map[string]any) (*dslAgg, error) {
field := p.extractor.getString(aggValue, "field")
settings := make(map[string]any)
if interval := p.extractor.getString(aggValue, "fixed_interval"); interval != "" {
settings["interval"] = interval
} else if interval := p.extractor.getString(aggValue, "calendar_interval"); interval != "" {
settings["interval"] = interval
} else if interval := p.extractor.getString(aggValue, "interval"); interval != "" {
settings["interval"] = interval
}
if minDocCount := p.extractor.getInt(aggValue, "min_doc_count"); minDocCount > 0 {
settings["min_doc_count"] = strconv.Itoa(minDocCount)
}
if timeZone := p.extractor.getString(aggValue, "time_zone"); timeZone != "" {
settings["time_zone"] = timeZone
}
return &dslAgg{
ID: id,
Type: dateHistType,
Field: field,
Settings: simplejson.NewFromAny(settings),
AggType: aggTypeBucket,
}, nil
}
// termsParser handles terms aggregations
type termsParser struct {
extractor *fieldExtractor
}
func (p *termsParser) CanParse(aggType string) bool {
return aggType == termsType
}
func (p *termsParser) Parse(id, aggType string, aggValue map[string]any) (*dslAgg, error) {
field := p.extractor.getString(aggValue, "field")
settings := make(map[string]any)
if size := p.extractor.getInt(aggValue, "size"); size > 0 {
settings["size"] = strconv.Itoa(size)
}
if order := p.extractor.getMap(aggValue, "order"); order != nil {
for k := range order {
settings["orderBy"] = k
orderJSON := p.extractor.getString(order, k)
settings["order"] = orderJSON
}
}
if minDocCount := p.extractor.getInt(aggValue, "min_doc_count"); minDocCount != 0 {
minDocCountJSON, _ := json.Marshal(minDocCount)
settings["min_doc_count"] = string(minDocCountJSON)
}
if missing := p.extractor.getString(aggValue, "missing"); missing != "" {
settings["missing"] = missing
}
return &dslAgg{
ID: id,
Type: termsType,
Field: field,
Settings: simplejson.NewFromAny(settings),
AggType: aggTypeBucket,
}, nil
}
// histogramParser handles histogram aggregations
type histogramParser struct {
extractor *fieldExtractor
}
func (p *histogramParser) CanParse(aggType string) bool {
return aggType == histogramType
}
func (p *histogramParser) Parse(id, aggType string, aggValue map[string]any) (*dslAgg, error) {
field := p.extractor.getString(aggValue, "field")
settings := make(map[string]any)
if interval := p.extractor.getFloat(aggValue, "interval"); interval > 0 {
settings["interval"] = strconv.FormatFloat(interval, 'f', -1, 64)
}
if minDocCount := p.extractor.getInt(aggValue, "min_doc_count"); minDocCount > 0 {
settings["min_doc_count"] = strconv.Itoa(minDocCount)
}
return &dslAgg{
ID: id,
Type: histogramType,
Field: field,
Settings: simplejson.NewFromAny(settings),
AggType: aggTypeBucket,
}, nil
}
// simpleMetricParser handles simple metric aggregations (avg, sum, min, max, cardinality)
type simpleMetricParser struct {
extractor *fieldExtractor
types map[string]bool
}
func newSimpleMetricParser() *simpleMetricParser {
return &simpleMetricParser{
extractor: &fieldExtractor{},
types: map[string]bool{
"avg": true,
"sum": true,
"min": true,
"max": true,
"cardinality": true,
},
}
}
func (p *simpleMetricParser) CanParse(aggType string) bool {
return p.types[aggType]
}
func (p *simpleMetricParser) Parse(id, aggType string, aggValue map[string]any) (*dslAgg, error) {
field := p.extractor.getString(aggValue, "field")
settings := p.extractor.getSettings(aggValue)
return &dslAgg{
ID: id,
Type: aggType,
Field: field,
Settings: settings,
AggType: aggTypeMetric,
}, nil
}
// filtersParser handles filters aggregations
type filtersParser struct {
extractor *fieldExtractor
}
func (p *filtersParser) CanParse(aggType string) bool {
return aggType == filtersType
}
func (p *filtersParser) Parse(id, aggType string, aggValue map[string]any) (*dslAgg, error) {
settings := make(map[string]any)
if filters := p.extractor.getMap(aggValue, "filters"); filters != nil {
filtersArray := make([]any, 0, len(filters))
for k, v := range filters {
if queryString := p.extractor.getMap(v.(map[string]any), "query_string"); queryString != nil {
queryString["label"] = k
filtersArray = append(filtersArray, queryString)
}
}
settings["filters"] = filtersArray
}
return &dslAgg{
ID: id,
Type: filtersType,
Field: "",
Settings: simplejson.NewFromAny(settings),
AggType: aggTypeBucket,
}, nil
}
// geohashGridParser handles geohash_grid aggregations
type geohashGridParser struct {
extractor *fieldExtractor
}
func (p *geohashGridParser) CanParse(aggType string) bool {
return aggType == geohashGridType
}
func (p *geohashGridParser) Parse(id, aggType string, aggValue map[string]any) (*dslAgg, error) {
field := p.extractor.getString(aggValue, "field")
settings := make(map[string]any)
if precision := p.extractor.getInt(aggValue, "precision"); precision > 0 {
settings["precision"] = strconv.Itoa(precision)
}
return &dslAgg{
ID: id,
Type: geohashGridType,
Field: field,
Settings: simplejson.NewFromAny(settings),
AggType: aggTypeBucket,
}, nil
}
// nestedParser handles nested aggregations
type nestedParser struct {
extractor *fieldExtractor
}
func (p *nestedParser) CanParse(aggType string) bool {
return aggType == nestedType
}
func (p *nestedParser) Parse(id, aggType string, aggValue map[string]any) (*dslAgg, error) {
path := p.extractor.getString(aggValue, "path")
return &dslAgg{
ID: id,
Type: nestedType,
Field: path,
Settings: simplejson.NewFromAny(map[string]any{}),
AggType: aggTypeBucket,
}, nil
}
// extendedStatsParser handles extended_stats aggregations
type extendedStatsParser struct {
extractor *fieldExtractor
}
func (p *extendedStatsParser) CanParse(aggType string) bool {
return aggType == extendedStatsType
}
func (p *extendedStatsParser) Parse(id, aggType string, aggValue map[string]any) (*dslAgg, error) {
field := p.extractor.getString(aggValue, "field")
settings := p.extractor.getSettings(aggValue)
return &dslAgg{
ID: id,
Type: extendedStatsType,
Field: field,
Settings: settings,
AggType: aggTypeMetric,
}, nil
}
// percentilesParser handles percentiles aggregations
type percentilesParser struct {
extractor *fieldExtractor
}
func (p *percentilesParser) CanParse(aggType string) bool {
return aggType == percentilesType
}
func (p *percentilesParser) Parse(id, aggType string, aggValue map[string]any) (*dslAgg, error) {
field := p.extractor.getString(aggValue, "field")
settings := p.extractor.getSettings(aggValue)
return &dslAgg{
ID: id,
Type: percentilesType,
Field: field,
Settings: settings,
AggType: aggTypeMetric,
}, nil
}
// topMetricsParser handles top_metrics aggregations
type topMetricsParser struct {
extractor *fieldExtractor
}
func (p *topMetricsParser) CanParse(aggType string) bool {
return aggType == topMetricsType
}
func (p *topMetricsParser) Parse(id, aggType string, aggValue map[string]any) (*dslAgg, error) {
settings := p.extractor.getSettings(aggValue)
// Extract metrics field if present
field := ""
if metrics := p.extractor.getMap(aggValue, "metrics"); metrics != nil {
if metricsField := p.extractor.getString(metrics, "field"); metricsField != "" {
field = metricsField
}
}
return &dslAgg{
ID: id,
Type: topMetricsType,
Field: field,
Settings: settings,
AggType: aggTypeMetric,
}, nil
}
// pipelineParser handles pipeline aggregations
type pipelineParser struct {
extractor *fieldExtractor
types map[string]bool
}
func newPipelineParser() *pipelineParser {
return &pipelineParser{
extractor: &fieldExtractor{},
types: map[string]bool{
"moving_avg": true,
"moving_fn": true,
"derivative": true,
"cumulative_sum": true,
"serial_diff": true,
},
}
}
func (p *pipelineParser) CanParse(aggType string) bool {
return p.types[aggType]
}
func (p *pipelineParser) Parse(id, aggType string, aggValue map[string]any) (*dslAgg, error) {
bucketsPath := p.extractor.getString(aggValue, "buckets_path")
settings := p.extractor.getSettings(aggValue)
return &dslAgg{
ID: id,
Type: aggType,
Field: bucketsPath, // For pipeline aggs, buckets_path goes in Field
Settings: settings,
AggType: aggTypeMetric,
}, nil
}
// bucketScriptParser handles bucket_script aggregations
type bucketScriptParser struct {
extractor *fieldExtractor
}
func (p *bucketScriptParser) CanParse(aggType string) bool {
return aggType == "bucket_script"
}
func (p *bucketScriptParser) Parse(id, aggType string, aggValue map[string]any) (*dslAgg, error) {
settings := p.extractor.getSettings(aggValue)
// Extract buckets_path (can be a string or map)
pipelineVariables := make(map[string]string)
if bucketsPath, ok := aggValue["buckets_path"]; ok {
switch bp := bucketsPath.(type) {
case string:
// Single string bucket path
pipelineVariables["var1"] = bp
case map[string]any:
// Map of variable names to bucket paths
for varName, path := range bp {
if pathStr, ok := path.(string); ok {
pipelineVariables[varName] = pathStr
}
}
}
}
return &dslAgg{
ID: id,
Type: "bucket_script",
Field: "",
PipelineVariables: pipelineVariables,
Settings: settings,
AggType: aggTypeMetric,
}, nil
}
// compositeParser combines multiple parsers
type compositeParser struct {
parsers []aggregationTypeParser
extractor *fieldExtractor
}
func newCompositeParser() *compositeParser {
extractor := &fieldExtractor{}
return &compositeParser{
extractor: extractor,
parsers: []aggregationTypeParser{
// Bucket aggregations
&dateHistogramParser{extractor: extractor},
&termsParser{extractor: extractor},
&histogramParser{extractor: extractor},
&filtersParser{extractor: extractor},
&geohashGridParser{extractor: extractor},
&nestedParser{extractor: extractor},
// Metric aggregations
newSimpleMetricParser(),
&extendedStatsParser{extractor: extractor},
&percentilesParser{extractor: extractor},
&topMetricsParser{extractor: extractor},
// Pipeline aggregations
newPipelineParser(),
&bucketScriptParser{extractor: extractor},
},
}
}
func (p *compositeParser) findParser(aggType string) aggregationTypeParser {
for _, parser := range p.parsers {
if parser.CanParse(aggType) {
return parser
}
}
return nil
}
func (p *compositeParser) Parse(rawQuery string) ([]*BucketAgg, []*MetricAgg, error) {
if rawQuery == "" {
return nil, nil, nil
}
var queryBody map[string]any
if err := json.Unmarshal([]byte(rawQuery), &queryBody); err != nil {
return nil, nil, fmt.Errorf("failed to parse raw query JSON: %w", err)
}
// Look for aggregations in both "aggs" and "aggregations"
var aggsData map[string]any
if aggs, ok := queryBody["aggs"].(map[string]any); ok {
aggsData = aggs
} else if aggs, ok := queryBody["aggregations"].(map[string]any); ok {
aggsData = aggs
}
if aggsData == nil {
return nil, nil, nil
}
b, m := p.parseAggregations(aggsData)
return b, m, nil
}
func (p *compositeParser) parseAggregations(aggsData map[string]any) ([]*BucketAgg, []*MetricAgg) {
var bucketAggs []*BucketAgg
var metricAggs []*MetricAgg
for aggID, aggData := range aggsData {
aggMap, ok := aggData.(map[string]any)
if !ok {
continue
}
// Find the aggregation type (first key that's not "aggs" or "aggregations")
var aggType string
var aggValue map[string]any
for key, value := range aggMap {
if key != "aggs" && key != "aggregations" {
aggType = key
if val, ok := value.(map[string]any); ok {
aggValue = val
}
break
}
}
if aggType == "" || aggValue == nil {
continue
}
// Find the appropriate parser for this aggregation type
parser := p.findParser(aggType)
if parser == nil {
// Unknown aggregation type, skip it
continue
}
// Try to parse as agg aggregation
if agg, err := parser.Parse(aggID, aggType, aggValue); err == nil && agg != nil {
switch agg.AggType {
case aggTypeBucket:
bucketAggs = append(bucketAggs, agg.toBucketAgg())
case aggTypeMetric:
metricAggs = append(metricAggs, agg.toMetricAgg())
}
}
// Parse nested aggregations
nestedAggs := p.extractor.getMap(aggMap, "aggs")
if nestedAggs == nil {
nestedAggs = p.extractor.getMap(aggMap, "aggregations")
}
nestedBuckets, nestedMetrics := p.parseAggregations(nestedAggs)
bucketAggs = append(bucketAggs, nestedBuckets...)
metricAggs = append(metricAggs, nestedMetrics...)
}
return bucketAggs, metricAggs
}
// NewAggregationParser creates a new aggregation parser
func NewAggregationParser() AggregationParser {
return newCompositeParser()
}
@@ -0,0 +1,706 @@
package elasticsearch
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestFieldExtractor tests the field extraction utility
func TestFieldExtractor(t *testing.T) {
extractor := &fieldExtractor{}
t.Run("getString", func(t *testing.T) {
data := map[string]any{
"field": "value",
"number": 42,
"missing": nil,
}
assert.Equal(t, "value", extractor.getString(data, "field"))
assert.Equal(t, "", extractor.getString(data, "number"))
assert.Equal(t, "", extractor.getString(data, "missing"))
assert.Equal(t, "", extractor.getString(data, "nonexistent"))
})
t.Run("getInt", func(t *testing.T) {
data := map[string]any{
"float": 42.0,
"int": 100,
"string": "200",
"bad": "notanumber",
}
assert.Equal(t, 42, extractor.getInt(data, "float"))
assert.Equal(t, 100, extractor.getInt(data, "int"))
assert.Equal(t, 200, extractor.getInt(data, "string"))
assert.Equal(t, 0, extractor.getInt(data, "bad"))
assert.Equal(t, 0, extractor.getInt(data, "nonexistent"))
})
t.Run("getFloat", func(t *testing.T) {
data := map[string]any{
"float": 42.5,
"int": 100,
"string": "3.14",
}
assert.Equal(t, 42.5, extractor.getFloat(data, "float"))
assert.Equal(t, 100.0, extractor.getFloat(data, "int"))
assert.Equal(t, 3.14, extractor.getFloat(data, "string"))
assert.Equal(t, 0.0, extractor.getFloat(data, "nonexistent"))
})
t.Run("getMap", func(t *testing.T) {
data := map[string]any{
"map": map[string]any{"key": "value"},
"notmap": "string",
}
result := extractor.getMap(data, "map")
require.NotNil(t, result)
assert.Equal(t, "value", result["key"])
assert.Nil(t, extractor.getMap(data, "notmap"))
assert.Nil(t, extractor.getMap(data, "nonexistent"))
})
}
// TestDateHistogramParser tests the date histogram parser
func TestDateHistogramParser(t *testing.T) {
parser := &dateHistogramParser{extractor: &fieldExtractor{}}
t.Run("CanParse", func(t *testing.T) {
assert.True(t, parser.CanParse(dateHistType))
assert.False(t, parser.CanParse("terms"))
})
t.Run("Parse with fixed_interval", func(t *testing.T) {
aggValue := map[string]any{
"field": "@timestamp",
"fixed_interval": "30s",
"min_doc_count": 1,
}
agg, err := parser.Parse("1", dateHistType, aggValue)
require.NoError(t, err)
require.NotNil(t, agg)
bucket := agg.toBucketAgg()
assert.Equal(t, "1", bucket.ID)
assert.Equal(t, dateHistType, bucket.Type)
assert.Equal(t, "@timestamp", bucket.Field)
assert.Equal(t, "30s", bucket.Settings.Get("interval").MustString())
assert.Equal(t, "1", bucket.Settings.Get("min_doc_count").MustString())
})
t.Run("Parse with calendar_interval", func(t *testing.T) {
aggValue := map[string]any{
"field": "@timestamp",
"calendar_interval": "1d",
"time_zone": "UTC",
}
agg, err := parser.Parse("2", dateHistType, aggValue)
require.NoError(t, err)
require.NotNil(t, agg)
bucket := agg.toBucketAgg()
assert.Equal(t, "1d", bucket.Settings.Get("interval").MustString())
assert.Equal(t, "UTC", bucket.Settings.Get("time_zone").MustString())
})
t.Run("Parse returns bucket aggregation", func(t *testing.T) {
agg, err := parser.Parse("1", dateHistType, map[string]any{"field": "@timestamp"})
assert.NoError(t, err)
assert.NotNil(t, agg)
assert.Equal(t, aggTypeBucket, agg.AggType)
})
}
// TestTermsParser tests the terms parser
func TestTermsParser(t *testing.T) {
parser := &termsParser{extractor: &fieldExtractor{}}
t.Run("CanParse", func(t *testing.T) {
assert.True(t, parser.CanParse(termsType))
assert.False(t, parser.CanParse("histogram"))
})
t.Run("Parse", func(t *testing.T) {
aggValue := map[string]any{
"field": "hostname.keyword",
"size": 10,
"order": map[string]any{"_count": "desc"},
}
agg, err := parser.Parse("3", termsType, aggValue)
require.NoError(t, err)
require.NotNil(t, agg)
bucket := agg.toBucketAgg()
assert.Equal(t, "3", bucket.ID)
assert.Equal(t, termsType, bucket.Type)
assert.Equal(t, "hostname.keyword", bucket.Field)
assert.Equal(t, "10", bucket.Settings.Get("size").MustString())
})
}
// TestHistogramParser tests the histogram parser
func TestHistogramParser(t *testing.T) {
parser := &histogramParser{extractor: &fieldExtractor{}}
t.Run("CanParse", func(t *testing.T) {
assert.True(t, parser.CanParse(histogramType))
assert.False(t, parser.CanParse("terms"))
})
t.Run("Parse", func(t *testing.T) {
aggValue := map[string]any{
"field": "response_time",
"interval": 50.0,
}
agg, err := parser.Parse("4", histogramType, aggValue)
require.NoError(t, err)
require.NotNil(t, agg)
bucket := agg.toBucketAgg()
assert.Equal(t, "4", bucket.ID)
assert.Equal(t, histogramType, bucket.Type)
assert.Equal(t, "response_time", bucket.Field)
assert.Equal(t, "50", bucket.Settings.Get("interval").MustString())
})
}
// TestFiltersParser tests the filters parser
func TestFiltersParser(t *testing.T) {
parser := &filtersParser{extractor: &fieldExtractor{}}
t.Run("CanParse", func(t *testing.T) {
assert.True(t, parser.CanParse(filtersType))
assert.False(t, parser.CanParse("terms"))
})
t.Run("Parse", func(t *testing.T) {
aggValue := map[string]any{
"filters": map[string]any{
"errors": map[string]any{"query_string": map[string]any{"query": "level:error"}},
"warnings": map[string]any{"query_string": map[string]any{"query": "level:warning"}},
},
}
agg, err := parser.Parse("filters", filtersType, aggValue)
require.NoError(t, err)
require.NotNil(t, agg)
bucket := agg.toBucketAgg()
assert.Equal(t, "filters", bucket.ID)
assert.Equal(t, filtersType, bucket.Type)
filtersArray := bucket.Settings.Get("filters").MustArray()
assert.NotEmpty(t, filtersArray)
assert.Len(t, filtersArray, 2)
})
}
// TestSimpleMetricParser tests the simple metric parser
func TestSimpleMetricParser(t *testing.T) {
parser := newSimpleMetricParser()
t.Run("CanParse", func(t *testing.T) {
assert.True(t, parser.CanParse("avg"))
assert.True(t, parser.CanParse("sum"))
assert.True(t, parser.CanParse("min"))
assert.True(t, parser.CanParse("max"))
assert.True(t, parser.CanParse("cardinality"))
assert.False(t, parser.CanParse("bucket_script"))
})
t.Run("Parse avg", func(t *testing.T) {
aggValue := map[string]any{
"field": "response_time",
}
agg, err := parser.Parse("1", "avg", aggValue)
require.NoError(t, err)
require.NotNil(t, agg)
metric := agg.toMetricAgg()
assert.Equal(t, "1", metric.ID)
assert.Equal(t, "avg", metric.Type)
assert.Equal(t, "response_time", metric.Field)
})
t.Run("Parse returns metric aggregation", func(t *testing.T) {
agg, err := parser.Parse("1", "avg", map[string]any{})
assert.NoError(t, err)
assert.NotNil(t, agg)
assert.Equal(t, aggTypeMetric, agg.AggType)
})
}
// TestExtendedStatsParser tests the extended stats parser
func TestExtendedStatsParser(t *testing.T) {
parser := &extendedStatsParser{extractor: &fieldExtractor{}}
t.Run("CanParse", func(t *testing.T) {
assert.True(t, parser.CanParse(extendedStatsType))
assert.False(t, parser.CanParse("avg"))
})
t.Run("Parse", func(t *testing.T) {
aggValue := map[string]any{
"field": "response_time",
"sigma": 2,
}
agg, err := parser.Parse("stats", extendedStatsType, aggValue)
require.NoError(t, err)
require.NotNil(t, agg)
metric := agg.toMetricAgg()
assert.Equal(t, "stats", metric.ID)
assert.Equal(t, extendedStatsType, metric.Type)
assert.Equal(t, "response_time", metric.Field)
})
}
// TestPercentilesParser tests the percentiles parser
func TestPercentilesParser(t *testing.T) {
parser := &percentilesParser{extractor: &fieldExtractor{}}
t.Run("CanParse", func(t *testing.T) {
assert.True(t, parser.CanParse(percentilesType))
assert.False(t, parser.CanParse("avg"))
})
t.Run("Parse", func(t *testing.T) {
aggValue := map[string]any{
"field": "response_time",
"percents": []any{50.0, 95.0, 99.0},
}
agg, err := parser.Parse("percentiles", percentilesType, aggValue)
require.NoError(t, err)
require.NotNil(t, agg)
metric := agg.toMetricAgg()
assert.Equal(t, "percentiles", metric.ID)
assert.Equal(t, percentilesType, metric.Type)
assert.Equal(t, "response_time", metric.Field)
})
}
// TestPipelineParser tests the pipeline parser
func TestPipelineParser(t *testing.T) {
parser := newPipelineParser()
t.Run("CanParse", func(t *testing.T) {
assert.True(t, parser.CanParse("moving_avg"))
assert.True(t, parser.CanParse("derivative"))
assert.True(t, parser.CanParse("cumulative_sum"))
assert.False(t, parser.CanParse("bucket_script"))
})
t.Run("Parse", func(t *testing.T) {
aggValue := map[string]any{
"buckets_path": "1",
}
agg, err := parser.Parse("moving", "moving_avg", aggValue)
require.NoError(t, err)
require.NotNil(t, agg)
metric := agg.toMetricAgg()
assert.Equal(t, "moving", metric.ID)
assert.Equal(t, "moving_avg", metric.Type)
assert.Equal(t, "1", metric.Field)
})
}
// TestBucketScriptParser tests the bucket script parser
func TestBucketScriptParser(t *testing.T) {
parser := &bucketScriptParser{extractor: &fieldExtractor{}}
t.Run("CanParse", func(t *testing.T) {
assert.True(t, parser.CanParse("bucket_script"))
assert.False(t, parser.CanParse("moving_avg"))
})
t.Run("Parse with map buckets_path", func(t *testing.T) {
aggValue := map[string]any{
"buckets_path": map[string]any{
"count": "total",
},
"script": "params.count / 60",
}
agg, err := parser.Parse("rate", "bucket_script", aggValue)
require.NoError(t, err)
require.NotNil(t, agg)
metric := agg.toMetricAgg()
assert.Equal(t, "rate", metric.ID)
assert.Equal(t, "bucket_script", metric.Type)
assert.Equal(t, "total", metric.PipelineVariables["count"])
assert.Equal(t, "params.count / 60", metric.Settings.Get("script").MustString())
})
t.Run("Parse with string buckets_path", func(t *testing.T) {
aggValue := map[string]any{
"buckets_path": "1",
}
agg, err := parser.Parse("rate", "bucket_script", aggValue)
require.NoError(t, err)
require.NotNil(t, agg)
metric := agg.toMetricAgg()
assert.Equal(t, "1", metric.PipelineVariables["var1"])
})
}
// TestCompositeParser tests the full parser integration
func TestCompositeParser(t *testing.T) {
parser := NewAggregationParser()
t.Run("Parse date histogram aggregation", func(t *testing.T) {
rawQuery := `{
"query": {
"match_all": {}
},
"aggs": {
"2": {
"date_histogram": {
"field": "@timestamp",
"fixed_interval": "30s",
"min_doc_count": 1
}
}
}
}`
bucketAggs, metricAggs, err := parser.Parse(rawQuery)
require.NoError(t, err)
require.Len(t, bucketAggs, 1)
require.Len(t, metricAggs, 0)
assert.Equal(t, "2", bucketAggs[0].ID)
assert.Equal(t, dateHistType, bucketAggs[0].Type)
assert.Equal(t, "@timestamp", bucketAggs[0].Field)
assert.Equal(t, "30s", bucketAggs[0].Settings.Get("interval").MustString())
})
t.Run("Parse nested aggregations with metrics", func(t *testing.T) {
rawQuery := `{
"query": {
"match_all": {}
},
"aggs": {
"2": {
"date_histogram": {
"field": "@timestamp",
"fixed_interval": "30s"
},
"aggs": {
"1": {
"avg": {
"field": "value"
}
},
"3": {
"sum": {
"field": "total"
}
}
}
}
}
}`
bucketAggs, metricAggs, err := parser.Parse(rawQuery)
require.NoError(t, err)
require.Len(t, bucketAggs, 1)
require.Len(t, metricAggs, 2)
// Check bucket aggregation
assert.Equal(t, "2", bucketAggs[0].ID)
assert.Equal(t, dateHistType, bucketAggs[0].Type)
// Check metric aggregations
avgFound := false
sumFound := false
for _, m := range metricAggs {
if m.ID == "1" && m.Type == "avg" && m.Field == "value" {
avgFound = true
}
if m.ID == "3" && m.Type == "sum" && m.Field == "total" {
sumFound = true
}
}
assert.True(t, avgFound, "avg aggregation not found")
assert.True(t, sumFound, "sum aggregation not found")
})
t.Run("Parse terms aggregation", func(t *testing.T) {
rawQuery := `{
"aggs": {
"3": {
"terms": {
"field": "hostname.keyword",
"size": 10,
"order": {
"_count": "desc"
}
}
}
}
}`
bucketAggs, metricAggs, err := parser.Parse(rawQuery)
require.NoError(t, err)
require.Len(t, bucketAggs, 1)
require.Len(t, metricAggs, 0)
assert.Equal(t, "3", bucketAggs[0].ID)
assert.Equal(t, termsType, bucketAggs[0].Type)
assert.Equal(t, "hostname.keyword", bucketAggs[0].Field)
assert.Equal(t, "10", bucketAggs[0].Settings.Get("size").MustString())
})
t.Run("Parse histogram aggregation", func(t *testing.T) {
rawQuery := `{
"aggs": {
"4": {
"histogram": {
"field": "response_time",
"interval": 50
}
}
}
}`
bucketAggs, _, err := parser.Parse(rawQuery)
require.NoError(t, err)
require.Len(t, bucketAggs, 1)
assert.Equal(t, "4", bucketAggs[0].ID)
assert.Equal(t, histogramType, bucketAggs[0].Type)
assert.Equal(t, "response_time", bucketAggs[0].Field)
assert.Equal(t, "50", bucketAggs[0].Settings.Get("interval").MustString())
})
t.Run("Parse extended stats aggregation", func(t *testing.T) {
rawQuery := `{
"aggs": {
"stats": {
"extended_stats": {
"field": "response_time",
"sigma": 2
}
}
}
}`
_, metricAggs, err := parser.Parse(rawQuery)
require.NoError(t, err)
require.Len(t, metricAggs, 1)
assert.Equal(t, "stats", metricAggs[0].ID)
assert.Equal(t, extendedStatsType, metricAggs[0].Type)
assert.Equal(t, "response_time", metricAggs[0].Field)
})
t.Run("Parse percentiles aggregation", func(t *testing.T) {
rawQuery := `{
"aggs": {
"percentiles": {
"percentiles": {
"field": "response_time",
"percents": [50, 95, 99]
}
}
}
}`
_, metricAggs, err := parser.Parse(rawQuery)
require.NoError(t, err)
require.Len(t, metricAggs, 1)
assert.Equal(t, "percentiles", metricAggs[0].ID)
assert.Equal(t, percentilesType, metricAggs[0].Type)
})
t.Run("Parse pipeline aggregations", func(t *testing.T) {
rawQuery := `{
"aggs": {
"2": {
"date_histogram": {
"field": "@timestamp",
"fixed_interval": "1m"
},
"aggs": {
"1": {
"avg": {
"field": "value"
}
},
"moving": {
"moving_avg": {
"buckets_path": "1"
}
},
"deriv": {
"derivative": {
"buckets_path": "1"
}
}
}
}
}
}`
bucketAggs, metricAggs, err := parser.Parse(rawQuery)
require.NoError(t, err)
require.Len(t, bucketAggs, 1)
require.GreaterOrEqual(t, len(metricAggs), 2) // At least avg and one pipeline
// Find pipeline aggregations
movingAvgFound := false
derivativeFound := false
for _, m := range metricAggs {
if m.ID == "moving" && m.Type == "moving_avg" {
movingAvgFound = true
assert.Equal(t, "1", m.Field)
}
if m.ID == "deriv" && m.Type == "derivative" {
derivativeFound = true
assert.Equal(t, "1", m.Field)
}
}
assert.True(t, movingAvgFound, "moving_avg aggregation not found")
assert.True(t, derivativeFound, "derivative aggregation not found")
})
t.Run("Parse bucket script aggregation", func(t *testing.T) {
rawQuery := `{
"aggs": {
"2": {
"date_histogram": {
"field": "@timestamp",
"fixed_interval": "1m"
},
"aggs": {
"total": {
"sum": {
"field": "bytes"
}
},
"rate": {
"bucket_script": {
"buckets_path": {
"count": "total"
},
"script": "params.count / 60"
}
}
}
}
}
}`
_, metricAggs, err := parser.Parse(rawQuery)
require.NoError(t, err)
// Find bucket script
var bucketScriptAgg *MetricAgg
for _, m := range metricAggs {
if m.ID == "rate" && m.Type == "bucket_script" {
bucketScriptAgg = m
break
}
}
require.NotNil(t, bucketScriptAgg, "bucket_script aggregation not found")
assert.Equal(t, "params.count / 60", bucketScriptAgg.Settings.Get("script").MustString())
assert.Equal(t, "total", bucketScriptAgg.PipelineVariables["count"])
})
t.Run("Parse filters aggregation", func(t *testing.T) {
rawQuery := `{
"aggs": {
"messages": {
"filters": {
"filters": {
"errors": {
"query_string": {
"query": "level:error"
}
},
"warnings": {
"query_string": {
"query": "level:warning"
}
}
}
}
}
}
}`
bucketAggs, _, err := parser.Parse(rawQuery)
require.NoError(t, err)
require.Len(t, bucketAggs, 1)
assert.Equal(t, "messages", bucketAggs[0].ID)
assert.Equal(t, filtersType, bucketAggs[0].Type)
})
t.Run("Handle empty query", func(t *testing.T) {
bucketAggs, metricAggs, err := parser.Parse("")
require.NoError(t, err)
assert.Nil(t, bucketAggs)
assert.Nil(t, metricAggs)
})
t.Run("Handle query without aggregations", func(t *testing.T) {
rawQuery := `{
"query": {
"match_all": {}
}
}`
bucketAggs, metricAggs, err := parser.Parse(rawQuery)
require.NoError(t, err)
assert.Nil(t, bucketAggs)
assert.Nil(t, metricAggs)
})
t.Run("Handle invalid JSON", func(t *testing.T) {
rawQuery := `{invalid json`
_, _, err := parser.Parse(rawQuery)
require.Error(t, err)
})
t.Run("Use 'aggregations' instead of 'aggs'", func(t *testing.T) {
rawQuery := `{
"query": {
"match_all": {}
},
"aggregations": {
"2": {
"date_histogram": {
"field": "@timestamp",
"fixed_interval": "30s"
}
}
}
}`
bucketAggs, _, err := parser.Parse(rawQuery)
require.NoError(t, err)
require.Len(t, bucketAggs, 1)
assert.Equal(t, "2", bucketAggs[0].ID)
})
}
@@ -12,7 +12,7 @@ import { DashboardViewItem } from '../../../features/search/types';
import { useFoldersQuery } from './useFoldersQuery';
import { getCustomRootFolderItem, getRootFolderItem } from './utils';
const [_, { folderA, folderB, folderC }] = getFolderFixtures();
const [_, { folderA, folderB, folderC, folderD }] = getFolderFixtures();
runtime.setBackendSrv(backendSrv);
setupMockServer();
@@ -44,7 +44,7 @@ describe('useFoldersQuery', () => {
const [_dashboardsContainer, ...items] = await testFn();
const sortedItemTitles = items.map((item) => (item.item as DashboardViewItem).title).sort();
const expectedTitles = [folderA.item.title, folderB.item.title, folderC.item.title].sort();
const expectedTitles = [folderA.item.title, folderB.item.title, folderC.item.title, folderD.item.title].sort();
expect(sortedItemTitles).toEqual(expectedTitles);
});
@@ -21,8 +21,8 @@ import {
Stack,
Text,
} from '@grafana/ui';
import { NestedFolderPicker } from 'app/core/components/NestedFolderPicker/NestedFolderPicker';
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
import { ProvisioningAwareFolderPicker } from 'app/features/provisioning/components/Shared/ProvisioningAwareFolderPicker';
import { Folder } from '../../types/rule-form';
import {
@@ -409,9 +409,10 @@ function TargetFolderField() {
name="targetFolder"
render={({ field: { onChange, ref, ...field } }) => (
<Stack width={42}>
<NestedFolderPicker
<ProvisioningAwareFolderPicker
permission="view"
showRootFolder={false}
repositoryName={undefined}
invalid={!!errors.targetFolder?.message}
{...field}
value={field.value?.uid}
@@ -3,7 +3,7 @@ import { Controller, useFormContext } from 'react-hook-form';
import { Trans, t } from '@grafana/i18n';
import { Field, Label, Stack } from '@grafana/ui';
import { NestedFolderPicker } from 'app/core/components/NestedFolderPicker/NestedFolderPicker';
import { ProvisioningAwareFolderPicker } from 'app/features/provisioning/components/Shared/ProvisioningAwareFolderPicker';
import { Folder, RuleFormValues } from '../../types/rule-form';
import { CreateNewFolder } from '../create-folder/CreateNewFolder';
@@ -48,9 +48,10 @@ export function FolderSelector() {
<Controller
render={({ field: { ref, ...field } }) => (
<div style={{ width: 420 }}>
<NestedFolderPicker
<ProvisioningAwareFolderPicker
permission="view"
showRootFolder={false}
repositoryName={undefined}
invalid={!!errors.folder?.message}
{...field}
value={folder?.uid}
@@ -44,6 +44,7 @@ export interface DashboardsTreeItem<T extends DashboardViewItemWithUIItems = Das
level: number;
isOpen: boolean;
parentUID?: string;
disabled?: boolean;
}
interface RendererUserProps {
@@ -1,11 +1,21 @@
import { render } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import { selectors } from '@grafana/e2e-selectors';
import { config } from '@grafana/runtime';
import { SceneVariableSet, ScopesVariable, TextBoxVariable } from '@grafana/scenes';
import { playlistSrv } from 'app/features/playlist/PlaylistSrv';
import { DashboardControls, DashboardControlsState } from './DashboardControls';
import { DashboardScene } from './DashboardScene';
jest.mock('app/features/playlist/PlaylistSrv', () => ({
playlistSrv: {
useState: jest.fn().mockReturnValue({ isPlaying: false }),
state: { isPlaying: false },
stop: jest.fn(),
},
}));
describe('DashboardControls', () => {
describe('Given a standard scene', () => {
it('should initialize with default values', () => {
@@ -221,8 +231,85 @@ describe('DashboardControls', () => {
expect(setState).toHaveBeenCalledTimes(0);
});
});
describe('DashboardControlActions editable flag', () => {
const originalFeatureToggles = { ...config.featureToggles };
beforeEach(() => {
config.featureToggles.dashboardNewLayouts = true;
jest.mocked(playlistSrv.useState).mockReturnValue({ isPlaying: false });
});
afterEach(() => {
config.featureToggles = originalFeatureToggles;
jest.clearAllMocks();
});
it('should show EditDashboardSwitch when editable is true', async () => {
const controls = buildTestSceneWithEditable({ editable: true, canEdit: true });
render(<controls.Component model={controls} />);
expect(await screen.findByRole('button', { name: /edit/i })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /make editable/i })).not.toBeInTheDocument();
});
it('should show MakeDashboardEditableButton when editable is false', async () => {
const controls = buildTestSceneWithEditable({ editable: false, canEdit: false, canMakeEditable: true });
render(<controls.Component model={controls} />);
expect(await screen.findByRole('button', { name: /make editable/i })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /^edit$/i })).not.toBeInTheDocument();
});
it('should not show edit buttons when canEditDashboard returns false', async () => {
const controls = buildTestSceneWithEditable({
editable: true,
canEdit: false,
canMakeEditable: false,
isSnapshot: true,
});
render(<controls.Component model={controls} />);
expect(screen.queryByRole('button', { name: /edit/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /make editable/i })).not.toBeInTheDocument();
});
it('should not show edit buttons when playlist is playing', async () => {
jest.mocked(playlistSrv.useState).mockReturnValue({ isPlaying: true });
const controls = buildTestSceneWithEditable({ editable: true, canEdit: true });
render(<controls.Component model={controls} />);
expect(screen.queryByRole('button', { name: /^edit$/i })).not.toBeInTheDocument();
expect(await screen.findByTestId(selectors.pages.Dashboard.DashNav.playlistControls.stop)).toBeInTheDocument();
});
});
});
function buildTestSceneWithEditable(options: {
editable: boolean;
canEdit?: boolean;
canMakeEditable?: boolean;
isSnapshot?: boolean;
}): DashboardControls {
const { editable, canEdit = true, canMakeEditable = false, isSnapshot = false } = options;
const dashboard = new DashboardScene({
uid: 'test-uid',
editable,
meta: {
canEdit,
canMakeEditable,
isSnapshot,
},
controls: new DashboardControls({}),
});
dashboard.activate();
return dashboard.state.controls as DashboardControls;
}
function buildTestScene(state?: Partial<DashboardControlsState>): DashboardControls {
const variable = new TextBoxVariable({
name: 'A',
@@ -31,6 +31,7 @@ import { VariableControls } from './VariableControls';
import { DashboardControlsButton } from './dashboard-controls-menu/DashboardControlsMenuButton';
import { hasDashboardControls, useHasDashboardControls } from './dashboard-controls-menu/utils';
import { EditDashboardSwitch } from './new-toolbar/actions/EditDashboardSwitch';
import { MakeDashboardEditableButton } from './new-toolbar/actions/MakeDashboardEditableButton';
import { SaveDashboard } from './new-toolbar/actions/SaveDashboard';
import { ShareDashboardButton } from './new-toolbar/actions/ShareDashboardButton';
@@ -191,7 +192,7 @@ function DashboardControlsRenderer({ model }: SceneComponentProps<DashboardContr
}
function DashboardControlActions({ dashboard }: { dashboard: DashboardScene }) {
const { isEditing, editPanel, uid, meta } = dashboard.useState();
const { isEditing, editPanel, uid, meta, editable } = dashboard.useState();
const { isPlaying } = playlistSrv.useState();
if (editPanel) {
@@ -201,13 +202,17 @@ function DashboardControlActions({ dashboard }: { dashboard: DashboardScene }) {
const canEditDashboard = dashboard.canEditDashboard();
const hasUid = Boolean(uid);
const isSnapshot = Boolean(meta.isSnapshot);
const isEditable = Boolean(editable);
const showShareButton = hasUid && !isSnapshot && !isPlaying;
return (
<>
{showShareButton && <ShareDashboardButton dashboard={dashboard} />}
{isEditing && <SaveDashboard dashboard={dashboard} />}
{!isPlaying && canEditDashboard && <EditDashboardSwitch dashboard={dashboard} />}
{!isPlaying && canEditDashboard && isEditable && <EditDashboardSwitch dashboard={dashboard} />}
{!isPlaying && canEditDashboard && !isEditable && !isEditing && (
<MakeDashboardEditableButton dashboard={dashboard} />
)}
{isPlaying && (
<Button
variant="secondary"
@@ -17,7 +17,6 @@ export const MakeDashboardEditableButton = ({ dashboard }: ToolbarActionProps) =
}}
tooltip={t('dashboard.toolbar.new.enter-edit-mode.tooltip', 'This dashboard was marked as read only')}
variant="secondary"
size="sm"
data-testid={selectors.components.NavToolbar.editDashboard.editButton}
>
<Trans i18nKey="dashboard.toolbar.new.enter-edit-mode.label">Make editable</Trans>
@@ -108,7 +108,8 @@ export function createRowsFromPanels(oldPanels: PanelModel[]): RowsLayoutManager
rowItems.push(
new RowItem({
title: '',
collapse: panel.collapsed,
// Hidden header rows must stay expanded; collapsing them would hide the panels entirely.
collapse: false,
layout: new DefaultGridLayoutManager({
grid: new SceneGridLayout({
children: currentRowPanels,
@@ -0,0 +1,95 @@
import { config } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv';
import { ManagerKind } from 'app/features/apiserver/types';
import { AccessControlAction } from 'app/types/accessControl';
import { FolderDTO } from 'app/types/folders';
import { buildNavModel, getAlertingTabID } from './navModel';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
config: {
unifiedAlertingEnabled: true,
},
}));
jest.mock('app/core/services/context_srv', () => ({
contextSrv: {
hasPermission: jest.fn(),
},
}));
describe('buildNavModel', () => {
const mockFolder: FolderDTO = {
uid: 'test-folder-uid',
title: 'Test Folder',
url: '/dashboards/f/test-folder-uid',
id: 1,
created: '',
createdBy: '',
hasAcl: false,
updated: '',
updatedBy: '',
canSave: true,
canEdit: true,
canAdmin: true,
canDelete: true,
version: 0,
};
beforeEach(() => {
jest.clearAllMocks();
(contextSrv.hasPermission as jest.Mock).mockReturnValue(true);
config.unifiedAlertingEnabled = true;
});
describe('Alerts tab visibility', () => {
it('should show Alerts tab for regular (non-managed) folders when user has permissions', () => {
const navModel = buildNavModel(mockFolder);
const alertingTab = navModel.children?.find((child) => child.id === getAlertingTabID(mockFolder.uid));
expect(alertingTab).toBeDefined();
expect(alertingTab?.text).toContain('Alert rules');
});
it('should hide Alerts tab for Git-synced folders', () => {
const gitSyncedFolder: FolderDTO = {
...mockFolder,
managedBy: ManagerKind.Repo,
};
const navModel = buildNavModel(gitSyncedFolder);
const alertingTab = navModel.children?.find((child) => child.id === getAlertingTabID(mockFolder.uid));
expect(alertingTab).toBeUndefined();
});
it('should hide Alerts tab when user lacks AlertingRuleRead permission', () => {
(contextSrv.hasPermission as jest.Mock).mockReturnValue(false);
const navModel = buildNavModel(mockFolder);
const alertingTab = navModel.children?.find((child) => child.id === getAlertingTabID(mockFolder.uid));
expect(alertingTab).toBeUndefined();
expect(contextSrv.hasPermission).toHaveBeenCalledWith(AccessControlAction.AlertingRuleRead);
});
it('should hide Alerts tab when unified alerting is disabled', () => {
config.unifiedAlertingEnabled = false;
const navModel = buildNavModel(mockFolder);
const alertingTab = navModel.children?.find((child) => child.id === getAlertingTabID(mockFolder.uid));
expect(alertingTab).toBeUndefined();
});
it('should show Alerts tab for regular folders with all conditions met', () => {
const navModel = buildNavModel(mockFolder);
const alertingTab = navModel.children?.find((child) => child.id === getAlertingTabID(mockFolder.uid));
expect(alertingTab).toBeDefined();
expect(alertingTab?.icon).toBe('bell');
expect(alertingTab?.url).toBe(`${mockFolder.url}/alerting`);
});
});
});
@@ -52,7 +52,11 @@ export function buildNavModel(folder: FolderDTO | FolderParent, parentsArg?: Fol
});
}
if (contextSrv.hasPermission(AccessControlAction.AlertingRuleRead) && config.unifiedAlertingEnabled) {
if (
!isProvisioned &&
contextSrv.hasPermission(AccessControlAction.AlertingRuleRead) &&
config.unifiedAlertingEnabled
) {
model.children!.push({
active: false,
icon: 'bell',
@@ -0,0 +1,29 @@
import { SelectableValue } from '@grafana/data';
import { RadioButtonGroup } from '@grafana/ui';
import { useDispatch } from '../../hooks/useStatelessReducer';
import { EditorType } from '../../types';
import { useQuery } from './ElasticsearchQueryContext';
import { changeEditorTypeAndResetQuery } from './state';
const BASE_OPTIONS: Array<SelectableValue<EditorType>> = [
{ value: 'builder', label: 'Builder' },
{ value: 'code', label: 'Code' },
];
export const EditorTypeSelector = () => {
const query = useQuery();
const dispatch = useDispatch();
// Default to 'builder' if editorType is empty
const editorType: EditorType = query.editorType === 'code' ? 'code' : 'builder';
const onChange = (newEditorType: EditorType) => {
dispatch(changeEditorTypeAndResetQuery(newEditorType));
};
return (
<RadioButtonGroup<EditorType> fullWidth={false} options={BASE_OPTIONS} value={editorType} onChange={onChange} />
);
};
@@ -8,7 +8,7 @@ import { combineReducers, useStatelessReducer, DispatchContext } from '../../hoo
import { createReducer as createBucketAggsReducer } from './BucketAggregationsEditor/state/reducer';
import { reducer as metricsReducer } from './MetricAggregationsEditor/state/reducer';
import { aliasPatternReducer, queryReducer, initQuery } from './state';
import { aliasPatternReducer, queryReducer, rawDSLQueryReducer, editorTypeReducer, initQuery } from './state';
const DatasourceContext = createContext<ElasticDatasource | undefined>(undefined);
const QueryContext = createContext<ElasticsearchDataQuery | undefined>(undefined);
@@ -40,9 +40,13 @@ export const ElasticsearchProvider = ({
[onChange, onRunQuery]
);
const reducer = combineReducers<Pick<ElasticsearchDataQuery, 'query' | 'alias' | 'metrics' | 'bucketAggs'>>({
const reducer = combineReducers<
Pick<ElasticsearchDataQuery, 'query' | 'rawDSLQuery' | 'alias' | 'editorType' | 'metrics' | 'bucketAggs'>
>({
query: queryReducer,
rawDSLQuery: rawDSLQueryReducer,
alias: aliasPatternReducer,
editorType: editorTypeReducer,
metrics: metricsReducer,
bucketAggs: createBucketAggsReducer(datasource.timeField),
});
@@ -11,6 +11,9 @@ export const changeMetricField = createAction<{ id: MetricAggregation['id']; fie
export const changeMetricType = createAction<{ id: MetricAggregation['id']; type: MetricAggregation['type'] }>(
'@metrics/change_type'
);
export const changeEditorType = createAction<{ id: MetricAggregation['id']; type: MetricAggregation['type'] }>(
'@metrics/change_type'
);
export const changeMetricAttribute = createAction<{ metric: MetricAggregation; attribute: string; newValue: unknown }>(
'@metrics/change_attr'
);
@@ -0,0 +1,108 @@
import { css } from '@emotion/css';
import { useCallback, useRef } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { CodeEditor, Monaco, CodeEditorMonacoOptions, monacoTypes, useStyles2, Button, Stack, Box } from '@grafana/ui';
interface Props {
value?: string;
onChange: (value: string) => void;
onRunQuery: () => void;
}
export function RawQueryEditor({ value, onChange, onRunQuery }: Props) {
const styles = useStyles2(getStyles);
const editorRef = useRef<monacoTypes.editor.IStandaloneCodeEditor | null>(null);
const handleEditorDidMount = useCallback(
(editor: monacoTypes.editor.IStandaloneCodeEditor, monaco: Monaco) => {
editorRef.current = editor;
// Add keyboard shortcut for running query (Ctrl/Cmd+Enter)
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => {
onRunQuery();
});
},
[onRunQuery]
);
const handleFormat = useCallback(() => {
if (editorRef.current) {
editorRef.current.getAction('editor.action.formatDocument')?.run();
}
}, []);
const handleQueryChange = useCallback(
(newValue: string) => {
if (!newValue) {
return;
}
onChange(newValue);
},
[onChange]
);
const monacoOptions: CodeEditorMonacoOptions = {
fontSize: 14,
lineNumbers: 'on',
minimap: { enabled: false },
scrollBeyondLastLine: false,
wordWrap: 'on',
automaticLayout: true,
fixedOverflowWidgets: true,
folding: true,
renderLineHighlight: 'all',
suggest: {
showProperties: true,
showMethods: true,
showKeywords: true,
},
quickSuggestions: {
other: true,
strings: true,
},
};
return (
<Box>
<div className={styles.header}>
<Stack gap={1}>
<Button
size="sm"
variant="secondary"
icon="brackets-curly"
onClick={handleFormat}
tooltip="Format query (Shift+Alt+F)"
>
Format
</Button>
<Button size="sm" variant="primary" icon="play" onClick={onRunQuery} tooltip="Run query (Ctrl/Cmd+Enter)">
Run
</Button>
</Stack>
</div>
<CodeEditor
value={value ?? ''}
language="json"
height={200}
width="100%"
onBlur={handleQueryChange}
monacoOptions={monacoOptions}
onEditorDidMount={handleEditorDidMount}
/>
</Box>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
container: css({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
}),
header: css({
display: 'flex',
justifyContent: 'flex-end',
padding: theme.spacing(0.5, 0),
}),
});
@@ -3,6 +3,7 @@ import { useEffect, useId, useState } from 'react';
import { SemVer } from 'semver';
import { getDefaultTimeRange, GrafanaTheme2, QueryEditorProps } from '@grafana/data';
import { config } from '@grafana/runtime';
import { Alert, InlineField, InlineLabel, Input, QueryField, useStyles2 } from '@grafana/ui';
import { ElasticsearchDataQuery } from '../../dataquery.gen';
@@ -13,11 +14,13 @@ import { ElasticsearchOptions } from '../../types';
import { isSupportedVersion, isTimeSeriesQuery, unsupportedVersionMessage } from '../../utils';
import { BucketAggregationsEditor } from './BucketAggregationsEditor';
import { EditorTypeSelector } from './EditorTypeSelector';
import { ElasticsearchProvider } from './ElasticsearchQueryContext';
import { MetricAggregationsEditor } from './MetricAggregationsEditor';
import { metricAggregationConfig } from './MetricAggregationsEditor/utils';
import { QueryTypeSelector } from './QueryTypeSelector';
import { changeAliasPattern, changeQuery } from './state';
import { RawQueryEditor } from './RawQueryEditor';
import { changeAliasPattern, changeQuery, changeRawDSLQuery } from './state';
export type ElasticQueryEditorProps = QueryEditorProps<ElasticDatasource, ElasticsearchDataQuery, ElasticsearchOptions>;
@@ -59,7 +62,7 @@ export const QueryEditor = ({ query, onChange, onRunQuery, datasource, range }:
range={range || getDefaultTimeRange()}
>
{showUnsupportedMessage && <Alert title={unsupportedVersionMessage} />}
<QueryEditorForm value={query} />
<QueryEditorForm value={query} onRunQuery={onRunQuery} />
</ElasticsearchProvider>
);
};
@@ -88,7 +91,7 @@ export const ElasticSearchQueryField = ({ value, onChange }: { value?: string; o
);
};
const QueryEditorForm = ({ value }: Props) => {
const QueryEditorForm = ({ value, onRunQuery }: Props & { onRunQuery: () => void }) => {
const dispatch = useDispatch();
const nextId = useNextId();
const inputId = useId();
@@ -96,6 +99,9 @@ const QueryEditorForm = ({ value }: Props) => {
const isTimeSeries = isTimeSeriesQuery(value);
const isCodeEditor = value.editorType === 'code';
const rawDSLFeatureEnabled = config.featureToggles.elasticsearchRawDSLQuery;
const showBucketAggregationsEditor = value.metrics?.every(
(metric) => metricAggregationConfig[metric.type].impliedQueryType === 'metrics'
);
@@ -108,29 +114,50 @@ const QueryEditorForm = ({ value }: Props) => {
<QueryTypeSelector />
</div>
</div>
<div className={styles.root}>
<InlineLabel width={17}>Lucene Query</InlineLabel>
<ElasticSearchQueryField onChange={(query) => dispatch(changeQuery(query))} value={value?.query} />
{rawDSLFeatureEnabled && (
<div className={styles.root}>
<InlineLabel width={17}>Editor type</InlineLabel>
<div className={styles.queryItem}>
<EditorTypeSelector />
</div>
</div>
)}
{isTimeSeries && (
<InlineField
label="Alias"
labelWidth={15}
tooltip="Aliasing only works for timeseries queries (when the last group is 'Date Histogram'). For all other query types this field is ignored."
htmlFor={inputId}
>
<Input
id={inputId}
placeholder="Alias Pattern"
onBlur={(e) => dispatch(changeAliasPattern(e.currentTarget.value))}
defaultValue={value.alias}
/>
</InlineField>
)}
</div>
{isCodeEditor && rawDSLFeatureEnabled && (
<RawQueryEditor
value={value.rawDSLQuery}
onChange={(rawDSLQuery) => dispatch(changeRawDSLQuery(rawDSLQuery))}
onRunQuery={onRunQuery}
/>
)}
<MetricAggregationsEditor nextId={nextId} />
{showBucketAggregationsEditor && <BucketAggregationsEditor nextId={nextId} />}
{!isCodeEditor && (
<>
<div className={styles.root}>
<InlineLabel width={17}>Lucene Query</InlineLabel>
<ElasticSearchQueryField onChange={(query) => dispatch(changeQuery(query))} value={value?.query} />
{isTimeSeries && (
<InlineField
label="Alias"
labelWidth={15}
tooltip="Aliasing only works for timeseries queries (when the last group is 'Date Histogram'). For all other query types this field is ignored."
htmlFor={inputId}
>
<Input
id={inputId}
placeholder="Alias Pattern"
onBlur={(e) => dispatch(changeAliasPattern(e.currentTarget.value))}
defaultValue={value.alias}
/>
</InlineField>
)}
</div>
<MetricAggregationsEditor nextId={nextId} />
{showBucketAggregationsEditor && <BucketAggregationsEditor nextId={nextId} />}
</>
)}
</>
);
};
@@ -11,13 +11,25 @@ export const initQuery = createAction<QueryType | undefined>('init');
export const changeQuery = createAction<ElasticsearchDataQuery['query']>('change_query');
export const changeRawDSLQuery = createAction<ElasticsearchDataQuery['rawDSLQuery']>('change_raw_dsl_query');
export const changeAliasPattern = createAction<ElasticsearchDataQuery['alias']>('change_alias_pattern');
export const changeEditorType = createAction<ElasticsearchDataQuery['editorType']>('change_editor_type');
export const changeEditorTypeAndResetQuery = createAction<ElasticsearchDataQuery['editorType']>(
'change_editor_type_and_reset_query'
);
export const queryReducer = (prevQuery: ElasticsearchDataQuery['query'], action: Action) => {
if (changeQuery.match(action)) {
return action.payload;
}
if (changeEditorTypeAndResetQuery.match(action)) {
return '';
}
if (initQuery.match(action)) {
return prevQuery || '';
}
@@ -25,6 +37,22 @@ export const queryReducer = (prevQuery: ElasticsearchDataQuery['query'], action:
return prevQuery;
};
export const rawDSLQueryReducer = (prevRawDSLQuery: ElasticsearchDataQuery['rawDSLQuery'], action: Action) => {
if (changeRawDSLQuery.match(action)) {
return action.payload;
}
if (changeEditorTypeAndResetQuery.match(action)) {
return '';
}
if (initQuery.match(action)) {
return prevRawDSLQuery || '';
}
return prevRawDSLQuery;
};
export const aliasPatternReducer = (prevAliasPattern: ElasticsearchDataQuery['alias'], action: Action) => {
if (changeAliasPattern.match(action)) {
return action.payload;
@@ -36,3 +64,19 @@ export const aliasPatternReducer = (prevAliasPattern: ElasticsearchDataQuery['al
return prevAliasPattern;
};
export const editorTypeReducer = (prevEditorType: ElasticsearchDataQuery['editorType'], action: Action) => {
if (changeEditorType.match(action)) {
return action.payload;
}
if (changeEditorTypeAndResetQuery.match(action)) {
return action.payload;
}
if (initQuery.match(action)) {
return prevEditorType || 'builder';
}
return prevEditorType;
};
@@ -31,8 +31,12 @@ composableKinds: DataQuery: {
alias?: string
// Lucene query
query?: string
// Raw DSL query
rawDSLQuery?: string
// Name of time field
timeField?: string
// Editor type
editorType?: string
// List of bucket aggregations
bucketAggs?: [...#BucketAggregation]
// List of metric aggregations
@@ -126,7 +130,7 @@ composableKinds: DataQuery: {
precision?: string
} @cuetsy(kind="interface")
#PipelineMetricAggregationType: "moving_avg" | "moving_fn" | "derivative" | "serial_diff" | "cumulative_sum" | "bucket_script" @cuetsy(kind="type")
#PipelineMetricAggregationType: "moving_avg" | "moving_fn" | "derivative" | "serial_diff" | "cumulative_sum" | "bucket_script" @cuetsy(kind="type")
#MetricAggregationType: "count" | "avg" | "sum" | "min" | "max" | "extended_stats" | "percentiles" | "cardinality" | "raw_document" | "raw_data" | "logs" | "rate" | "top_metrics" | #PipelineMetricAggregationType @cuetsy(kind="type")
#BaseMetricAggregation: {
@@ -396,7 +400,7 @@ composableKinds: DataQuery: {
}
} @cuetsy(kind="interface")
#PipelineMetricAggregation: #MovingAverage | #Derivative | #CumulativeSum | #BucketScript @cuetsy(kind="type")
#PipelineMetricAggregation: #MovingAverage | #Derivative | #CumulativeSum | #BucketScript @cuetsy(kind="type")
#MetricAggregationWithSettings: #BucketScript | #CumulativeSum | #Derivative | #SerialDiff | #RawData | #RawDocument | #UniqueCount | #Percentiles | #ExtendedStats | #Min | #Max | #Sum | #Average | #MovingAverage | #MovingFunction | #Logs | #Rate | #TopMetrics @cuetsy(kind="type")
}
}]
@@ -385,6 +385,10 @@ export interface ElasticsearchDataQuery extends common.DataQuery {
* List of bucket aggregations
*/
bucketAggs?: Array<BucketAggregation>;
/**
* Editor type
*/
editorType?: string;
/**
* List of metric aggregations
*/
@@ -393,6 +397,10 @@ export interface ElasticsearchDataQuery extends common.DataQuery {
* Lucene query
*/
query?: string;
/**
* Raw DSL query
*/
rawDSLQuery?: string;
/**
* Name of time field
*/
@@ -67,6 +67,7 @@ export interface ElasticsearchOptions extends DataSourceJsonData {
}
export type QueryType = 'metrics' | 'logs' | 'raw_data' | 'raw_document';
export type EditorType = 'code' | 'builder';
interface MetricConfiguration<T extends MetricAggregationType> {
label: string;