Compare commits

..

34 Commits

Author SHA1 Message Date
grambbledook 2d3aed92e4 Change the doc comment in the FeatureFlag type 2026-01-12 16:49:33 +01:00
grambbledook 33148b19e4 add non-boolean types top the doc in default.ini 2026-01-12 16:38:36 +01:00
grambbledook 5b898a3d84 add openfeature sdk to goleak ignore in testutil 2026-01-12 16:30:41 +01:00
grambbledook 60ea788f25 fix linter 2026-01-12 16:30:41 +01:00
grambbledook 26a31d65ea make test names a bit more precise 2026-01-12 16:30:41 +01:00
grambbledook eb67e05029 minor refactoring to make api a bit easier to debug 2026-01-12 16:30:41 +01:00
grambbledook 0def06e393 handle enterprise cases 2026-01-12 16:30:41 +01:00
grambbledook ff570b2427 add test cases for enterprise 2026-01-12 16:30:41 +01:00
grambbledook 9358bbe040 addressing review comments 2026-01-12 16:30:41 +01:00
grambbledook 3d2a176847 fix rebase issues 2026-01-12 16:30:41 +01:00
Denis Vodopianov 795ffb8126 Update pkg/setting/setting_feature_toggles.go
Co-authored-by: Dave Henderson <dave.henderson@grafana.com>
2026-01-12 16:30:41 +01:00
grambbledook 78c20c0cb9 address golint issues 2026-01-12 16:30:41 +01:00
grambbledook 959e6187a1 update tests according to recent changes 2026-01-12 16:30:41 +01:00
grambbledook bc164a2c4f add ff parsing tests to check if types are handled correctly 2026-01-12 16:30:41 +01:00
grambbledook f52a6bf88e more tests added 2026-01-12 16:30:41 +01:00
grambbledook 9228b8f0a4 add new thiongs 2026-01-12 16:30:41 +01:00
grambbledook 5153b5dad1 revert: the rest 2026-01-12 16:30:41 +01:00
grambbledook a23ce17a81 the rest 2026-01-12 16:30:41 +01:00
grambbledook 77d0a60ef1 minor refactoring 2026-01-12 16:30:41 +01:00
grambbledook 640e72bb2f finialise the static provider 2026-01-12 16:30:41 +01:00
grambbledook 5c070951ef add support of integerts 2026-01-12 16:30:40 +01:00
grambbledook f3fd2676ca initial commit 2026-01-12 16:30:40 +01:00
Haris Rozajac 0d1ec94548 Dashboard Schema V2: Activate dashboard ds queries (#116085)
* support dashboard ds

* add test for getPanelDataSource
2026-01-12 08:15:10 -07:00
Jack Westbrook 23a51ec9c5 CI: Fix frontend package validation (#116104)
* ci(frontend-lint): add frontend package change detection and add validate packed packages lint step

* ci(change-detection): add validate-npm-packages.sh to frontend-packages list

* ci(gh-workflows): add actions globs to frontend-packages detection

* ci(gh-workflows): fix typo - > _

* ci(frontend-lint): add missing needs

* chore(i18n): fix publint erroring for custom condition pointing to .cjs file

* ci(validate-npm-packages): make profile node16 default

* chore(validate-npm-packages): remove shellcheck disable comment
2026-01-12 16:08:32 +01:00
Dafydd 51dcdd3499 Datasources: Experimental API group names use full plugin IDs (#112961) 2026-01-12 15:01:40 +00:00
Larissa Wandzura 880bc23c85 Docs: Added a troubleshooting guide for MSSQL, plus some updates (#116088)
* added troubleshooting guide

* cleaned up the intro doc

* cleaned up the before you begin section in the configure doc

* changed a note to a tip

* changed to troubleshooting for cohesion

* final edits

* minor clean up item

* added note about Kerberos not being supported in Cloud

* punctuation fixes
2026-01-12 14:57:45 +00:00
Yunwen Zheng 6dc604c2ea RecentlyViewedDashboards: Add instrumentations (#116036)
RecentlyViewedDashboards: Add instrumentation
2026-01-12 09:56:15 -05:00
Matias Chomicki 77c500dc01 logsExploreTableDefaultVisualization: remove feature flag (#116127) 2026-01-12 14:46:47 +00:00
Kristina Demeshchik bec4d225b3 FieldConfig: Fix multiple value mappings in field overrides being overwritten (#116027)
* multiple value mappingg overrides

* add comment for clarity

* remove extra check
2026-01-12 09:29:16 -05:00
Ivana Huckova b91ca14f48 Icons: Add brain icon (#116023)
* Icons: Add brain icon

* lint

* Add brain to cached icons
2026-01-12 13:38:18 +01:00
Matias Chomicki 2aedbdb76f processing: support duplicated keys when parsing json logs (#116116)
* processing: support duplicated keys when parsing json logs

* Add regression test

* prettier
2026-01-12 13:37:17 +01:00
Rafael Bortolon Paulovic 0d7f46c08a chore(unified): remove unifiedStorageSearch feature toggle (#116109) 2026-01-12 13:22:48 +01:00
Ryan McKinley 1b52718c23 Search: include panel titles and types in index (#115742) 2026-01-12 13:21:03 +01:00
Naimesh Patel e61e406440 Explore: Add keyboard shortcut to run queries (#111675) (#115811)
* Explore: add keyboard shortcut to run queries (#111675)

* Update mock

* Fix linting

---------

Co-authored-by: Piotr Jamróz <pm.jamroz@gmail.com>
2026-01-12 13:19:47 +01:00
112 changed files with 3426 additions and 2348 deletions
@@ -14,6 +14,9 @@ outputs:
frontend:
description: Whether the frontend or self has changed in any way
value: ${{ steps.changed-files.outputs.frontend_any_changed || 'true' }}
frontend-packages:
description: Whether any frontend packages have changed
value: ${{ steps.changed-files.outputs.frontend_packages_any_changed || 'true' }}
e2e:
description: Whether the e2e tests or self have changed in any way
value: ${{ steps.changed-files.outputs.e2e_any_changed == 'true' ||
@@ -97,6 +100,12 @@ runs:
- '.yarn/**'
- 'apps/dashboard/pkg/migration/**'
- '${{ inputs.self }}'
frontend_packages:
- '.github/actions/checkout/**'
- '.github/actions/change-detection/**'
- 'packages/**'
- './scripts/validate-npm-packages.sh'
- '${{ inputs.self }}'
e2e:
- 'e2e/**'
- 'e2e-playwright/**'
@@ -153,6 +162,8 @@ runs:
echo " --> ${{ steps.changed-files.outputs.backend_all_changed_files }}"
echo "Frontend: ${{ steps.changed-files.outputs.frontend_any_changed || 'true' }}"
echo " --> ${{ steps.changed-files.outputs.frontend_all_changed_files }}"
echo "Frontend packages: ${{ steps.changed-files.outputs.frontend_packages_any_changed || 'true' }}"
echo " --> ${{ steps.changed-files.outputs.frontend_packages_all_changed_files }}"
echo "E2E: ${{ steps.changed-files.outputs.e2e_any_changed || 'true' }}"
echo " --> ${{ steps.changed-files.outputs.e2e_all_changed_files }}"
echo " --> ${{ steps.changed-files.outputs.backend_all_changed_files }}"
+2 -2
View File
@@ -4,8 +4,8 @@ description: Sets up a node.js environment with presets for the Grafana reposito
runs:
using: "composite"
steps:
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'yarn'
cache-dependency-path: 'yarn.lock'
cache-dependency-path: 'yarn.lock'
+36 -30
View File
@@ -17,6 +17,7 @@ jobs:
outputs:
changed: ${{ steps.detect-changes.outputs.frontend }}
prettier: ${{ steps.detect-changes.outputs.frontend == 'true' || steps.detect-changes.outputs.docs == 'true' }}
changed-frontend-packages: ${{ steps.detect-changes.outputs.frontend-packages }}
steps:
- uses: actions/checkout@v5
with:
@@ -42,11 +43,8 @@ jobs:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'yarn'
cache-dependency-path: 'yarn.lock'
- name: Setup Node
uses: ./.github/actions/setup-node
- run: yarn install --immutable --check-cache
- run: yarn run prettier:check
- run: yarn run lint
@@ -63,11 +61,8 @@ jobs:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'yarn'
cache-dependency-path: 'yarn.lock'
- name: Setup Node
uses: ./.github/actions/setup-node
- name: Setup Enterprise
uses: ./.github/actions/setup-enterprise
with:
@@ -89,11 +84,8 @@ jobs:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'yarn'
cache-dependency-path: 'yarn.lock'
- name: Setup Node
uses: ./.github/actions/setup-node
- run: yarn install --immutable --check-cache
- run: yarn run typecheck
lint-frontend-typecheck-enterprise:
@@ -109,11 +101,8 @@ jobs:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'yarn'
cache-dependency-path: 'yarn.lock'
- name: Setup Node
uses: ./.github/actions/setup-node
- name: Setup Enterprise
uses: ./.github/actions/setup-enterprise
with:
@@ -133,11 +122,8 @@ jobs:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'yarn'
cache-dependency-path: 'yarn.lock'
- name: Setup Node
uses: ./.github/actions/setup-node
- run: yarn install --immutable --check-cache
- name: Generate API clients
run: |
@@ -164,11 +150,8 @@ jobs:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: 'yarn'
cache-dependency-path: 'yarn.lock'
- name: Setup Node
uses: ./.github/actions/setup-node
- name: Setup Enterprise
uses: ./.github/actions/setup-enterprise
with:
@@ -187,3 +170,26 @@ jobs:
echo "${uncommited_error_message}"
exit 1
fi
lint-frontend-packed-packages:
needs: detect-changes
permissions:
contents: read
id-token: write
if: github.event_name == 'pull_request' && needs.detect-changes.outputs.changed-frontend-packages == 'true'
name: Verify packed frontend packages
runs-on: ubuntu-latest
steps:
- name: Checkout build commit
uses: actions/checkout@v5
with:
persist-credentials: false
- name: Setup Node
uses: ./.github/actions/setup-node
- name: Install dependencies
run: yarn install --immutable
- name: Build and pack packages
run: |
yarn run packages:build
yarn run packages:pack
- name: Validate packages
run: ./scripts/validate-npm-packages.sh
@@ -852,6 +852,194 @@
}
}
}
},
"panel-7": {
"kind": "Panel",
"spec": {
"id": 7,
"title": "Single Dashboard DS Query",
"description": "Panel with a single -- Dashboard -- datasource query",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "datasource",
"spec": {
"panelId": 1,
"withTransforms": true
}
},
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "stat",
"spec": {
"pluginVersion": "12.1.0-pre",
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{
"value": 0,
"color": "green"
}
]
},
"color": {
"mode": "thresholds"
}
},
"overrides": []
}
}
}
}
},
"panel-8": {
"kind": "Panel",
"spec": {
"id": 8,
"title": "Multiple Dashboard DS Queries",
"description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "datasource",
"spec": {
"panelId": 1,
"withTransforms": true
}
},
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"refId": "A",
"hidden": false
}
},
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "datasource",
"spec": {
"panelId": 2,
"withTransforms": true
}
},
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"refId": "B",
"hidden": false
}
},
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "datasource",
"spec": {
"panelId": 3,
"withTransforms": true
}
},
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"refId": "C",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "stat",
"spec": {
"pluginVersion": "12.1.0-pre",
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{
"value": 0,
"color": "green"
}
]
},
"color": {
"mode": "thresholds"
}
},
"overrides": []
}
}
}
}
}
},
"layout": {
@@ -914,6 +1102,24 @@
"name": "panel-6"
}
}
},
{
"kind": "AutoGridLayoutItem",
"spec": {
"element": {
"kind": "ElementReference",
"name": "panel-7"
}
}
},
{
"kind": "AutoGridLayoutItem",
"spec": {
"element": {
"kind": "ElementReference",
"name": "panel-8"
}
}
}
]
}
@@ -879,6 +879,200 @@
}
}
}
},
"panel-7": {
"kind": "Panel",
"spec": {
"id": 7,
"title": "Single Dashboard DS Query",
"description": "Panel with a single -- Dashboard -- datasource query",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "DataQuery",
"group": "datasource",
"version": "v0",
"datasource": {
"name": "-- Dashboard --"
},
"spec": {
"panelId": 1,
"withTransforms": true
}
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "VizConfig",
"group": "stat",
"version": "12.1.0-pre",
"spec": {
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{
"value": 0,
"color": "green"
}
]
},
"color": {
"mode": "thresholds"
}
},
"overrides": []
}
}
}
}
},
"panel-8": {
"kind": "Panel",
"spec": {
"id": 8,
"title": "Multiple Dashboard DS Queries",
"description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "DataQuery",
"group": "datasource",
"version": "v0",
"datasource": {
"name": "-- Dashboard --"
},
"spec": {
"panelId": 1,
"withTransforms": true
}
},
"refId": "A",
"hidden": false
}
},
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "DataQuery",
"group": "datasource",
"version": "v0",
"datasource": {
"name": "-- Dashboard --"
},
"spec": {
"panelId": 2,
"withTransforms": true
}
},
"refId": "B",
"hidden": false
}
},
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "DataQuery",
"group": "datasource",
"version": "v0",
"datasource": {
"name": "-- Dashboard --"
},
"spec": {
"panelId": 3,
"withTransforms": true
}
},
"refId": "C",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "VizConfig",
"group": "stat",
"version": "12.1.0-pre",
"spec": {
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{
"value": 0,
"color": "green"
}
]
},
"color": {
"mode": "thresholds"
}
},
"overrides": []
}
}
}
}
}
},
"layout": {
@@ -973,6 +1167,32 @@
"name": "panel-6"
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
"x": 0,
"y": 6,
"width": 8,
"height": 3,
"element": {
"kind": "ElementReference",
"name": "panel-7"
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
"x": 8,
"y": 6,
"width": 8,
"height": 3,
"element": {
"kind": "ElementReference",
"name": "panel-8"
}
}
}
]
}
@@ -711,6 +711,146 @@
],
"title": "Mixed DS WITHOUT REFS",
"type": "timeseries"
},
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"description": "Panel with a single -- Dashboard -- datasource query",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
}
]
}
}
},
"gridPos": {
"h": 9,
"w": 8,
"x": 0,
"y": 18
},
"id": 7,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"panelId": 1,
"refId": "A",
"withTransforms": true
}
],
"title": "Single Dashboard DS Query",
"type": "stat"
},
{
"datasource": {
"type": "mixed",
"uid": "-- Mixed --"
},
"description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
}
]
}
}
},
"gridPos": {
"h": 9,
"w": 8,
"x": 8,
"y": 18
},
"id": 8,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"panelId": 1,
"refId": "A",
"withTransforms": true
},
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"panelId": 2,
"refId": "B",
"withTransforms": true
},
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"panelId": 3,
"refId": "C",
"withTransforms": true
}
],
"title": "Multiple Dashboard DS Queries",
"type": "stat"
}
],
"preload": false,
@@ -711,6 +711,146 @@
],
"title": "Mixed DS WITHOUT REFS",
"type": "timeseries"
},
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"description": "Panel with a single -- Dashboard -- datasource query",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
}
]
}
}
},
"gridPos": {
"h": 9,
"w": 8,
"x": 0,
"y": 18
},
"id": 7,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"panelId": 1,
"refId": "A",
"withTransforms": true
}
],
"title": "Single Dashboard DS Query",
"type": "stat"
},
{
"datasource": {
"type": "mixed",
"uid": "-- Mixed --"
},
"description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
}
]
}
}
},
"gridPos": {
"h": 9,
"w": 8,
"x": 8,
"y": 18
},
"id": 8,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"panelId": 1,
"refId": "A",
"withTransforms": true
},
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"panelId": 2,
"refId": "B",
"withTransforms": true
},
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"panelId": 3,
"refId": "C",
"withTransforms": true
}
],
"title": "Multiple Dashboard DS Queries",
"type": "stat"
}
],
"preload": false,
@@ -879,6 +879,200 @@
}
}
}
},
"panel-7": {
"kind": "Panel",
"spec": {
"id": 7,
"title": "Single Dashboard DS Query",
"description": "Panel with a single -- Dashboard -- datasource query",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "DataQuery",
"group": "datasource",
"version": "v0",
"datasource": {
"name": "-- Dashboard --"
},
"spec": {
"panelId": 1,
"withTransforms": true
}
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "VizConfig",
"group": "stat",
"version": "12.1.0-pre",
"spec": {
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{
"value": 0,
"color": "green"
}
]
},
"color": {
"mode": "thresholds"
}
},
"overrides": []
}
}
}
}
},
"panel-8": {
"kind": "Panel",
"spec": {
"id": 8,
"title": "Multiple Dashboard DS Queries",
"description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "DataQuery",
"group": "datasource",
"version": "v0",
"datasource": {
"name": "-- Dashboard --"
},
"spec": {
"panelId": 1,
"withTransforms": true
}
},
"refId": "A",
"hidden": false
}
},
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "DataQuery",
"group": "datasource",
"version": "v0",
"datasource": {
"name": "-- Dashboard --"
},
"spec": {
"panelId": 2,
"withTransforms": true
}
},
"refId": "B",
"hidden": false
}
},
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "DataQuery",
"group": "datasource",
"version": "v0",
"datasource": {
"name": "-- Dashboard --"
},
"spec": {
"panelId": 3,
"withTransforms": true
}
},
"refId": "C",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "VizConfig",
"group": "stat",
"version": "12.1.0-pre",
"spec": {
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{
"value": 0,
"color": "green"
}
]
},
"color": {
"mode": "thresholds"
}
},
"overrides": []
}
}
}
}
}
},
"layout": {
@@ -941,6 +1135,24 @@
"name": "panel-6"
}
}
},
{
"kind": "AutoGridLayoutItem",
"spec": {
"element": {
"kind": "ElementReference",
"name": "panel-7"
}
}
},
{
"kind": "AutoGridLayoutItem",
"spec": {
"element": {
"kind": "ElementReference",
"name": "panel-8"
}
}
}
]
}
@@ -711,6 +711,146 @@
],
"title": "Mixed DS WITHOUT REFS",
"type": "timeseries"
},
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"description": "Panel with a single -- Dashboard -- datasource query",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
}
]
}
}
},
"gridPos": {
"h": 3,
"w": 8,
"x": 0,
"y": 6
},
"id": 7,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"panelId": 1,
"refId": "A",
"withTransforms": true
}
],
"title": "Single Dashboard DS Query",
"type": "stat"
},
{
"datasource": {
"type": "mixed",
"uid": "-- Mixed --"
},
"description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
}
]
}
}
},
"gridPos": {
"h": 3,
"w": 8,
"x": 8,
"y": 6
},
"id": 8,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"panelId": 1,
"refId": "A",
"withTransforms": true
},
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"panelId": 2,
"refId": "B",
"withTransforms": true
},
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"panelId": 3,
"refId": "C",
"withTransforms": true
}
],
"title": "Multiple Dashboard DS Queries",
"type": "stat"
}
],
"preload": false,
@@ -711,6 +711,146 @@
],
"title": "Mixed DS WITHOUT REFS",
"type": "timeseries"
},
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"description": "Panel with a single -- Dashboard -- datasource query",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
}
]
}
}
},
"gridPos": {
"h": 3,
"w": 8,
"x": 0,
"y": 6
},
"id": 7,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"panelId": 1,
"refId": "A",
"withTransforms": true
}
],
"title": "Single Dashboard DS Query",
"type": "stat"
},
{
"datasource": {
"type": "mixed",
"uid": "-- Mixed --"
},
"description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
}
]
}
}
},
"gridPos": {
"h": 3,
"w": 8,
"x": 8,
"y": 6
},
"id": 8,
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"panelId": 1,
"refId": "A",
"withTransforms": true
},
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"panelId": 2,
"refId": "B",
"withTransforms": true
},
{
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"panelId": 3,
"refId": "C",
"withTransforms": true
}
],
"title": "Multiple Dashboard DS Queries",
"type": "stat"
}
],
"preload": false,
@@ -852,6 +852,194 @@
}
}
}
},
"panel-7": {
"kind": "Panel",
"spec": {
"id": 7,
"title": "Single Dashboard DS Query",
"description": "Panel with a single -- Dashboard -- datasource query",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "datasource",
"spec": {
"panelId": 1,
"withTransforms": true
}
},
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "stat",
"spec": {
"pluginVersion": "12.1.0-pre",
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{
"value": 0,
"color": "green"
}
]
},
"color": {
"mode": "thresholds"
}
},
"overrides": []
}
}
}
}
},
"panel-8": {
"kind": "Panel",
"spec": {
"id": 8,
"title": "Multiple Dashboard DS Queries",
"description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "datasource",
"spec": {
"panelId": 1,
"withTransforms": true
}
},
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"refId": "A",
"hidden": false
}
},
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "datasource",
"spec": {
"panelId": 2,
"withTransforms": true
}
},
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"refId": "B",
"hidden": false
}
},
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "datasource",
"spec": {
"panelId": 3,
"withTransforms": true
}
},
"datasource": {
"type": "datasource",
"uid": "-- Dashboard --"
},
"refId": "C",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "stat",
"spec": {
"pluginVersion": "12.1.0-pre",
"options": {
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"percentChangeColorMode": "standard",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showPercentChange": false,
"textMode": "auto",
"wideLayout": true
},
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{
"value": 0,
"color": "green"
}
]
},
"color": {
"mode": "thresholds"
}
},
"overrides": []
}
}
}
}
}
},
"layout": {
@@ -946,6 +1134,32 @@
"name": "panel-6"
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
"x": 0,
"y": 6,
"width": 8,
"height": 3,
"element": {
"kind": "ElementReference",
"name": "panel-7"
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
"x": 8,
"y": 6,
"width": 8,
"height": 3,
"element": {
"kind": "ElementReference",
"name": "panel-8"
}
}
}
]
}
@@ -1195,16 +1195,36 @@ func getDataSourceForQuery(explicitDS *dashv2alpha1.DashboardDataSourceRef, quer
// getPanelDatasource determines the panel-level datasource for V1.
// Returns:
// - Mixed datasource reference if queries use different datasources
// - Mixed datasource reference if multiple queries use Dashboard datasource (they fetch from different panels)
// - Dashboard datasource reference if a single query uses Dashboard datasource
// - First query's datasource if all queries use the same datasource
// - nil if no queries exist
// Compares based on V2 input without runtime resolution:
// - If query has explicit datasource.uid → use that UID and type
// - Else → use query.Kind as type (empty UID)
func getPanelDatasource(queries []dashv2alpha1.DashboardPanelQueryKind) map[string]interface{} {
const sharedDashboardQuery = "-- Dashboard --"
if len(queries) == 0 {
return nil
}
// Count how many queries use Dashboard datasource
// Multiple dashboard queries need mixed mode because they fetch from different panels
// which may have different underlying datasources
dashboardDsQueryCount := 0
for _, query := range queries {
if query.Spec.Datasource != nil && query.Spec.Datasource.Uid != nil && *query.Spec.Datasource.Uid == sharedDashboardQuery {
dashboardDsQueryCount++
}
}
if dashboardDsQueryCount > 1 {
return map[string]interface{}{
"type": "mixed",
"uid": "-- Mixed --",
}
}
var firstUID, firstType string
var hasFirst bool
@@ -1239,6 +1259,16 @@ func getPanelDatasource(queries []dashv2alpha1.DashboardPanelQueryKind) map[stri
}
}
// Handle case when a single query uses Dashboard datasource.
// This is needed for the frontend to properly activate and fetch data from source panels.
// See DashboardDatasourceBehaviour.tsx for more details.
if firstUID == sharedDashboardQuery {
return map[string]interface{}{
"type": "datasource",
"uid": sharedDashboardQuery,
}
}
// Not mixed - return the first query's datasource so the panel has a datasource set.
// This is required because the frontend's legacy PanelModel.PanelQueryRunner.run uses panel.datasource
// to resolve the datasource, and if undefined, it falls back to the default datasource
+8 -1
View File
@@ -336,7 +336,7 @@ rudderstack_data_plane_url =
rudderstack_sdk_url =
# Rudderstack v3 SDK, optional, defaults to false. If set, Rudderstack v3 SDK will be used instead of v1
rudderstack_v3_sdk_url =
rudderstack_v3_sdk_url =
# Rudderstack Config url, optional, used by Rudderstack SDK to fetch source config
rudderstack_config_url =
@@ -2079,8 +2079,15 @@ enable =
# To enable features by default, set `Expression: "true"` in:
# https://github.com/grafana/grafana/blob/main/pkg/services/featuremgmt/registry.go
# The feature_toggles section now supports feature flags of different types,
# including boolean, string, integer, float, and structured values, following the OpenFeature specification.
# This feature is experimental and may change in future releases.
#
# feature1 = true
# feature2 = false
# feature3 = foobar
# feature4 = 1.5
# feature5 = { "foo": "bar" }
[feature_toggles.openfeature]
# This is EXPERIMENTAL. Please, do not use this section
+46 -3
View File
@@ -99,12 +99,27 @@ refs:
destination: /docs/grafana/<GRAFANA_VERSION>/administration/data-source-management/#query-and-resource-caching
- pattern: /docs/grafana-cloud/
destination: /docs/grafana/<GRAFANA_VERSION>/administration/data-source-management/#query-and-resource-caching
mssql-troubleshoot:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/mssql/troubleshooting/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/mssql/troubleshooting/
postgres:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/postgres/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/postgres/
mysql:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/mysql/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/mysql/
---
# Microsoft SQL Server (MSSQL) data source
Grafana ships with built-in support for Microsoft SQL Server (MSSQL).
You can query and visualize data from any Microsoft SQL Server 2005 or newer, including the Microsoft Azure SQL Database.
You can query and visualize data from any Microsoft SQL Server 2005 or newer, including Microsoft Azure SQL Database.
Use this data source to create dashboards, explore SQL data, and monitor MSSQL-based workloads in real time.
@@ -113,10 +128,33 @@ The following documentation helps you get started working with the Microsoft SQL
- [Configure the Microsoft SQL Server data source](ref:configure-mssql-data-source)
- [Microsoft SQL Server query editor](ref:mssql-query-editor)
- [Microsoft SQL Server template variables](ref:mssql-template-variables)
- [Troubleshoot Microsoft SQL Server data source issues](ref:mssql-troubleshoot)
## Get the most out of the data source
## Supported versions
After installing and configuring the Microsoft SQL Server data source, you can:
This data source supports the following Microsoft SQL Server versions:
- Microsoft SQL Server 2005 and newer
- Microsoft Azure SQL Database
- Azure SQL Managed Instance
Grafana recommends using the latest available service pack for your SQL Server version for optimal compatibility.
## Key capabilities
The Microsoft SQL Server data source supports:
- **Time series queries:** Visualize metrics over time using the built-in time grouping macros.
- **Table queries:** Display query results in table format for any valid SQL query.
- **Template variables:** Create dynamic dashboards with variable-driven queries.
- **Annotations:** Overlay events from SQL Server on your dashboard graphs.
- **Alerting:** Create alerts based on SQL Server query results.
- **Stored procedures:** Execute stored procedures and visualize results.
- **Macros:** Simplify queries with built-in macros for time filtering and grouping.
## Additional resources
After configuring the Microsoft SQL Server data source, you can:
- Create a wide variety of [visualizations](ref:visualizations)
- Configure and use [templates and variables](ref:variables)
@@ -124,3 +162,8 @@ After installing and configuring the Microsoft SQL Server data source, you can:
- Add [annotations](ref:annotate-visualizations)
- Set up [alerting](ref:alerting)
- Optimize performance with [query caching](ref:query-caching)
## Related data sources
- [PostgreSQL](ref:postgres) - For PostgreSQL databases.
- [MySQL](ref:mysql) - For MySQL and MariaDB databases.
@@ -89,6 +89,26 @@ refs:
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-access/configure-authentication/azuread/#enable-azure-ad-oauth-in-grafana
- pattern: /docs/grafana-cloud/
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-access/configure-authentication/azuread/#enable-azure-ad-oauth-in-grafana
mssql-query-editor:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/mssql/query-editor/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/mssql/query-editor/
mssql-template-variables:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/mssql/template-variables/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/mssql/template-variables/
alerting:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/alerting-and-irm/alerting/
mssql-troubleshoot:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/mssql/troubleshooting/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/mssql/troubleshooting/
---
# Configure the Microsoft SQL Server data source
@@ -97,13 +117,28 @@ This document provides instructions for configuring the Microsoft SQL Server dat
## Before you begin
- Grafana comes with a built-in MSSQL data source plugin, eliminating the need to install a plugin.
Before configuring the Microsoft SQL Server data source, ensure you have the following:
- You must have the `Organization administrator` role to configure the MSSQL data source. Organization administrators can also [configure the data source via YAML](#provision-the-data-source) with the Grafana provisioning system.
- **Grafana permissions:** You must have the `Organization administrator` role to configure data sources. Organization administrators can also [configure the data source via YAML](#provision-the-data-source) with the Grafana provisioning system.
- Familiarize yourself with your MSSQL security configuration and gather any necessary security certificates and client keys.
- **A running SQL Server instance:** Microsoft SQL Server 2005 or newer, Azure SQL Database, or Azure SQL Managed Instance.
- Verify that data from MSSQL is being written to your Grafana instance.
- **Network access:** Grafana must be able to reach your SQL Server. The default port is `1433`.
- **Authentication credentials:** Depending on your authentication method, you need one of:
- SQL Server login credentials (username and password).
- Windows/Kerberos credentials and configuration (not supported in Grafana Cloud).
- Azure Entra ID app registration or managed identity.
- **Security certificates:** If using encrypted connections, gather any necessary TLS/SSL certificates.
{{< admonition type="note" >}}
Grafana ships with a built-in Microsoft SQL Server data source plugin. No additional installation is required.
{{< /admonition >}}
{{< admonition type="tip" >}}
**Grafana Cloud users:** If your SQL Server is in a private network, you can configure [Private data source connect](ref:private-data-source-connect) to establish connectivity.
{{< /admonition >}}
## Add the MSSQL data source
@@ -382,3 +417,48 @@ datasources:
secureJsonData:
password: 'Password!'
```
### Configure with Terraform
You can configure the Microsoft SQL Server data source using [Terraform](https://www.terraform.io/) with the [Grafana Terraform provider](https://registry.terraform.io/providers/grafana/grafana/latest/docs).
For more information about provisioning resources with Terraform, refer to the [Grafana as code using Terraform](https://grafana.com/docs/grafana-cloud/developer-resources/infrastructure-as-code/terraform/) documentation.
#### Terraform example
The following example creates a basic Microsoft SQL Server data source:
```hcl
resource "grafana_data_source" "mssql" {
name = "MSSQL"
type = "mssql"
url = "localhost:1433"
user = "grafana"
json_data_encoded = jsonencode({
database = "grafana"
maxOpenConns = 100
maxIdleConns = 100
maxIdleConnsAuto = true
connMaxLifetime = 14400
connectionTimeout = 0
encrypt = "false"
})
secure_json_data_encoded = jsonencode({
password = "Password!"
})
}
```
For all available configuration options, refer to the [Grafana provider data source resource documentation](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/data_source).
## Next steps
After configuring your Microsoft SQL Server data source, you can:
- [Write queries](ref:mssql-query-editor) using the query editor to explore and visualize your data
- [Create template variables](ref:mssql-template-variables) to build dynamic, reusable dashboards
- [Add annotations](ref:annotate-visualizations) to overlay SQL Server events on your graphs
- [Set up alerting](ref:alerting) to create alert rules based on your SQL Server data
- [Troubleshoot issues](ref:mssql-troubleshoot) if you encounter problems with your data source
@@ -0,0 +1,333 @@
---
description: Troubleshoot common problems with the Microsoft SQL Server data source in Grafana
keywords:
- grafana
- MSSQL
- Microsoft
- SQL
- troubleshooting
- errors
labels:
products:
- cloud
- enterprise
- oss
menuTitle: Troubleshooting
title: Troubleshoot Microsoft SQL Server data source issues
weight: 400
refs:
configure-mssql-data-source:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/mssql/configure/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/mssql/configure/
mssql-query-editor:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/mssql/query-editor/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/mssql/query-editor/
private-data-source-connect:
- pattern: /docs/grafana/
destination: /docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/
---
# Troubleshoot Microsoft SQL Server data source issues
This document provides solutions to common issues you may encounter when configuring or using the Microsoft SQL Server (MSSQL) data source in Grafana.
## Connection errors
These errors occur when Grafana cannot establish or maintain a connection to the Microsoft SQL Server.
### Unable to connect to the server
**Error message:** "Unable to open tcp connection" or "dial tcp: connection refused"
**Cause:** Grafana cannot establish a network connection to the SQL Server.
**Solution:**
1. Verify that the SQL Server is running and accessible.
1. Check that the host and port are correct in the data source configuration. The default SQL Server port is `1433`.
1. Ensure there are no firewall rules blocking the connection between Grafana and SQL Server.
1. Verify that SQL Server is configured to allow remote connections.
1. For Grafana Cloud, ensure you have configured [Private data source connect](ref:private-data-source-connect) if your SQL Server instance is not publicly accessible.
### Connection timeout
**Error message:** "Connection timed out" or "I/O timeout"
**Cause:** The connection to SQL Server timed out before receiving a response.
**Solution:**
1. Check the network latency between Grafana and SQL Server.
1. Verify that SQL Server is not overloaded or experiencing performance issues.
1. Increase the **Connection timeout** setting in the data source configuration under **Additional settings**.
1. Check if any network devices (load balancers, proxies) are timing out the connection.
### Encryption-related connection failures
**Error message:** "TLS handshake failed" or "certificate verify failed"
**Cause:** There is a mismatch between the encryption settings in Grafana and what the SQL Server supports or requires.
**Solution:**
1. For older versions of SQL Server (2008, 2008R2), set the **Encrypt** option to **Disable** or **False** in the data source configuration.
1. Verify that the SQL Server has a valid SSL certificate if encryption is enabled.
1. Check that the certificate is trusted by the Grafana server.
1. Ensure you're using the latest available service pack for your SQL Server version for optimal compatibility.
### Named instance connection issues
**Error message:** "Cannot connect to named instance" or connection fails when using instance name
**Cause:** Grafana cannot resolve the SQL Server named instance.
**Solution:**
1. Use the format `hostname\instancename` or `hostname\instancename,port` in the **Host** field.
1. Verify that the SQL Server Browser service is running on the SQL Server machine.
1. If the Browser service is unavailable, specify the port number directly: `hostname,port`.
1. Check that UDP port 1434 is open if using the SQL Server Browser service.
## Authentication errors
These errors occur when there are issues with authentication credentials or permissions.
### Login failed for user
**Error message:** "Login failed for user 'username'" or "Authentication failed"
**Cause:** The authentication credentials are invalid or the user doesn't have permission to access the database.
**Solution:**
1. Verify that the username and password are correct.
1. Check that the user exists in SQL Server and is enabled.
1. Ensure the user has access to the specified database.
1. For Windows Authentication, verify that the credentials are in the correct format (`DOMAIN\User`).
1. Check that the SQL Server authentication mode allows the type of login you're using (SQL Server Authentication, Windows Authentication, or Mixed Mode).
### Access denied to database
**Error message:** "Cannot open database 'dbname' requested by the login"
**Cause:** The authenticated user doesn't have permission to access the specified database.
**Solution:**
1. Verify that the database name is correct in the data source configuration.
1. Ensure the user is mapped to the database with appropriate permissions.
1. Grant at least `SELECT` permission on the required tables:
```sql
USE [your_database]
GRANT SELECT ON dbo.YourTable TO [your_user]
```
1. Check that the user doesn't have any conflicting permissions from the public role.
### Windows Authentication (Kerberos) issues
**Error message:** "Kerberos authentication failed" or "Cannot initialize Kerberos"
**Cause:** Kerberos configuration is incorrect or incomplete.
**Solution:**
1. Verify that the Kerberos configuration file (`krb5.conf`) path is correct in the data source settings.
1. For keytab authentication, ensure the keytab file exists and is readable by Grafana.
1. Check that the realm and KDC settings are correct in the Kerberos configuration.
1. Verify DNS is correctly resolving the KDC servers.
1. Ensure the service principal name (SPN) is registered for the SQL Server instance.
{{< admonition type="note" >}}
Kerberos authentication is not supported in Grafana Cloud.
{{< /admonition >}}
### Azure Entra ID authentication errors
**Error message:** "AADSTS error codes" or "Azure AD authentication failed"
**Cause:** Azure Entra ID (formerly Azure AD) authentication is misconfigured.
**Solution:**
1. For **App Registration** authentication:
- Verify the tenant ID, client ID, and client secret are correct.
- Ensure the app registration has been added as a user in the Azure SQL database.
- Check that the client secret hasn't expired.
1. For **Managed Identity** authentication:
- Verify `managed_identity_enabled = true` is set in the Grafana server configuration.
- Ensure the managed identity has been added to the Azure SQL database.
- Confirm the Azure resource hosting Grafana has managed identity enabled.
1. For **Current User** authentication:
- Ensure `user_identity_enabled = true` is set in the Grafana server configuration.
- Verify the app registration is configured to issue both Access Tokens and ID Tokens.
- Check that the required API permissions are configured (`user_impersonation` for Azure SQL).
For detailed Azure authentication configuration, refer to [Configure the Microsoft SQL Server data source](ref:configure-mssql-data-source).
## Query errors
These errors occur when there are issues with query syntax or configuration.
### Time column not found or invalid
**Error message:** "Could not find time column" or time series visualization shows no data
**Cause:** The query doesn't return a properly formatted `time` column for time series visualization.
**Solution:**
1. Ensure your query includes a column named `time` when using the **Time series** format.
1. Use the `$__time()` macro to rename your date column: `$__time(your_date_column)`.
1. Verify the time column is of a valid SQL date/time type (`datetime`, `datetime2`, `date`) or contains Unix epoch values.
1. Ensure the result set is sorted by the time column using `ORDER BY`.
### Macro expansion errors
**Error message:** "Error parsing query" or macros appear unexpanded in the query
**Cause:** Grafana macros are being used incorrectly.
**Solution:**
1. Verify macro syntax: use `$__timeFilter(column)` not `$_timeFilter(column)`.
1. Macros don't work inside stored procedures—use explicit date parameters instead.
1. Check that the column name passed to macros exists in your table.
1. View the expanded query by clicking **Generated SQL** after running the query to debug macro expansion.
### Timezone and time shift issues
**Cause:** Time series data appears shifted or doesn't align with expected times.
**Solution:**
1. Store timestamps in UTC in your database to avoid timezone issues.
1. Time macros (`$__time`, `$__timeFilter`, etc.) always expand to UTC values.
1. If your timestamps are stored in local time, convert them to UTC in your query:
```sql
SELECT
your_datetime_column AT TIME ZONE 'Your Local Timezone' AT TIME ZONE 'UTC' AS time,
value
FROM your_table
```
1. Don't pass timezone parameters to time macros—they're not supported.
### Query returns too many rows
**Error message:** "Result set too large" or browser becomes unresponsive
**Cause:** The query returns more data than can be efficiently processed.
**Solution:**
1. Add time filters using `$__timeFilter(column)` to limit data to the dashboard time range.
1. Use aggregations (`AVG`, `SUM`, `COUNT`) with `GROUP BY` instead of returning raw rows.
1. Add a `TOP` clause to limit results: `SELECT TOP 1000 ...`.
1. Use the `$__timeGroup()` macro to aggregate data into time intervals.
### Stored procedure returns no data
**Cause:** Stored procedure output isn't being captured correctly.
**Solution:**
1. Ensure the stored procedure uses `SELECT` statements, not just variable assignments.
1. Remove `SET NOCOUNT ON` if present, or ensure it's followed by a `SELECT` statement.
1. Verify the stored procedure parameters are being passed correctly.
1. Test the stored procedure directly in SQL Server Management Studio with the same parameters.
For more information on using stored procedures, refer to the [query editor documentation](ref:mssql-query-editor).
## Performance issues
These issues relate to slow queries or high resource usage.
### Slow query execution
**Cause:** Queries take a long time to execute.
**Solution:**
1. Reduce the dashboard time range to limit data volume.
1. Add indexes to columns used in `WHERE` clauses and time filters.
1. Use aggregations instead of returning individual rows.
1. Increase the **Min time interval** setting to reduce the number of data points.
1. Review the query execution plan in SQL Server Management Studio to identify bottlenecks.
### Connection pool exhaustion
**Error message:** "Too many connections" or "Connection pool exhausted"
**Cause:** Too many concurrent connections to the database.
**Solution:**
1. Increase the **Max open** connection limit in the data source configuration.
1. Enable **Auto max idle** to automatically manage idle connections.
1. Reduce the number of panels querying the same data source simultaneously.
1. Check for long-running queries that might be holding connections.
## Other common issues
The following issues don't produce specific error messages but are commonly encountered.
### System databases appear in queries
**Cause:** Queries accidentally access system databases.
**Solution:**
1. The query editor automatically excludes `tempdb`, `model`, `msdb`, and `master` from the database dropdown.
1. Always specify the database in your data source configuration to restrict access.
1. Ensure the database user only has permissions on the intended database.
### Template variable queries fail
**Cause:** Variable queries return unexpected results or errors.
**Solution:**
1. Verify the variable query syntax is valid SQL that returns a single column.
1. Check that the data source connection is working.
1. Ensure the user has permission to access the tables referenced in the variable query.
1. Test the query in the query editor before using it as a variable query.
### Data appears incorrect or misaligned
**Cause:** Data formatting or type conversion issues.
**Solution:**
1. Use explicit column aliases to ensure consistent naming: `SELECT value AS metric`.
1. Verify numeric columns are actually numeric types, not strings.
1. Check for `NULL` values that might affect aggregations.
1. Use the `FILL` option in `$__timeGroup()` macro to handle missing data points.
## Get additional help
If you continue to experience issues after following this troubleshooting guide:
1. Check the [Grafana community forums](https://community.grafana.com/) for similar issues.
1. Review the [Grafana GitHub issues](https://github.com/grafana/grafana/issues) for known bugs.
1. Enable debug logging in Grafana to capture detailed error information.
1. Check SQL Server logs for additional error details.
1. Contact Grafana Support if you're an Enterprise or Cloud customer.
When reporting issues, include:
- Grafana version
- SQL Server version
- Error messages (redact sensitive information)
- Steps to reproduce
- Relevant query examples (redact sensitive data)
+5
View File
@@ -1801,6 +1801,11 @@
"count": 1
}
},
"public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataPane.tsx": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
}
},
"public/app/features/dashboard-scene/panel-edit/PanelOptionsPane.test.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
@@ -246,6 +246,8 @@ const injectedRtkApi = api
facetLimit: queryArg.facetLimit,
tags: queryArg.tags,
libraryPanel: queryArg.libraryPanel,
panelType: queryArg.panelType,
dataSourceType: queryArg.dataSourceType,
permission: queryArg.permission,
sort: queryArg.sort,
limit: queryArg.limit,
@@ -674,6 +676,10 @@ export type SearchDashboardsAndFoldersApiArg = {
tags?: string[];
/** find dashboards that reference a given libraryPanel */
libraryPanel?: string;
/** find dashboards using panels of a given plugin type */
panelType?: string;
/** find dashboards using datasources of a given plugin type */
dataSourceType?: string;
/** permission needed for the resource (view, edit, admin) */
permission?: 'view' | 'edit' | 'admin';
/** sortable field */
@@ -9,6 +9,7 @@ import { FieldColorModeId } from '../types/fieldColor';
import { FieldConfigPropertyItem, FieldConfigSource } from '../types/fieldOverrides';
import { InterpolateFunction } from '../types/panel';
import { ThresholdsMode } from '../types/thresholds';
import { MappingType } from '../types/valueMapping';
import { Registry } from '../utils/Registry';
import { locationUtil } from '../utils/location';
import { mockStandardProperties } from '../utils/tests/mockStandardProperties';
@@ -999,6 +1000,45 @@ describe('setDynamicConfigValue', () => {
expect(config.custom.property3).toEqual({});
expect(config.displayName).toBeUndefined();
});
it('works correctly with multiple value mappings in the same override', () => {
const config: FieldConfig = {
mappings: [{ type: MappingType.ValueToText, options: { existing: { text: 'existing' } } }],
};
setDynamicConfigValue(
config,
{
id: 'mappings',
value: [{ type: MappingType.ValueToText, options: { first: { text: 'first' } } }],
},
{
fieldConfigRegistry: customFieldRegistry,
data: [],
field: { type: FieldType.number } as Field,
dataFrameIndex: 0,
}
);
setDynamicConfigValue(
config,
{
id: 'mappings',
value: [{ type: MappingType.ValueToText, options: { second: { text: 'second' } } }],
},
{
fieldConfigRegistry: customFieldRegistry,
data: [],
field: { type: FieldType.number } as Field,
dataFrameIndex: 0,
}
);
expect(config.mappings).toHaveLength(3);
expect(config.mappings![0]).toEqual({ type: MappingType.ValueToText, options: { existing: { text: 'existing' } } });
expect(config.mappings![1]).toEqual({ type: MappingType.ValueToText, options: { first: { text: 'first' } } });
expect(config.mappings![2]).toEqual({ type: MappingType.ValueToText, options: { second: { text: 'second' } } });
});
});
describe('getLinksSupplier', () => {
@@ -341,7 +341,7 @@ export function setDynamicConfigValue(config: FieldConfig, value: DynamicConfigV
return;
}
const val = item.process(value.value, context, item.settings);
let val = item.process(value.value, context, item.settings);
const remove = val === undefined || val === null;
@@ -352,6 +352,15 @@ export function setDynamicConfigValue(config: FieldConfig, value: DynamicConfigV
unset(config, item.path);
}
} else {
// Merge arrays (e.g. mappings) when multiple overrides target the same field
if (Array.isArray(val)) {
const existingValue = item.isCustom ? get(config.custom, item.path) : get(config, item.path);
if (Array.isArray(existingValue)) {
val = [...existingValue, ...val];
}
}
if (item.isCustom) {
if (!config.custom) {
config.custom = {};
-12
View File
@@ -332,10 +332,6 @@ export interface FeatureToggles {
*/
alertingUIUseFullyCompatBackendFilters?: boolean;
/**
* Enables creating alert rules from a panel using a drawer UI
*/
createAlertRuleFromPanel?: boolean;
/**
* Enable Grafana to have a remote Alertmanager instance as the primary Alertmanager.
*/
alertmanagerRemotePrimary?: boolean;
@@ -531,10 +527,6 @@ export interface FeatureToggles {
*/
dashboardTemplates?: boolean;
/**
* Sets the logs table as default visualisation in logs explore
*/
logsExploreTableDefaultVisualization?: boolean;
/**
* Enables the new alert list view design
*/
alertingListViewV2?: boolean;
@@ -661,10 +653,6 @@ export interface FeatureToggles {
*/
rolePickerDrawer?: boolean;
/**
* Enable unified storage search
*/
unifiedStorageSearch?: boolean;
/**
* Enable sprinkles on unified storage search
*/
unifiedStorageSearchSprinkles?: boolean;
+1
View File
@@ -52,6 +52,7 @@ export const availableIconsIndex = {
bookmark: true,
'book-open': true,
'brackets-curly': true,
brain: true,
'browser-alt': true,
bug: true,
building: true,
-1
View File
@@ -29,7 +29,6 @@
"@grafana-app/source": "./src/internal/index.ts"
},
"./eslint-plugin": {
"@grafana-app/source": "./src/eslint/index.cjs",
"types": "./src/eslint/index.d.ts",
"default": "./src/eslint/index.cjs"
}
+40 -11
View File
@@ -142,6 +142,24 @@ func (s *SearchHandler) GetAPIRoutes(defs map[string]common.OpenAPIDefinition) *
Schema: spec.StringProperty(),
},
},
{
ParameterProps: spec3.ParameterProps{
Name: "panelType",
In: "query",
Description: "find dashboards using panels of a given plugin type",
Required: false,
Schema: spec.StringProperty(),
},
},
{
ParameterProps: spec3.ParameterProps{
Name: "dataSourceType",
In: "query",
Description: "find dashboards using datasources of a given plugin type",
Required: false,
Schema: spec.StringProperty(),
},
},
{
ParameterProps: spec3.ParameterProps{
Name: "permission",
@@ -430,14 +448,11 @@ func convertHttpSearchRequestToResourceSearchRequest(queryParams url.Values, use
}
}
// The facet term fields
// Apply facet terms
if facets, ok := queryParams["facet"]; ok {
if queryParams.Has("facetLimit") {
if parsed, err := strconv.Atoi(queryParams.Get("facetLimit")); err == nil && parsed > 0 {
facetLimit = parsed
if facetLimit > 1000 {
facetLimit = 1000
}
facetLimit = min(parsed, 1000)
}
}
searchRequest.Facet = make(map[string]*resourcepb.ResourceSearchRequest_Facet)
@@ -449,21 +464,35 @@ func convertHttpSearchRequestToResourceSearchRequest(queryParams url.Values, use
}
}
// The tags filter
if tags, ok := queryParams["tag"]; ok {
if v, ok := queryParams["tag"]; ok {
searchRequest.Options.Fields = append(searchRequest.Options.Fields, &resourcepb.Requirement{
Key: "tags",
Operator: "=",
Values: tags,
Values: v,
})
}
// The libraryPanel filter
if libraryPanel, ok := queryParams["libraryPanel"]; ok {
if v, ok := queryParams["panelType"]; ok {
searchRequest.Options.Fields = append(searchRequest.Options.Fields, &resourcepb.Requirement{
Key: resource.SEARCH_FIELD_PREFIX + builders.DASHBOARD_PANEL_TYPES,
Operator: "=",
Values: v,
})
}
if v, ok := queryParams["dataSourceType"]; ok {
searchRequest.Options.Fields = append(searchRequest.Options.Fields, &resourcepb.Requirement{
Key: resource.SEARCH_FIELD_PREFIX + builders.DASHBOARD_DS_TYPES,
Operator: "=",
Values: v,
})
}
if v, ok := queryParams["libraryPanel"]; ok {
searchRequest.Options.Fields = append(searchRequest.Options.Fields, &resourcepb.Requirement{
Key: builders.DASHBOARD_LIBRARY_PANEL_REFERENCE,
Operator: "=",
Values: libraryPanel,
Values: v,
})
}
@@ -71,7 +71,6 @@ type cachingDatasourceProvider struct {
}
func (q *cachingDatasourceProvider) GetDatasourceProvider(pluginJson plugins.JSONData) PluginDatasourceProvider {
group, _ := plugins.GetDatasourceGroupNameFromPluginID(pluginJson.ID)
return &scopedDatasourceProvider{
plugin: pluginJson,
dsService: q.dsService,
@@ -81,7 +80,7 @@ func (q *cachingDatasourceProvider) GetDatasourceProvider(pluginJson plugins.JSO
mapper: q.converter.mapper,
plugin: pluginJson.ID,
alias: pluginJson.AliasIDs,
group: group,
group: pluginJson.ID,
},
}
}
+24 -19
View File
@@ -37,6 +37,11 @@ var (
_ builder.APIGroupBuilder = (*DataSourceAPIBuilder)(nil)
)
type DataSourceAPIBuilderConfig struct {
LoadQueryTypes bool
UseDualWriter bool
}
// DataSourceAPIBuilder is used just so wire has something unique to return
type DataSourceAPIBuilder struct {
datasourceResourceInfo utils.ResourceInfo
@@ -46,7 +51,7 @@ type DataSourceAPIBuilder struct {
contextProvider PluginContextWrapper
accessControl accesscontrol.AccessControl
queryTypes *queryV0.QueryTypeDefinitionList
configCrudUseNewApis bool
cfg DataSourceAPIBuilderConfig
dataSourceCRUDMetric *prometheus.HistogramVec
}
@@ -89,20 +94,24 @@ func RegisterAPIService(
return nil, fmt.Errorf("plugin client is not a PluginClient: %T", pluginClient)
}
groupName := pluginJSON.ID + ".datasource.grafana.app"
builder, err = NewDataSourceAPIBuilder(
groupName,
pluginJSON,
client,
datasources.GetDatasourceProvider(pluginJSON),
contextProvider,
accessControl,
//nolint:staticcheck // not yet migrated to OpenFeature
features.IsEnabledGlobally(featuremgmt.FlagDatasourceQueryTypes),
//nolint:staticcheck // not yet migrated to OpenFeature
features.IsEnabledGlobally(featuremgmt.FlagQueryServiceWithConnections),
DataSourceAPIBuilderConfig{
//nolint:staticcheck // not yet migrated to OpenFeature
LoadQueryTypes: features.IsEnabledGlobally(featuremgmt.FlagDatasourceQueryTypes),
UseDualWriter: false,
},
)
if err != nil {
return nil, err
}
builder.SetDataSourceCRUDMetrics(dataSourceCRUDMetric)
apiRegistrar.RegisterAPI(builder)
@@ -120,31 +129,27 @@ type PluginClient interface {
}
func NewDataSourceAPIBuilder(
groupName string,
plugin plugins.JSONData,
client PluginClient,
datasources PluginDatasourceProvider,
contextProvider PluginContextWrapper,
accessControl accesscontrol.AccessControl,
loadQueryTypes bool,
configCrudUseNewApis bool,
cfg DataSourceAPIBuilderConfig,
) (*DataSourceAPIBuilder, error) {
group, err := plugins.GetDatasourceGroupNameFromPluginID(plugin.ID)
if err != nil {
return nil, err
}
builder := &DataSourceAPIBuilder{
datasourceResourceInfo: datasourceV0.DataSourceResourceInfo.WithGroupAndShortName(group, plugin.ID),
datasourceResourceInfo: datasourceV0.DataSourceResourceInfo.WithGroupAndShortName(groupName, plugin.ID),
pluginJSON: plugin,
client: client,
datasources: datasources,
contextProvider: contextProvider,
accessControl: accessControl,
configCrudUseNewApis: configCrudUseNewApis,
cfg: cfg,
}
if loadQueryTypes {
var err error
if cfg.LoadQueryTypes {
// In the future, this will somehow come from the plugin
builder.queryTypes, err = getHardcodedQueryTypes(group)
builder.queryTypes, err = getHardcodedQueryTypes(groupName)
}
return builder, err
}
@@ -154,9 +159,9 @@ func getHardcodedQueryTypes(group string) (*queryV0.QueryTypeDefinitionList, err
var err error
var raw json.RawMessage
switch group {
case "testdata.datasource.grafana.app":
case "testdata.datasource.grafana.app", "grafana-testdata-datasource":
raw, err = kinds.QueryTypeDefinitionListJSON()
case "prometheus.datasource.grafana.app":
case "prometheus.datasource.grafana.app", "prometheus":
raw, err = models.QueryTypeDefinitionListJSON()
}
if err != nil {
@@ -233,7 +238,7 @@ func (b *DataSourceAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver
storage["connections"] = &noopREST{} // hidden from openapi
storage["connections/query"] = storage[ds.StoragePath("query")] // deprecated in openapi
if b.configCrudUseNewApis {
if b.cfg.UseDualWriter {
legacyStore := &legacyStorage{
datasources: b.datasources,
resourceInfo: &ds,
+5 -1
View File
@@ -133,7 +133,11 @@ type FeatureFlag struct {
Stage FeatureFlagStage `json:"stage,omitempty"`
Owner codeowner `json:"-"` // Owner person or team that owns this feature flag
// CEL-GO expression. Using the value "true" will mean this is on by default
// Expression defined by the feature_toggles configuration.
// Supports multiple types including boolean, string, integer, float,
// and structured values following the OpenFeature specification.
// Using the value "true" means the feature flag is enabled by default,
// Using the value "1.0" means the default value of the feature flag is 1.0
Expression string `json:"expression,omitempty"`
// Special behavior properties
+4 -3
View File
@@ -8,6 +8,7 @@ import (
clientauthmiddleware "github.com/grafana/grafana/pkg/clientauth/middleware"
"github.com/grafana/grafana/pkg/setting"
"github.com/open-feature/go-sdk/openfeature/memprovider"
sdkhttpclient "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
"github.com/open-feature/go-sdk/openfeature"
@@ -26,7 +27,7 @@ type OpenFeatureConfig struct {
// HTTPClient is a pre-configured HTTP client (optional, used by features-service + OFREP providers)
HTTPClient *http.Client
// StaticFlags are the feature flags to use with static provider
StaticFlags map[string]bool
StaticFlags map[string]memprovider.InMemoryFlag
// TargetingKey is used for evaluation context
TargetingKey string
// ContextAttrs are additional attributes for evaluation context
@@ -100,7 +101,7 @@ func InitOpenFeatureWithCfg(cfg *setting.Cfg) error {
func createProvider(
providerType string,
u *url.URL,
staticFlags map[string]bool,
staticFlags map[string]memprovider.InMemoryFlag,
httpClient *http.Client,
) (openfeature.FeatureProvider, error) {
if providerType == setting.FeaturesServiceProviderType || providerType == setting.OFREPProviderType {
@@ -117,7 +118,7 @@ func createProvider(
}
}
return newStaticProvider(staticFlags)
return newStaticProvider(staticFlags, standardFeatureFlags)
}
func createHTTPClient(m *clientauthmiddleware.TokenExchangeMiddleware) (*http.Client, error) {
-21
View File
@@ -534,13 +534,6 @@ var (
Owner: grafanaAlertingSquad,
HideFromDocs: true,
},
{
Name: "createAlertRuleFromPanel",
Description: "Enables creating alert rules from a panel using a drawer UI",
Stage: FeatureStageExperimental,
Owner: grafanaAlertingSquad,
FrontendOnly: true,
},
{
Name: "alertmanagerRemotePrimary",
Description: "Enable Grafana to have a remote Alertmanager instance as the primary Alertmanager.",
@@ -879,13 +872,6 @@ var (
Owner: grafanaSharingSquad,
FrontendOnly: false,
},
{
Name: "logsExploreTableDefaultVisualization",
Description: "Sets the logs table as default visualisation in logs explore",
Stage: FeatureStageExperimental,
Owner: grafanaObservabilityLogsSquad,
FrontendOnly: true,
},
{
Name: "alertingListViewV2",
Description: "Enables the new alert list view design",
@@ -1094,13 +1080,6 @@ var (
Stage: FeatureStageExperimental,
Owner: identityAccessTeam,
},
{
Name: "unifiedStorageSearch",
Description: "Enable unified storage search",
Stage: FeatureStageExperimental,
Owner: grafanaSearchAndStorageSquad,
HideFromDocs: true,
},
{
Name: "unifiedStorageSearchSprinkles",
Description: "Enable sprinkles on unified storage search",
+2 -1
View File
@@ -47,7 +47,8 @@ func ProvideManagerService(cfg *setting.Cfg) (*FeatureManager, error) {
}
mgmt.warnings[key] = "unknown flag in config"
}
mgmt.startup[key] = val
mgmt.startup[key] = val.Variants[val.DefaultVariant] == true
}
// update the values
+1 -1
View File
@@ -29,7 +29,7 @@ func CreateStaticEvaluator(cfg *setting.Cfg) (StaticFlagEvaluator, error) {
return nil, fmt.Errorf("failed to read feature flags from config: %w", err)
}
staticProvider, err := newStaticProvider(staticFlags)
staticProvider, err := newStaticProvider(staticFlags, standardFeatureFlags)
if err != nil {
return nil, fmt.Errorf("failed to create static provider: %w", err)
}
+18 -29
View File
@@ -1,8 +1,13 @@
package featuremgmt
import (
"fmt"
"maps"
"github.com/open-feature/go-sdk/openfeature"
"github.com/open-feature/go-sdk/openfeature/memprovider"
"github.com/grafana/grafana/pkg/setting"
)
// inMemoryBulkProvider is a wrapper around memprovider.InMemoryProvider that
@@ -28,37 +33,21 @@ func (p *inMemoryBulkProvider) ListFlags() ([]string, error) {
return keys, nil
}
func newStaticProvider(confFlags map[string]bool) (openfeature.FeatureProvider, error) {
flags := make(map[string]memprovider.InMemoryFlag, len(standardFeatureFlags))
func newStaticProvider(confFlags map[string]memprovider.InMemoryFlag, standardFlags []FeatureFlag) (openfeature.FeatureProvider, error) {
flags := make(map[string]memprovider.InMemoryFlag, len(standardFlags))
// Parse and add standard flags
for _, flag := range standardFlags {
inMemFlag, err := setting.ParseFlag(flag.Name, flag.Expression)
if err != nil {
return nil, fmt.Errorf("failed to parse flag %s: %w", flag.Name, err)
}
flags[flag.Name] = inMemFlag
}
// Add flags from config.ini file
for name, value := range confFlags {
flags[name] = createInMemoryFlag(name, value)
}
// Add standard flags
for _, flag := range standardFeatureFlags {
if _, exists := flags[flag.Name]; !exists {
enabled := flag.Expression == "true"
flags[flag.Name] = createInMemoryFlag(flag.Name, enabled)
}
}
maps.Copy(flags, confFlags)
return newInMemoryBulkProvider(flags), nil
}
func createInMemoryFlag(name string, enabled bool) memprovider.InMemoryFlag {
variant := "disabled"
if enabled {
variant = "enabled"
}
return memprovider.InMemoryFlag{
Key: name,
DefaultVariant: variant,
Variants: map[string]interface{}{
"enabled": true,
"disabled": false,
},
}
}
@@ -5,6 +5,7 @@ import (
"testing"
"github.com/grafana/grafana/pkg/setting"
"github.com/open-feature/go-sdk/openfeature/memprovider"
"github.com/open-feature/go-sdk/openfeature"
"github.com/stretchr/testify/assert"
@@ -93,3 +94,144 @@ ABCD = true
enabledFeatureManager := mgr.GetEnabled(ctx)
assert.Equal(t, openFeatureEnabledFlags, enabledFeatureManager)
}
func Test_StaticProvider_TypedFlags(t *testing.T) {
tests := []struct {
flags FeatureFlag
defaultValue any
expectedValue any
}{
{
flags: FeatureFlag{
Name: "Flag",
Expression: "true",
},
defaultValue: false,
expectedValue: true,
},
{
flags: FeatureFlag{
Name: "Flag",
Expression: "1.0",
},
defaultValue: 0.0,
expectedValue: 1.0,
},
{
flags: FeatureFlag{
Name: "Flag",
Expression: "blue",
},
defaultValue: "red",
expectedValue: "blue",
},
{
flags: FeatureFlag{
Name: "Flag",
Expression: "1",
},
defaultValue: int64(0),
expectedValue: int64(1),
},
{
flags: FeatureFlag{
Name: "Flag",
Expression: `{ "foo": "bar" }`,
},
expectedValue: map[string]any{"foo": "bar"},
},
}
for _, tt := range tests {
provider, err := newStaticProvider(nil, []FeatureFlag{tt.flags})
assert.NoError(t, err)
var result any
switch tt.expectedValue.(type) {
case bool:
result = provider.BooleanEvaluation(t.Context(), tt.flags.Name, tt.defaultValue.(bool), openfeature.FlattenedContext{}).Value
case float64:
result = provider.FloatEvaluation(t.Context(), tt.flags.Name, tt.defaultValue.(float64), openfeature.FlattenedContext{}).Value
case string:
result = provider.StringEvaluation(t.Context(), tt.flags.Name, tt.defaultValue.(string), openfeature.FlattenedContext{}).Value
case int64:
result = provider.IntEvaluation(t.Context(), tt.flags.Name, tt.defaultValue.(int64), openfeature.FlattenedContext{}).Value
case map[string]any:
result = provider.ObjectEvaluation(t.Context(), tt.flags.Name, tt.defaultValue, openfeature.FlattenedContext{}).Value
}
assert.Equal(t, tt.expectedValue, result)
}
}
func Test_StaticProvider_ConfigOverride(t *testing.T) {
tests := []struct {
name string
originalValue string
configValue any
}{
{
name: "bool",
originalValue: "false",
configValue: true,
},
{
name: "int",
originalValue: "0",
configValue: int64(1),
},
{
name: "float",
originalValue: "0.0",
configValue: 1.0,
},
{
name: "string",
originalValue: "foo",
configValue: "bar",
},
{
name: "structure",
originalValue: "{}",
configValue: make(map[string]any),
},
}
for _, tt := range tests {
configFlags, standardFlags := makeFlags(tt)
provider, err := newStaticProvider(configFlags, standardFlags)
assert.NoError(t, err)
var result any
switch tt.configValue.(type) {
case bool:
result = provider.BooleanEvaluation(t.Context(), tt.name, false, openfeature.FlattenedContext{}).Value
case float64:
result = provider.FloatEvaluation(t.Context(), tt.name, 0.0, openfeature.FlattenedContext{}).Value
case string:
result = provider.StringEvaluation(t.Context(), tt.name, "foo", openfeature.FlattenedContext{}).Value
case int64:
result = provider.IntEvaluation(t.Context(), tt.name, 1, openfeature.FlattenedContext{}).Value
case map[string]any:
result = provider.ObjectEvaluation(t.Context(), tt.name, make(map[string]any), openfeature.FlattenedContext{}).Value
}
assert.Equal(t, tt.configValue, result)
}
}
func makeFlags(tt struct {
name string
originalValue string
configValue any
}) (map[string]memprovider.InMemoryFlag, []FeatureFlag) {
orig := FeatureFlag{
Name: tt.name,
Expression: tt.originalValue,
}
config := map[string]memprovider.InMemoryFlag{
tt.name: setting.NewInMemoryFlag(tt.name, tt.configValue),
}
return config, []FeatureFlag{orig}
}
-3
View File
@@ -74,7 +74,6 @@ alertmanagerRemoteSecondary,experimental,@grafana/alerting-squad,false,false,fal
alertingProvenanceLockWrites,experimental,@grafana/alerting-squad,false,false,false
alertingUIUseBackendFilters,experimental,@grafana/alerting-squad,false,false,false
alertingUIUseFullyCompatBackendFilters,experimental,@grafana/alerting-squad,false,false,false
createAlertRuleFromPanel,experimental,@grafana/alerting-squad,false,false,true
alertmanagerRemotePrimary,experimental,@grafana/alerting-squad,false,false,false
annotationPermissionUpdate,GA,@grafana/identity-access-team,false,false,false
dashboardSceneForViewers,GA,@grafana/dashboards-squad,false,false,true
@@ -121,7 +120,6 @@ queryLibrary,preview,@grafana/sharing-squad,false,false,false
dashboardLibrary,experimental,@grafana/sharing-squad,false,false,false
suggestedDashboards,experimental,@grafana/sharing-squad,false,false,false
dashboardTemplates,preview,@grafana/sharing-squad,false,false,false
logsExploreTableDefaultVisualization,experimental,@grafana/observability-logs,false,false,true
alertingListViewV2,privatePreview,@grafana/alerting-squad,false,false,true
alertingSavedSearches,experimental,@grafana/alerting-squad,false,false,true
alertingDisableSendAlertsExternal,experimental,@grafana/alerting-squad,false,false,false
@@ -151,7 +149,6 @@ alertingQueryAndExpressionsStepMode,GA,@grafana/alerting-squad,false,false,true
improvedExternalSessionHandling,GA,@grafana/identity-access-team,false,false,false
useSessionStorageForRedirection,GA,@grafana/identity-access-team,false,false,false
rolePickerDrawer,experimental,@grafana/identity-access-team,false,false,false
unifiedStorageSearch,experimental,@grafana/search-and-storage,false,false,false
unifiedStorageSearchSprinkles,experimental,@grafana/search-and-storage,false,false,false
managedDualWriter,experimental,@grafana/search-and-storage,false,false,false
pluginsSriChecks,GA,@grafana/plugins-platform-backend,false,false,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
74 alertingProvenanceLockWrites experimental @grafana/alerting-squad false false false
75 alertingUIUseBackendFilters experimental @grafana/alerting-squad false false false
76 alertingUIUseFullyCompatBackendFilters experimental @grafana/alerting-squad false false false
createAlertRuleFromPanel experimental @grafana/alerting-squad false false true
77 alertmanagerRemotePrimary experimental @grafana/alerting-squad false false false
78 annotationPermissionUpdate GA @grafana/identity-access-team false false false
79 dashboardSceneForViewers GA @grafana/dashboards-squad false false true
120 dashboardLibrary experimental @grafana/sharing-squad false false false
121 suggestedDashboards experimental @grafana/sharing-squad false false false
122 dashboardTemplates preview @grafana/sharing-squad false false false
logsExploreTableDefaultVisualization experimental @grafana/observability-logs false false true
123 alertingListViewV2 privatePreview @grafana/alerting-squad false false true
124 alertingSavedSearches experimental @grafana/alerting-squad false false true
125 alertingDisableSendAlertsExternal experimental @grafana/alerting-squad false false false
149 improvedExternalSessionHandling GA @grafana/identity-access-team false false false
150 useSessionStorageForRedirection GA @grafana/identity-access-team false false false
151 rolePickerDrawer experimental @grafana/identity-access-team false false false
unifiedStorageSearch experimental @grafana/search-and-storage false false false
152 unifiedStorageSearchSprinkles experimental @grafana/search-and-storage false false false
153 managedDualWriter experimental @grafana/search-and-storage false false false
154 pluginsSriChecks GA @grafana/plugins-platform-backend false false false
-4
View File
@@ -455,10 +455,6 @@ const (
// Enables the new role picker drawer design
FlagRolePickerDrawer = "rolePickerDrawer"
// FlagUnifiedStorageSearch
// Enable unified storage search
FlagUnifiedStorageSearch = "unifiedStorageSearch"
// FlagUnifiedStorageSearchSprinkles
// Enable sprinkles on unified storage search
FlagUnifiedStorageSearchSprinkles = "unifiedStorageSearchSprinkles"
+4 -15
View File
@@ -962,19 +962,6 @@
"hideFromDocs": true
}
},
{
"metadata": {
"name": "createAlertRuleFromPanel",
"resourceVersion": "1763546460188",
"creationTimestamp": "2025-10-29T09:52:06Z"
},
"spec": {
"description": "Enables creating alert rules from a panel using a drawer UI",
"stage": "experimental",
"codeowner": "@grafana/alerting-squad",
"frontend": true
}
},
{
"metadata": {
"name": "dashboardDisableSchemaValidationV1",
@@ -2259,7 +2246,8 @@
"metadata": {
"name": "logsExploreTableDefaultVisualization",
"resourceVersion": "1764664939750",
"creationTimestamp": "2024-05-02T15:28:15Z"
"creationTimestamp": "2024-05-02T15:28:15Z",
"deletionTimestamp": "2026-01-12T14:11:46Z"
},
"spec": {
"description": "Sets the logs table as default visualisation in logs explore",
@@ -3710,7 +3698,8 @@
"metadata": {
"name": "unifiedStorageSearch",
"resourceVersion": "1764664939750",
"creationTimestamp": "2024-09-30T19:46:14Z"
"creationTimestamp": "2024-09-30T19:46:14Z",
"deletionTimestamp": "2026-01-12T10:02:12Z"
},
"spec": {
"description": "Enable unified storage search",
+10 -1
View File
@@ -100,6 +100,9 @@ func (d *DsLookup) ByRef(ref *DataSourceRef) *DataSourceRef {
if ref == nil {
return d.defaultDS
}
if ref.UID == "default" && ref.Type == "" {
return d.defaultDS
}
key := ""
if ref.UID != "" {
@@ -117,7 +120,13 @@ func (d *DsLookup) ByRef(ref *DataSourceRef) *DataSourceRef {
return ds
}
return d.byName[key]
ds, ok = d.byName[key]
if ok {
return ds
}
// With nothing was found (or configured), use the original reference
return ref
}
func (d *DsLookup) ByType(dsType string) []DataSourceRef {
@@ -4,8 +4,8 @@
"tags": null,
"datasource": [
{
"uid": "default.uid",
"type": "default.type"
"uid": "000000001",
"type": "graphite"
}
],
"panels": [
@@ -16,8 +16,8 @@
"libraryPanel": "dfkljg98345dkf",
"datasource": [
{
"uid": "default.uid",
"type": "default.type"
"uid": "000000001",
"type": "graphite"
}
]
}
@@ -1,5 +1,7 @@
package dashboard
import "iter"
type PanelSummaryInfo struct {
ID int64 `json:"id"`
Title string `json:"title"`
@@ -30,3 +32,20 @@ type DashboardSummaryInfo struct {
Refresh string `json:"refresh,omitempty"`
ReadOnly bool `json:"readOnly,omitempty"` // editable = false
}
func (d *DashboardSummaryInfo) PanelIterator() iter.Seq[PanelSummaryInfo] {
return func(yield func(PanelSummaryInfo) bool) {
for _, p := range d.Panels {
if len(p.Collapsed) > 0 {
for _, c := range p.Collapsed {
if !yield(c) { // NOTE, rows can only be one level deep!
return
}
}
}
if !yield(p) {
return
}
}
}
}
+5 -2
View File
@@ -10,6 +10,7 @@ import (
"testing"
"github.com/open-feature/go-sdk/openfeature"
"github.com/open-feature/go-sdk/openfeature/memprovider"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/log"
@@ -378,8 +379,10 @@ func setupOpenFeatureProvider(t *testing.T, flagValue bool) {
err := featuremgmt.InitOpenFeature(featuremgmt.OpenFeatureConfig{
ProviderType: setting.StaticProviderType,
StaticFlags: map[string]bool{
featuremgmt.FlagPluginsAutoUpdate: flagValue,
StaticFlags: map[string]memprovider.InMemoryFlag{
featuremgmt.FlagPluginsAutoUpdate: {
Key: featuremgmt.FlagPluginsAutoUpdate, Variants: map[string]any{"": flagValue},
},
},
})
require.NoError(t, err)
+75 -5
View File
@@ -1,13 +1,20 @@
package setting
import (
"encoding/json"
"math"
"strconv"
"gopkg.in/ini.v1"
"github.com/open-feature/go-sdk/openfeature/memprovider"
"github.com/grafana/grafana/pkg/util"
)
// DefaultVariantName a placeholder name for config-based Feature Flags
const DefaultVariantName = "default"
// Deprecated: should use `featuremgmt.FeatureToggles`
func (cfg *Cfg) readFeatureToggles(iniFile *ini.File) error {
section := iniFile.Section("feature_toggles")
@@ -15,18 +22,27 @@ func (cfg *Cfg) readFeatureToggles(iniFile *ini.File) error {
if err != nil {
return err
}
// TODO IsFeatureToggleEnabled has been deprecated for 2 years now, we should remove this function completely
// nolint:staticcheck
cfg.IsFeatureToggleEnabled = func(key string) bool { return toggles[key] }
cfg.IsFeatureToggleEnabled = func(key string) bool {
toggle, ok := toggles[key]
if !ok {
return false
}
value, ok := toggle.Variants[toggle.DefaultVariant].(bool)
return value && ok
}
return nil
}
func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[string]bool, error) {
featureToggles := make(map[string]bool, 10)
func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[string]memprovider.InMemoryFlag, error) {
featureToggles := make(map[string]memprovider.InMemoryFlag, 10)
// parse the comma separated list in `enable`.
featuresTogglesStr := valueAsString(featureTogglesSection, "enable", "")
for _, feature := range util.SplitString(featuresTogglesStr) {
featureToggles[feature] = true
featureToggles[feature] = memprovider.InMemoryFlag{Key: feature, DefaultVariant: DefaultVariantName, Variants: map[string]any{DefaultVariantName: true}}
}
// read all other settings under [feature_toggles]. If a toggle is
@@ -36,7 +52,7 @@ func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[str
continue
}
b, err := strconv.ParseBool(v.Value())
b, err := ParseFlag(v.Name(), v.Value())
if err != nil {
return featureToggles, err
}
@@ -45,3 +61,57 @@ func ReadFeatureTogglesFromInitFile(featureTogglesSection *ini.Section) (map[str
}
return featureToggles, nil
}
func ParseFlag(name, value string) (memprovider.InMemoryFlag, error) {
var structure map[string]any
if integer, err := strconv.Atoi(value); err == nil {
return NewInMemoryFlag(name, integer), nil
}
if float, err := strconv.ParseFloat(value, 64); err == nil {
return NewInMemoryFlag(name, float), nil
}
if err := json.Unmarshal([]byte(value), &structure); err == nil {
return NewInMemoryFlag(name, structure), nil
}
if boolean, err := strconv.ParseBool(value); err == nil {
return NewInMemoryFlag(name, boolean), nil
}
return NewInMemoryFlag(name, value), nil
}
func NewInMemoryFlag(name string, value any) memprovider.InMemoryFlag {
return memprovider.InMemoryFlag{Key: name, DefaultVariant: DefaultVariantName, Variants: map[string]any{DefaultVariantName: value}}
}
func AsStringMap(m map[string]memprovider.InMemoryFlag) map[string]string {
var res = map[string]string{}
for k, v := range m {
res[k] = serializeFlagValue(v)
}
return res
}
func serializeFlagValue(flag memprovider.InMemoryFlag) string {
value := flag.Variants[flag.DefaultVariant]
switch castedValue := value.(type) {
case bool:
return strconv.FormatBool(castedValue)
case int64:
return strconv.FormatInt(castedValue, 10)
case float64:
// handle cases with a single or no zeros after the decimal point
if math.Trunc(castedValue) == castedValue {
return strconv.FormatFloat(castedValue, 'f', 1, 64)
}
return strconv.FormatFloat(castedValue, 'g', -1, 64)
case string:
return castedValue
default:
val, _ := json.Marshal(value)
return string(val)
}
}
+54 -23
View File
@@ -1,9 +1,11 @@
package setting
import (
"strconv"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/open-feature/go-sdk/openfeature/memprovider"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/ini.v1"
)
@@ -12,17 +14,16 @@ func TestFeatureToggles(t *testing.T) {
testCases := []struct {
name string
conf map[string]string
err error
expectedToggles map[string]bool
expectedToggles map[string]memprovider.InMemoryFlag
}{
{
name: "can parse feature toggles passed in the `enable` array",
conf: map[string]string{
"enable": "feature1,feature2",
},
expectedToggles: map[string]bool{
"feature1": true,
"feature2": true,
expectedToggles: map[string]memprovider.InMemoryFlag{
"feature1": NewInMemoryFlag("feature1", true),
"feature2": NewInMemoryFlag("feature2", true),
},
},
{
@@ -31,10 +32,10 @@ func TestFeatureToggles(t *testing.T) {
"enable": "feature1,feature2",
"feature3": "true",
},
expectedToggles: map[string]bool{
"feature1": true,
"feature2": true,
"feature3": true,
expectedToggles: map[string]memprovider.InMemoryFlag{
"feature1": NewInMemoryFlag("feature1", true),
"feature2": NewInMemoryFlag("feature2", true),
"feature3": NewInMemoryFlag("feature3", true),
},
},
{
@@ -43,19 +44,26 @@ func TestFeatureToggles(t *testing.T) {
"enable": "feature1,feature2",
"feature2": "false",
},
expectedToggles: map[string]bool{
"feature1": true,
"feature2": false,
expectedToggles: map[string]memprovider.InMemoryFlag{
"feature1": NewInMemoryFlag("feature1", true),
"feature2": NewInMemoryFlag("feature2", false),
},
},
{
name: "invalid boolean value should return syntax error",
name: "feature flags of different types are handled correctly",
conf: map[string]string{
"enable": "feature1,feature2",
"feature2": "invalid",
"feature1": "1", "feature2": "1.0",
"feature3": `{"foo":"bar"}`, "feature4": "bar",
"feature5": "t", "feature6": "T",
},
expectedToggles: map[string]memprovider.InMemoryFlag{
"feature1": NewInMemoryFlag("feature1", 1),
"feature2": NewInMemoryFlag("feature2", 1.0),
"feature3": NewInMemoryFlag("feature3", map[string]any{"foo": "bar"}),
"feature4": NewInMemoryFlag("feature4", "bar"),
"feature5": NewInMemoryFlag("feature5", true),
"feature6": NewInMemoryFlag("feature6", true),
},
expectedToggles: map[string]bool{},
err: strconv.ErrSyntax,
},
}
@@ -69,12 +77,35 @@ func TestFeatureToggles(t *testing.T) {
}
featureToggles, err := ReadFeatureTogglesFromInitFile(toggles)
require.ErrorIs(t, err, tc.err)
require.NoError(t, err)
if err == nil {
for k, v := range featureToggles {
require.Equal(t, tc.expectedToggles[k], v, tc.name)
}
for k, v := range featureToggles {
toggle := tc.expectedToggles[k]
require.Equal(t, toggle, v, tc.name)
}
}
}
func TestFlagValueSerialization(t *testing.T) {
testCases := []memprovider.InMemoryFlag{
NewInMemoryFlag("int", 1),
NewInMemoryFlag("1.0f", 1.0),
NewInMemoryFlag("1.01f", 1.01),
NewInMemoryFlag("1.10f", 1.10),
NewInMemoryFlag("struct", map[string]any{"foo": "bar"}),
NewInMemoryFlag("string", "bar"),
NewInMemoryFlag("true", true),
NewInMemoryFlag("false", false),
}
for _, tt := range testCases {
asStringMap := AsStringMap(map[string]memprovider.InMemoryFlag{tt.Key: tt})
deserialized, err := ParseFlag(tt.Key, asStringMap[tt.Key])
assert.NoError(t, err)
if diff := cmp.Diff(tt, deserialized); diff != "" {
t.Errorf("(-want, +got) = %v", diff)
}
}
}
-1
View File
@@ -236,7 +236,6 @@ kubernetesDashboards = true
kubernetesFolders = true
unifiedStorage = true
unifiedStorageHistoryPruner = true
unifiedStorageSearch = true
unifiedStorageSearchPermissionFiltering = false
unifiedStorageSearchSprinkles = false
+1 -1
View File
@@ -863,7 +863,7 @@ func newRebuildRequest(key NamespacedResource, minBuildTime, lastImportTime time
func (s *searchSupport) getOrCreateIndex(ctx context.Context, stats *SearchStats, key NamespacedResource, reason string) (ResourceIndex, error) {
if s == nil || s.search == nil {
return nil, fmt.Errorf("search is not configured properly (missing unifiedStorageSearch feature toggle?)")
return nil, fmt.Errorf("search is not configured properly (missing enable_search config?)")
}
ctx, span := tracer.Start(ctx, "resource.searchSupport.getOrCreateIndex")
+4 -2
View File
@@ -1253,21 +1253,23 @@ func (b *bleveIndex) toBleveSearchRequest(ctx context.Context, req *resourcepb.R
queryExact.SetField(resource.SEARCH_FIELD_TITLE)
queryExact.Analyzer = keyword.Name // don't analyze the query input - treat it as a single token
queryExact.Operator = query.MatchQueryOperatorAnd // This doesn't make a difference for keyword analyzer, we add it just to be explicit.
searchQuery := bleve.NewDisjunctionQuery(queryExact)
// Query 2: Phrase query with standard analyzer
queryPhrase := bleve.NewMatchPhraseQuery(req.Query)
queryPhrase.SetBoost(5.0)
queryPhrase.SetField(resource.SEARCH_FIELD_TITLE)
queryPhrase.Analyzer = standard.Name
searchQuery.AddQuery(queryPhrase)
// Query 3: Match query with standard analyzer
queryAnalyzed := bleve.NewMatchQuery(removeSmallTerms(req.Query))
queryAnalyzed.SetField(resource.SEARCH_FIELD_TITLE)
queryAnalyzed.SetBoost(2.0)
queryAnalyzed.Analyzer = standard.Name
queryAnalyzed.Operator = query.MatchQueryOperatorAnd // Make sure all terms from the query are matched
searchQuery.AddQuery(queryAnalyzed)
// At least one of the queries must match
searchQuery := bleve.NewDisjunctionQuery(queryExact, queryAnalyzed, queryPhrase)
queries = append(queries, searchQuery)
}
-1
View File
@@ -23,7 +23,6 @@ import (
"go.uber.org/goleak"
authlib "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/infra/log"
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"fmt"
"slices"
"sort"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
@@ -18,6 +19,7 @@ import (
const DASHBOARD_SCHEMA_VERSION = "schema_version"
const DASHBOARD_LINK_COUNT = "link_count"
const DASHBOARD_PANEL_TYPES = "panel_types"
const DASHBOARD_PANEL_TITLE = "panel_title"
const DASHBOARD_DS_TYPES = "ds_types"
const DASHBOARD_TRANSFORMATIONS = "transformation"
const DASHBOARD_LIBRARY_PANEL_REFERENCE = "reference.LibraryPanel"
@@ -53,11 +55,21 @@ func DashboardBuilder(namespaced resource.NamespacedDocumentSupplier) (resource.
Type: resourcepb.ResourceTableColumnDefinition_INT32,
Description: "How many links appear on the page",
},
{
Name: DASHBOARD_PANEL_TITLE,
Type: resourcepb.ResourceTableColumnDefinition_STRING,
IsArray: true,
Description: "The panel title text",
Properties: &resourcepb.ResourceTableColumnDefinition_Properties{
Filterable: false, // full text
FreeText: true,
},
},
{
Name: DASHBOARD_PANEL_TYPES,
Type: resourcepb.ResourceTableColumnDefinition_STRING,
IsArray: true,
Description: "How many links appear on the page",
Description: "The panel types used in this dashboard",
Properties: &resourcepb.ResourceTableColumnDefinition_Properties{
Filterable: true,
},
@@ -269,14 +281,22 @@ func (s *DashboardDocumentBuilder) BuildDocument(ctx context.Context, key *resou
doc.Description = summary.Description
doc.Tags = summary.Tags
panelTitles := []string{}
panelTypes := []string{}
transformations := []string{}
dsTypes := []string{}
for _, p := range summary.Panels {
if p.Type != "" {
for p := range summary.PanelIterator() {
switch p.Type {
case "": // ignore
case "row": // row should map to a layout type when we support v2 constructs
default:
panelTypes = append(panelTypes, p.Type)
}
if len(p.Title) > 0 {
panelTitles = append(panelTitles, p.Title)
}
if len(p.Transformer) > 0 {
transformations = append(transformations, p.Transformer...)
}
@@ -309,17 +329,20 @@ func (s *DashboardDocumentBuilder) BuildDocument(ctx context.Context, key *resou
resource.SEARCH_FIELD_LEGACY_ID: summary.ID,
}
if len(panelTitles) > 0 {
doc.Fields[DASHBOARD_PANEL_TITLE] = panelTitles
}
if len(panelTypes) > 0 {
sort.Strings(panelTypes)
doc.Fields[DASHBOARD_PANEL_TYPES] = panelTypes
doc.Fields[DASHBOARD_PANEL_TYPES] = slices.Compact(panelTypes) // distinct values
}
if len(dsTypes) > 0 {
sort.Strings(dsTypes)
doc.Fields[DASHBOARD_DS_TYPES] = dsTypes
doc.Fields[DASHBOARD_DS_TYPES] = slices.Compact(dsTypes) // distinct values
}
if len(transformations) > 0 {
sort.Strings(transformations)
doc.Fields[DASHBOARD_TRANSFORMATIONS] = transformations
doc.Fields[DASHBOARD_TRANSFORMATIONS] = slices.Compact(transformations) // distinct values
}
for k, v := range s.Stats[summary.UID] {
@@ -32,10 +32,16 @@
"errors_last_7_days": 1,
"grafana.app/deprecatedInternalID": 141,
"link_count": 0,
"panel_title": [
"green pie",
"red pie",
"blue pie",
"collapsed row"
],
"panel_types": [
"barchart",
"graph",
"row"
"pie"
],
"schema_version": 38
},
@@ -46,6 +52,12 @@
"kind": "DataSource",
"name": "DSUID"
},
{
"relation": "depends-on",
"group": "dashboards.grafana.app",
"kind": "LibraryPanel",
"name": "l3d2s634-fdgf-75u4-3fg3-67j966ii7jur"
},
{
"relation": "depends-on",
"group": "dashboards.grafana.app",
@@ -67,7 +67,7 @@
"name": "red pie",
"uid": "e1d5f519-dabd-47c6-9ad7-83d181ce1cee"
},
"title": "green pie"
"title": "red pie"
},
{
"id": 7,
@@ -78,6 +78,14 @@
"id": 8,
"type": "graph"
},
{
"id": 20,
"type": "graph"
},
{
"id": 30,
"type": "graph"
},
{
"collapsed": true,
"gridPos": {
@@ -101,6 +109,10 @@
"uid": "l3d2s634-fdgf-75u4-3fg3-67j966ii7jur"
},
"title": "blue pie"
},
{
"id": 40,
"type": "pie"
}
],
"title": "collapsed row",
+1 -1
View File
@@ -19,7 +19,7 @@ func NewSearchOptions(
ownsIndexFn func(key resource.NamespacedResource) (bool, error),
) (resource.SearchOptions, error) {
//nolint:staticcheck // not yet migrated to OpenFeature
if cfg.EnableSearch || features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageSearch) || features.IsEnabledGlobally(featuremgmt.FlagProvisioning) {
if cfg.EnableSearch || features.IsEnabledGlobally(featuremgmt.FlagProvisioning) {
root := cfg.IndexPath
if root == "" {
root = filepath.Join(cfg.DataPath, "unified-search", "bleve")
+11 -1
View File
@@ -71,11 +71,18 @@
"description": "How many links appear on the page",
"priority": 0
},
{
"name": "panel_title",
"type": "string",
"format": "",
"description": "The panel title text",
"priority": 0
},
{
"name": "panel_types",
"type": "string",
"format": "",
"description": "How many links appear on the page",
"description": "The panel types used in this dashboard",
"priority": 0
},
{
@@ -214,6 +221,7 @@
null,
null,
null,
null,
null
],
"object": {
@@ -239,6 +247,7 @@
"repo",
null,
null,
null,
[
"timeseries"
],
@@ -282,6 +291,7 @@
"repo",
null,
null,
null,
[
"timeseries",
"table"
+168
View File
@@ -4,10 +4,15 @@ import (
"context"
"encoding/json"
"fmt"
"io/fs"
"math"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic"
@@ -16,12 +21,167 @@ import (
dashboardV0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/tests/apis"
"github.com/grafana/grafana/pkg/tests/testinfra"
"github.com/grafana/grafana/pkg/util/testutil"
)
func TestIntegrationSearchDevDashboards(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
ctx := context.Background()
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
DisableDataMigrations: true,
AppModeProduction: true,
DisableAnonymous: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
"dashboards.dashboard.grafana.app": {DualWriterMode: rest.Mode5},
"folders.folder.grafana.app": {DualWriterMode: rest.Mode5},
},
UnifiedStorageEnableSearch: true,
})
defer helper.Shutdown()
// Create devenv dashboards from legacy API
cfg := dynamic.ConfigFor(helper.Org1.Admin.NewRestConfig())
cfg.GroupVersion = &dashboardV0.GroupVersion
adminClient, err := k8srest.RESTClientFor(cfg)
require.NoError(t, err)
adminClient.Get()
fileCount := 0
devenv := "../../../../devenv/dev-dashboards/panel-timeseries"
err = filepath.WalkDir(devenv, func(p string, d fs.DirEntry, e error) error {
require.NoError(t, err)
if d.IsDir() || filepath.Ext(d.Name()) != ".json" {
return nil
}
// use the filename as UID
uid := strings.TrimSuffix(d.Name(), ".json")
if len(uid) > 40 {
uid = uid[:40] // avoid uid too long, max 40 characters
}
// nolint:gosec
data, err := os.ReadFile(p)
require.NoError(t, err)
cmd := dashboards.SaveDashboardCommand{
Dashboard: &simplejson.Json{},
Overwrite: true,
}
err = cmd.Dashboard.FromDB(data)
require.NoError(t, err)
cmd.Dashboard.Set("id", nil)
cmd.Dashboard.Set("uid", uid)
data, err = json.Marshal(cmd)
require.NoError(t, err)
var statusCode int
result := adminClient.Post().AbsPath("api", "dashboards", "db").
Body(data).
SetHeader("Content-type", "application/json").
Do(ctx).
StatusCode(&statusCode)
require.NoError(t, result.Error(), "file: [%d] %s [status:%d]", fileCount, d.Name(), statusCode)
require.Equal(t, int(http.StatusOK), statusCode)
fileCount++
return nil
})
require.NoError(t, err)
require.Equal(t, 16, fileCount, "file count from %s", devenv)
// Helper to call search
callSearch := func(user apis.User, params string) dashboardV0.SearchResults {
require.NotNil(t, user)
ns := user.Identity.GetNamespace()
cfg := dynamic.ConfigFor(user.NewRestConfig())
cfg.GroupVersion = &dashboardV0.GroupVersion
restClient, err := k8srest.RESTClientFor(cfg)
require.NoError(t, err)
var statusCode int
req := restClient.Get().AbsPath("apis", "dashboard.grafana.app", "v0alpha1", "namespaces", ns, "search").
Param("limit", "1000").
Param("type", "dashboard") // Only search dashboards
for kv := range strings.SplitSeq(params, "&") {
if kv == "" {
continue
}
parts := strings.SplitN(kv, "=", 2)
if len(parts) == 2 {
req = req.Param(parts[0], parts[1])
}
}
res := req.Do(ctx).StatusCode(&statusCode)
require.NoError(t, res.Error())
require.Equal(t, int(http.StatusOK), statusCode)
var sr dashboardV0.SearchResults
raw, err := res.Raw()
require.NoError(t, err)
require.NoError(t, json.Unmarshal(raw, &sr))
// Normalize scores and query cost for snapshot comparison
sr.QueryCost = 0 // this depends on the hardware
sr.MaxScore = roundTo(sr.MaxScore, 3)
for i := range sr.Hits {
sr.Hits[i].Score = roundTo(sr.Hits[i].Score, 3) // 0.6250571494814442 -> 0.625
}
return sr
}
// Compare a results to snapshots
testCases := []struct {
name string
user apis.User
params string
}{
{
name: "all",
user: helper.Org1.Admin,
params: "", // only dashboards
},
{
name: "simple-query",
user: helper.Org1.Admin,
params: "query=stacking",
},
{
name: "with-text-panel",
user: helper.Org1.Admin,
params: "field=panel_types&panelType=text",
},
}
for i, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
res := callSearch(tc.user, tc.params)
jj, err := json.MarshalIndent(res, "", " ")
require.NoError(t, err)
fname := fmt.Sprintf("testdata/searchV0/t%02d-%s.json", i, tc.name)
// nolint:gosec
snapshot, err := os.ReadFile(fname)
if err != nil {
assert.Failf(t, "Failed to read snapshot", "file: %s", fname)
err = os.WriteFile(fname, jj, 0o644)
require.NoErrorf(t, err, "Failed to write snapshot file %s", fname)
return
}
if !assert.JSONEq(t, string(snapshot), string(jj)) {
err = os.WriteFile(fname, jj, 0o644)
require.NoErrorf(t, err, "Failed to write snapshot file %s", fname)
}
})
}
}
func TestIntegrationSearchPermissionFiltering(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
@@ -285,3 +445,11 @@ func setFolderPermissions(t *testing.T, helper *apis.K8sTestHelper, actingUser a
require.Equal(t, http.StatusOK, resp.Response.StatusCode, "Failed to set permissions for folder %s", folderUID)
}
// roundTo rounds a float64 to a specified number of decimal places.
func roundTo(n float64, decimals uint32) float64 {
// Calculate the power of 10 for the desired number of decimals
scale := math.Pow(10, float64(decimals))
// Multiply, round to the nearest integer, and then divide back
return math.Round(n*scale) / scale
}
+165
View File
@@ -0,0 +1,165 @@
{
"totalHits": 16,
"hits": [
{
"resource": "dashboards",
"name": "timeseries",
"title": "Panel Tests - Graph NG",
"tags": [
"gdev",
"panel-tests",
"graph-ng"
]
},
{
"resource": "dashboards",
"name": "timeseries-by-value-color-schemes",
"title": "Panel Tests - Graph NG - By value color schemes",
"tags": [
"gdev",
"panel-tests",
"graph-ng"
]
},
{
"resource": "dashboards",
"name": "timeseries-nulls",
"title": "Panel Tests - Graph NG - Discrete panels",
"tags": [
"gdev",
"panel-tests",
"graph-ng",
"timeseries",
"trend",
"state-timeline",
"transform"
]
},
{
"resource": "dashboards",
"name": "timeseries-gradient-area",
"title": "Panel Tests - Graph NG - Gradient Area Fills",
"tags": [
"gdev",
"panel-tests",
"graph-ng"
]
},
{
"resource": "dashboards",
"name": "timeseries-soft-limits",
"title": "Panel Tests - Graph NG - softMin/softMax",
"tags": [
"gdev",
"panel-tests",
"graph-ng"
]
},
{
"resource": "dashboards",
"name": "timeseries-yaxis-ticks",
"title": "Panel Tests - Graph NG - Y axis ticks",
"tags": [
"gdev",
"panel-tests",
"graph-ng"
]
},
{
"resource": "dashboards",
"name": "timeseries-hue-gradients",
"title": "Panel Tests - GraphNG - Hue Gradients",
"tags": [
"gdev",
"panel-tests",
"graph-ng"
]
},
{
"resource": "dashboards",
"name": "timeseries-time",
"title": "Panel Tests - GraphNG - Time Axis",
"tags": [
"gdev",
"panel-tests",
"graph-ng"
]
},
{
"resource": "dashboards",
"name": "timeseries-thresholds",
"title": "Panel Tests - GraphNG Thresholds",
"tags": [
"gdev",
"panel-tests",
"graph-ng"
]
},
{
"resource": "dashboards",
"name": "timeseries-shared-tooltip-cursor-positio",
"title": "Panel Tests - shared tooltips cursor positioning",
"tags": [
"gdev",
"panel-tests",
"graph-ng"
]
},
{
"resource": "dashboards",
"name": "timeseries-bars-high-density",
"title": "Panel Tests - TimeSeries - bars high density (stroke + fill)",
"tags": [
"gdev",
"panel-tests",
"graph-ng"
]
},
{
"resource": "dashboards",
"name": "timeseries-out-of-rage",
"title": "Panel Tests - Timeseries - Out of range",
"tags": [
"gdev",
"panel-tests",
"graph-ng"
]
},
{
"resource": "dashboards",
"name": "timeseries-stacking",
"title": "Panel Tests - TimeSeries - stacking",
"tags": [
"gdev",
"panel-tests",
"graph-ng"
]
},
{
"resource": "dashboards",
"name": "timeseries-formats",
"title": "Panel Tests - Timeseries - Supported input formats"
},
{
"resource": "dashboards",
"name": "timeseries-stacking2",
"title": "TimeSeries \u0026 BarChart Stacking",
"tags": [
"gdev",
"panel-tests",
"graph-ng"
]
},
{
"resource": "dashboards",
"name": "timeseries-y-ticks-zero-decimals",
"title": "Zero Decimals Y Ticks",
"tags": [
"gdev",
"panel-tests",
"graph-ng"
]
}
],
"maxScore": 1
}
@@ -0,0 +1,28 @@
{
"totalHits": 2,
"hits": [
{
"resource": "dashboards",
"name": "timeseries-stacking",
"title": "Panel Tests - TimeSeries - stacking",
"tags": [
"gdev",
"panel-tests",
"graph-ng"
],
"score": 0.658
},
{
"resource": "dashboards",
"name": "timeseries-stacking2",
"title": "TimeSeries \u0026 BarChart Stacking",
"tags": [
"gdev",
"panel-tests",
"graph-ng"
],
"score": 0.625
}
],
"maxScore": 0.658
}
@@ -0,0 +1,18 @@
{
"totalHits": 1,
"hits": [
{
"resource": "dashboards",
"name": "timeseries-formats",
"title": "Panel Tests - Timeseries - Supported input formats",
"field": {
"panel_types": [
"table",
"text",
"timeseries"
]
}
}
],
"maxScore": 1.778
}
+3 -3
View File
@@ -62,7 +62,7 @@ func TestIntegrationTestDatasource(t *testing.T) {
t.Run("Admin configs", func(t *testing.T) {
client := helper.Org1.Admin.ResourceClient(t, schema.GroupVersionResource{
Group: "testdata.datasource.grafana.app",
Group: "grafana-testdata-datasource.datasource.grafana.app",
Version: "v0alpha1",
Resource: "datasources",
}).Namespace("default")
@@ -92,7 +92,7 @@ func TestIntegrationTestDatasource(t *testing.T) {
t.Run("Call subresources", func(t *testing.T) {
client := helper.Org1.Admin.ResourceClient(t, schema.GroupVersionResource{
Group: "testdata.datasource.grafana.app",
Group: "grafana-testdata-datasource.datasource.grafana.app",
Version: "v0alpha1",
Resource: "datasources",
}).Namespace("default")
@@ -128,7 +128,7 @@ func TestIntegrationTestDatasource(t *testing.T) {
raw := apis.DoRequest[any](helper, apis.RequestParams{
User: helper.Org1.Admin,
Method: "GET",
Path: "/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/default/datasources/test/resource",
Path: "/apis/grafana-testdata-datasource.datasource.grafana.app/v0alpha1/namespaces/default/datasources/test/resource",
}, nil)
// endpoint is disabled currently because it has not been
// sufficiently tested.
+1 -1
View File
@@ -44,6 +44,6 @@ func TestIntegrationFeatures(t *testing.T) {
"value": true,
"key":"`+flag+`",
"reason":"static provider evaluation result",
"variant":"enabled"}`, string(rsp.Body))
"variant":"default"}`, string(rsp.Body))
})
}
+2 -6
View File
@@ -2054,9 +2054,7 @@ func TestIntegrationDeleteFolderWithProvisionedDashboards(t *testing.T) {
DualWriterMode: modeDw,
},
},
EnableFeatureToggles: []string{
featuremgmt.FlagUnifiedStorageSearch,
},
UnifiedStorageEnableSearch: true,
}
setupProvisioningDir(t, &ops)
@@ -2163,9 +2161,7 @@ func TestIntegrationProvisionedFolderPropagatesLabelsAndAnnotations(t *testing.T
DualWriterMode: mode3,
},
},
EnableFeatureToggles: []string{
featuremgmt.FlagUnifiedStorageSearch,
},
UnifiedStorageEnableSearch: true,
}
setupProvisioningDir(t, &ops)
@@ -1830,6 +1830,22 @@
"type": "string"
}
},
{
"name": "panelType",
"in": "query",
"description": "find dashboards using panels of a given plugin type",
"schema": {
"type": "string"
}
},
{
"name": "dataSourceType",
"in": "query",
"description": "find dashboards using datasources of a given plugin type",
"schema": {
"type": "string"
}
},
{
"name": "permission",
"in": "query",
@@ -2,10 +2,10 @@
"openapi": "3.0.0",
"info": {
"description": "Generates test data in different forms",
"title": "testdata.datasource.grafana.app/v0alpha1"
"title": "grafana-testdata-datasource.datasource.grafana.app/v0alpha1"
},
"paths": {
"/apis/testdata.datasource.grafana.app/v0alpha1/": {
"/apis/grafana-testdata-datasource.datasource.grafana.app/v0alpha1/": {
"get": {
"tags": [
"API Discovery"
@@ -36,7 +36,7 @@
}
}
},
"/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/connections/{name}/query": {
"/apis/grafana-testdata-datasource.datasource.grafana.app/v0alpha1/namespaces/{namespace}/connections/{name}/query": {
"post": {
"tags": [
"Connections (deprecated)"
@@ -68,7 +68,7 @@
"deprecated": true,
"x-kubernetes-action": "connect",
"x-kubernetes-group-version-kind": {
"group": "testdata.datasource.grafana.app",
"group": "grafana-testdata-datasource.datasource.grafana.app",
"version": "v0alpha1",
"kind": "QueryDataResponse"
}
@@ -96,7 +96,7 @@
}
]
},
"/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources": {
"/apis/grafana-testdata-datasource.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources": {
"get": {
"tags": [
"DataSource"
@@ -137,7 +137,7 @@
},
"x-kubernetes-action": "list",
"x-kubernetes-group-version-kind": {
"group": "testdata.datasource.grafana.app",
"group": "grafana-testdata-datasource.datasource.grafana.app",
"version": "v0alpha1",
"kind": "DataSource"
}
@@ -254,7 +254,7 @@
}
]
},
"/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}": {
"/apis/grafana-testdata-datasource.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}": {
"get": {
"tags": [
"DataSource"
@@ -285,7 +285,7 @@
},
"x-kubernetes-action": "get",
"x-kubernetes-group-version-kind": {
"group": "testdata.datasource.grafana.app",
"group": "grafana-testdata-datasource.datasource.grafana.app",
"version": "v0alpha1",
"kind": "DataSource"
}
@@ -322,7 +322,7 @@
}
]
},
"/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}/health": {
"/apis/grafana-testdata-datasource.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}/health": {
"get": {
"tags": [
"DataSource"
@@ -343,7 +343,7 @@
},
"x-kubernetes-action": "connect",
"x-kubernetes-group-version-kind": {
"group": "testdata.datasource.grafana.app",
"group": "grafana-testdata-datasource.datasource.grafana.app",
"version": "v0alpha1",
"kind": "HealthCheckResult"
}
@@ -371,7 +371,7 @@
}
]
},
"/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}/query": {
"/apis/grafana-testdata-datasource.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}/query": {
"post": {
"tags": [
"DataSource"
@@ -401,7 +401,7 @@
},
"x-kubernetes-action": "connect",
"x-kubernetes-group-version-kind": {
"group": "testdata.datasource.grafana.app",
"group": "grafana-testdata-datasource.datasource.grafana.app",
"version": "v0alpha1",
"kind": "QueryDataResponse"
}
@@ -429,7 +429,7 @@
}
]
},
"/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}/resource": {
"/apis/grafana-testdata-datasource.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}/resource": {
"get": {
"tags": [
"DataSource"
@@ -450,7 +450,7 @@
},
"x-kubernetes-action": "connect",
"x-kubernetes-group-version-kind": {
"group": "testdata.datasource.grafana.app",
"group": "grafana-testdata-datasource.datasource.grafana.app",
"version": "v0alpha1",
"kind": "Status"
}
@@ -478,7 +478,7 @@
}
]
},
"/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/queryconvert/{name}": {
"/apis/grafana-testdata-datasource.datasource.grafana.app/v0alpha1/namespaces/{namespace}/queryconvert/{name}": {
"post": {
"tags": [
"QueryDataRequest"
@@ -499,7 +499,7 @@
},
"x-kubernetes-action": "connect",
"x-kubernetes-group-version-kind": {
"group": "testdata.datasource.grafana.app",
"group": "grafana-testdata-datasource.datasource.grafana.app",
"version": "v0alpha1",
"kind": "QueryDataRequest"
}
@@ -620,7 +620,7 @@
"apiVersion": {
"type": "string",
"enum": [
"testdata.datasource.grafana.app/v0alpha1"
"grafana-testdata-datasource.datasource.grafana.app/v0alpha1"
]
},
"kind": {
@@ -660,7 +660,7 @@
},
"x-kubernetes-group-version-kind": [
{
"group": "testdata.datasource.grafana.app",
"group": "grafana-testdata-datasource.datasource.grafana.app",
"kind": "DataSource",
"version": "v0alpha1"
}
@@ -703,7 +703,7 @@
},
"x-kubernetes-group-version-kind": [
{
"group": "testdata.datasource.grafana.app",
"group": "grafana-testdata-datasource.datasource.grafana.app",
"kind": "DataSourceList",
"version": "v0alpha1"
}
@@ -744,7 +744,7 @@
},
"x-kubernetes-group-version-kind": [
{
"group": "testdata.datasource.grafana.app",
"group": "grafana-testdata-datasource.datasource.grafana.app",
"kind": "HealthCheckResult",
"version": "v0alpha1"
}
@@ -833,7 +833,7 @@
},
"x-kubernetes-group-version-kind": [
{
"group": "testdata.datasource.grafana.app",
"group": "grafana-testdata-datasource.datasource.grafana.app",
"kind": "QueryDataResponse",
"version": "v0alpha1"
}
+1 -1
View File
@@ -124,7 +124,7 @@ func TestIntegrationOpenAPIs(t *testing.T) {
Group: "shorturl.grafana.app",
Version: "v1beta1",
}, {
Group: "testdata.datasource.grafana.app",
Group: "grafana-testdata-datasource.datasource.grafana.app",
Version: "v0alpha1",
}, {
Group: "logsdrilldown.grafana.app",
+4 -1
View File
@@ -15,7 +15,10 @@ import (
func TestMain(m *testing.M) {
// make sure we don't leak goroutines after tests in this package have
// finished, which means we haven't leaked contexts either
goleak.VerifyTestMain(m)
// (Except for goroutines running specific functions. If possible we should fix this.)
goleak.VerifyTestMain(m,
goleak.IgnoreTopFunction("github.com/open-feature/go-sdk/openfeature.(*eventExecutor).startEventListener.func1.1"),
)
}
func TestTestContextFunc(t *testing.T) {
+1
View File
@@ -29,6 +29,7 @@
"unicons/bookmark",
"unicons/book-open",
"unicons/brackets-curly",
"unicons/brain",
"unicons/bug",
"unicons/building",
"unicons/calculator-alt",
@@ -70,7 +70,7 @@ exports[`PanelAlertTabContent Will render alerts belonging to panel and a button
"refId": "B",
"type": "reduce",
},
"queryType": "expression",
"queryType": "",
"refId": "B",
},
{
@@ -88,9 +88,7 @@ exports[`PanelAlertTabContent Will render alerts belonging to panel and a button
"type": "and",
},
"query": {
"params": [
"C",
],
"params": [],
},
"reducer": {
"params": [],
@@ -107,7 +105,7 @@ exports[`PanelAlertTabContent Will render alerts belonging to panel and a button
"refId": "C",
"type": "threshold",
},
"queryType": "expression",
"queryType": "",
"refId": "C",
},
],
@@ -1,289 +0,0 @@
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from 'test/test-utils';
import { setupMswServer } from 'app/features/alerting/unified/mockApi';
import { grantUserPermissions } from 'app/features/alerting/unified/mocks';
import { AccessControlAction } from 'app/types/accessControl';
import { GrafanaGroupUpdatedResponse } from '../api/alertRuleModel';
import { ContactPoint, RuleFormType, RuleFormValues } from '../types/rule-form';
import { AlertRuleDrawerForm, AlertRuleDrawerFormProps } from './AlertRuleDrawerForm';
setupMswServer();
// Mock the hooks
const mockExecute = jest.fn();
jest.mock('../hooks/ruleGroup/useUpsertRuleFromRuleGroup', () => ({
useAddRuleToRuleGroup: () => [{ execute: mockExecute }],
}));
// Mock notification hooks
const mockError = jest.fn();
const mockSuccess = jest.fn();
jest.mock('app/core/copy/appNotification', () => ({
useAppNotification: () => ({
error: mockError,
success: mockSuccess,
}),
}));
const defaultProps: AlertRuleDrawerFormProps = {
isOpen: true,
onClose: jest.fn(),
};
const renderDrawer = (props: Partial<AlertRuleDrawerFormProps> = {}) => {
return render(<AlertRuleDrawerForm {...defaultProps} {...props} />);
};
describe('AlertRuleDrawerForm', () => {
beforeEach(() => {
jest.clearAllMocks();
grantUserPermissions([
AccessControlAction.AlertingRuleCreate,
AccessControlAction.AlertingRuleRead,
AccessControlAction.AlertingRuleUpdate,
AccessControlAction.AlertingRuleDelete,
]);
});
describe('Rendering', () => {
it('should not render when isOpen is false', () => {
renderDrawer({ isOpen: false });
expect(screen.queryByRole('button', { name: /Create/i })).not.toBeInTheDocument();
});
it('should render "Continue in Alerting" button when callback is provided', () => {
renderDrawer({ onContinueInAlerting: jest.fn() });
expect(screen.getByRole('button', { name: /Continue in Alerting/i })).toBeInTheDocument();
});
});
describe('Cancel button', () => {
it('should call onClose when Cancel is clicked', async () => {
const user = userEvent.setup();
const onClose = jest.fn();
renderDrawer({ onClose });
await user.click(screen.getByRole('button', { name: /Cancel/i }));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('should reset form when Cancel is clicked with prefill', async () => {
const user = userEvent.setup();
const onClose = jest.fn();
const prefill: Partial<RuleFormValues> = {
name: 'Prefilled Rule Name',
};
const { rerender } = renderDrawer({ onClose, prefill });
// Verify prefilled value is present
const nameInput = screen.getByLabelText(/Name/i);
expect(nameInput).toHaveValue('Prefilled Rule Name');
// Modify the field
await user.clear(nameInput);
await user.type(nameInput, 'Changed Name');
expect(nameInput).toHaveValue('Changed Name');
// Click cancel - this triggers reset to prefill
await user.click(screen.getByRole('button', { name: /Cancel/i }));
expect(onClose).toHaveBeenCalled();
// Reopen to verify reset happened
rerender(<AlertRuleDrawerForm {...defaultProps} onClose={onClose} prefill={prefill} isOpen={true} />);
expect(screen.getByLabelText(/Name/i)).toHaveValue('Prefilled Rule Name');
});
});
describe('Continue in Alerting button', () => {
it('should call onContinueInAlerting with current form values', async () => {
const user = userEvent.setup();
const onContinueInAlerting = jest.fn();
const onClose = jest.fn();
renderDrawer({ onContinueInAlerting, onClose });
// Fill in a field
const nameInput = screen.getByLabelText(/Name/i);
await user.type(nameInput, 'Test Rule');
// Click Continue in Alerting
await user.click(screen.getByRole('button', { name: /Continue in Alerting/i }));
await waitFor(() => {
expect(onContinueInAlerting).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Test Rule',
})
);
});
});
it('should normalize contact points when calling onContinueInAlerting', async () => {
const user = userEvent.setup();
const onContinueInAlerting = jest.fn();
const onClose = jest.fn();
// Provide prefill with partial contact point data
// We intentionally create an incomplete ContactPoint to test that normalizeContactPoints fills in the missing optional fields with defaults
const prefill: Partial<RuleFormValues> = {
name: 'Test',
contactPoints: {
grafana: {
selectedContactPoint: 'test-contact',
} as ContactPoint,
},
};
renderDrawer({ onContinueInAlerting, onClose, prefill });
// Click Continue in Alerting
await user.click(screen.getByRole('button', { name: /Continue in Alerting/i }));
await waitFor(() => {
expect(onContinueInAlerting).toHaveBeenCalledWith(
expect.objectContaining({
contactPoints: expect.objectContaining({
grafana: expect.objectContaining({
selectedContactPoint: 'test-contact',
// Verify normalization added default values
overrideGrouping: false,
groupBy: [],
overrideTimings: false,
groupWaitValue: '',
groupIntervalValue: '',
repeatIntervalValue: '',
muteTimeIntervals: [],
activeTimeIntervals: [],
}),
}),
})
);
});
});
it('should close drawer after calling onContinueInAlerting', async () => {
const user = userEvent.setup();
const onContinueInAlerting = jest.fn();
const onClose = jest.fn();
renderDrawer({ onContinueInAlerting, onClose });
// Click Continue in Alerting
await user.click(screen.getByRole('button', { name: /Continue in Alerting/i }));
await waitFor(() => {
expect(onContinueInAlerting).toHaveBeenCalled();
});
expect(onClose).toHaveBeenCalled();
});
});
describe('Prefill behavior', () => {
it('should initialize form with prefill values', () => {
const prefill: Partial<RuleFormValues> = {
name: 'Prefilled Rule',
type: RuleFormType.grafana,
};
renderDrawer({ prefill });
expect(screen.getByLabelText(/Name/i)).toHaveValue('Prefilled Rule');
});
it('should reset form when prefill changes', async () => {
const prefill1: Partial<RuleFormValues> = {
name: 'First Rule',
};
const { rerender } = render(<AlertRuleDrawerForm {...defaultProps} prefill={prefill1} />);
expect(screen.getByLabelText(/Name/i)).toHaveValue('First Rule');
// Update prefill
const prefill2: Partial<RuleFormValues> = {
name: 'Second Rule',
};
rerender(<AlertRuleDrawerForm {...defaultProps} prefill={prefill2} />);
// Wait for the useEffect to trigger the reset
await waitFor(() => {
expect(screen.getByLabelText(/Name/i)).toHaveValue('Second Rule');
});
});
it('should reset to defaults when prefill becomes undefined', async () => {
const prefill: Partial<RuleFormValues> = {
name: 'Prefilled Rule',
};
const { rerender } = render(<AlertRuleDrawerForm {...defaultProps} prefill={prefill} />);
expect(screen.getByLabelText(/Name/i)).toHaveValue('Prefilled Rule');
// Clear prefill
rerender(<AlertRuleDrawerForm {...defaultProps} prefill={undefined} />);
// Wait for the useEffect to trigger the reset
await waitFor(() => {
expect(screen.getByLabelText(/Name/i)).toHaveValue('');
});
});
});
describe('Create button and submission', () => {
it('should close drawer on successful rule creation', async () => {
const user = userEvent.setup();
const onClose = jest.fn();
const mockResponse: GrafanaGroupUpdatedResponse = {
message: 'Rule created successfully',
created: ['rule-uid'],
};
mockExecute.mockResolvedValue(mockResponse);
// Prefill with required fields (folder and group are required for form submission)
const prefill: Partial<RuleFormValues> = {
name: 'Test Alert Rule',
folder: { title: 'Test Folder', uid: 'test-folder-uid' },
group: 'test-group',
evaluateEvery: '1m',
type: RuleFormType.grafana,
queries: [
{
refId: 'A',
datasourceUid: 'test-ds',
queryType: '',
model: { refId: 'A' },
},
],
condition: 'A',
};
renderDrawer({ onClose, prefill });
// Click Create
await user.click(screen.getByRole('button', { name: /Create/i }));
// Wait for the execute function to be called
await waitFor(() => {
expect(mockExecute).toHaveBeenCalled();
});
// Drawer should close on success
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
it('should show validation error when form is invalid', async () => {
const user = userEvent.setup();
renderDrawer();
// Try to submit without filling required fields (name, folder, group are required)
await user.click(screen.getByRole('button', { name: /Create/i }));
await waitFor(() => {
expect(mockError).toHaveBeenCalledWith('There are errors in the form. Please correct them and try again!');
});
});
});
});
@@ -1,198 +0,0 @@
import { css } from '@emotion/css';
import { useEffect, useMemo } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { Button, Drawer, Stack, useStyles2 } from '@grafana/ui';
import { useAppNotification } from 'app/core/copy/appNotification';
import { getMessageFromError } from 'app/core/utils/errors';
import { RuleDefinitionSection } from 'app/features/alerting/unified/components/RuleDefinitionSection';
import { isCloudGroupUpdatedResponse, isGrafanaGroupUpdatedResponse } from '../api/alertRuleModel';
import { useAddRuleToRuleGroup } from '../hooks/ruleGroup/useUpsertRuleFromRuleGroup';
import { getDefaultFormValues } from '../rule-editor/formDefaults';
import { AlertManagerManualRouting, ContactPoint, RuleFormType, RuleFormValues } from '../types/rule-form';
import { formValuesToRulerGrafanaRuleDTO } from '../utils/rule-form';
import { getRuleGroupLocationFromFormValues } from '../utils/rules';
import { RuleConditionSection } from './RuleConditionSection';
import { RuleNotificationSection } from './RuleNotificationSection';
/**
* Normalizes contact point fields to ensure all properties have defined values.
* This is only needed for the "Continue in Alerting" flow, which passes RuleFormValues
* directly to the rule editor page. The submit flow doesn't need this because
* getNotificationSettingsForDTO (called by formValuesToRulerGrafanaRuleDTO) already
* handles partial/missing fields when building the DTO for the backend.
*/
function normalizeContactPoints(
contactPoints: AlertManagerManualRouting | undefined
): AlertManagerManualRouting | undefined {
if (!contactPoints) {
return contactPoints;
}
const normalized: AlertManagerManualRouting = {};
for (const [alertManager, contactPoint] of Object.entries(contactPoints)) {
if (contactPoint.selectedContactPoint) {
const defaultContactPoint: ContactPoint = {
selectedContactPoint: contactPoint.selectedContactPoint,
overrideGrouping: contactPoint.overrideGrouping ?? false,
groupBy: contactPoint.groupBy ?? [],
overrideTimings: contactPoint.overrideTimings ?? false,
groupWaitValue: contactPoint.groupWaitValue ?? '',
groupIntervalValue: contactPoint.groupIntervalValue ?? '',
repeatIntervalValue: contactPoint.repeatIntervalValue ?? '',
muteTimeIntervals: contactPoint.muteTimeIntervals ?? [],
activeTimeIntervals: contactPoint.activeTimeIntervals ?? [],
};
normalized[alertManager] = defaultContactPoint;
} else {
normalized[alertManager] = contactPoint;
}
}
return normalized;
}
export interface AlertRuleDrawerFormProps {
isOpen: boolean;
onClose: () => void;
title?: string;
onContinueInAlerting?: (values: RuleFormValues) => void;
prefill?: Partial<RuleFormValues>;
}
export function AlertRuleDrawerForm({
isOpen,
onClose,
title,
onContinueInAlerting,
prefill,
}: AlertRuleDrawerFormProps) {
const baseDefaults = useMemo(() => getDefaultFormValues(RuleFormType.grafana), []);
const methods = useForm<RuleFormValues>({
defaultValues: prefill ? { ...baseDefaults, ...prefill } : baseDefaults,
});
const styles = useStyles2(getStyles);
const [addRuleToRuleGroup] = useAddRuleToRuleGroup();
const notifyApp = useAppNotification();
// Keep form in sync if prefill changes between openings
useEffect(() => {
methods.reset(prefill ? { ...baseDefaults, ...prefill } : baseDefaults);
}, [prefill, methods, baseDefaults]);
if (!isOpen) {
return null;
}
const submit = async (values: RuleFormValues) => {
try {
// The drawer doesn't expose a group field to keep the UX simple.
// We derive the group name from the rule name as a sensible default.
// The 'default' fallback should rarely occur since 'name' is a required field.
const groupName =
values.group && values.group.trim().length > 0 ? values.group : values.name?.trim() || 'default';
const effectiveValues: RuleFormValues = { ...values, group: groupName };
const dto = formValuesToRulerGrafanaRuleDTO(effectiveValues);
const groupIdentifier = getRuleGroupLocationFromFormValues(effectiveValues);
const result = await addRuleToRuleGroup.execute(groupIdentifier, dto, effectiveValues.evaluateEvery);
if (isGrafanaGroupUpdatedResponse(result)) {
onClose();
return;
}
// Handle cloud rules error response
if (isCloudGroupUpdatedResponse(result)) {
notifyApp.error('Failed to create rule', result.error);
} else {
// This should not happen with the current discriminated union types
notifyApp.error('Failed to create rule', 'Please review the form and try again.');
}
} catch (err) {
const errorMessage = getMessageFromError(err);
notifyApp.error('Failed to create rule', errorMessage);
}
};
const onInvalid = () => {
notifyApp.error('There are errors in the form. Please correct them and try again!');
};
return (
<Drawer
title={title ?? t('alerting.new-rule-from-panel-button.new-alert-rule', 'New alert rule')}
onClose={onClose}
>
<div className={styles.outer}>
<FormProvider {...methods}>
<RuleDefinitionSection type={RuleFormType.grafana} />
<div className={styles.divider} aria-hidden="true" />
<RuleConditionSection />
<div className={styles.divider} aria-hidden="true" />
<RuleNotificationSection />
<div className={styles.footer}>
<Stack direction="row" gap={1} alignItems="center" justifyContent="flex-end">
<Button
variant="secondary"
type="button"
onClick={() => {
methods.reset(prefill ? { ...baseDefaults, ...prefill } : baseDefaults);
onClose();
}}
>
{t('alerting.common.cancel', 'Cancel')}
</Button>
{onContinueInAlerting && (
<Button
variant="secondary"
type="button"
onClick={() => {
const currentValues = methods.getValues();
onContinueInAlerting({
...currentValues,
contactPoints: normalizeContactPoints(currentValues.contactPoints),
});
onClose();
}}
>
{t('alerting.simplified.continue-in-alerting', 'Continue in Alerting')}
</Button>
)}
<Button
variant="primary"
type="button"
onClick={methods.handleSubmit((values) => submit(values), onInvalid)}
disabled={methods.formState.isSubmitting}
icon={methods.formState.isSubmitting ? 'spinner' : undefined}
>
{t('alerting.simplified.create', 'Create')}
</Button>
</Stack>
</div>
</FormProvider>
</div>
</Drawer>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
outer: css({
paddingLeft: theme.spacing(1),
}),
divider: css({
borderTop: `1px solid ${theme.colors.border.weak}`,
margin: `${theme.spacing(3)} 0`,
width: '100%',
}),
footer: css({
marginTop: theme.spacing(3),
}),
};
}
@@ -1,296 +0,0 @@
import { css } from '@emotion/css';
import { useEffect, useRef, useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { GrafanaTheme2, ReducerID, SelectableValue, getNextRefId } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import {
Combobox,
ComboboxOption,
Icon,
InlineField,
InlineFieldRow,
Input,
Stack,
Text,
useStyles2,
} from '@grafana/ui';
import { EvalFunction } from 'app/features/alerting/state/alertDef';
import { ThresholdSelect } from 'app/features/expressions/components/ThresholdSelect';
import { ToLabel } from 'app/features/expressions/components/ToLabel';
import { ExpressionQuery, ExpressionQueryType, reducerTypes, thresholdFunctions } from 'app/features/expressions/types';
import { isRangeEvaluator } from 'app/features/expressions/utils/expressionTypes';
import { AlertQuery } from 'app/types/unified-alerting-dto';
import { RuleFormValues } from '../types/rule-form';
import { EvaluationGroupFieldRow } from './rule-editor/EvaluationGroupFieldRow';
const ExpressionDatasourceUID = '__expr__';
type LocalSimpleCondition = { whenField?: string; evaluator: { params: number[]; type: EvalFunction } };
/**
* Creates expression queries (reduce + threshold) from a simple condition.
* These expression queries reference the last data query and build a pipeline:
* data query -> reduce expression -> threshold expression
*/
function createExpressionQueries(
simpleCondition: LocalSimpleCondition,
dataQueries: AlertQuery[]
): { reduce: AlertQuery; threshold: AlertQuery; condition: string } {
const lastDataQueryRefId = dataQueries[dataQueries.length - 1].refId;
// Always use the same refIds for expressions to keep them stable
const existingExpressions = dataQueries.filter((q) => q.datasourceUid === ExpressionDatasourceUID);
const reduceRefId = existingExpressions[0]?.refId || getNextRefId(dataQueries);
// Create a temporary query for threshold refId calculation
const tempQueries = [
...dataQueries,
{
refId: reduceRefId,
datasourceUid: ExpressionDatasourceUID,
queryType: 'expression',
model: { refId: reduceRefId },
},
];
const thresholdRefId = existingExpressions[1]?.refId || getNextRefId(tempQueries);
const reduceExpression: ExpressionQuery = {
refId: reduceRefId,
type: ExpressionQueryType.reduce,
datasource: { uid: ExpressionDatasourceUID, type: '__expr__' },
reducer: simpleCondition.whenField || ReducerID.last,
expression: lastDataQueryRefId,
};
const thresholdExpression: ExpressionQuery = {
refId: thresholdRefId,
type: ExpressionQueryType.threshold,
datasource: { uid: ExpressionDatasourceUID, type: '__expr__' },
conditions: [
{
type: 'query',
evaluator: {
params: simpleCondition.evaluator.params,
type: simpleCondition.evaluator.type,
},
operator: { type: 'and' },
query: { params: [thresholdRefId] },
reducer: { params: [], type: 'last' as const },
},
],
expression: reduceRefId,
};
// Expression queries don't need relativeTimeRange - they inherit time range context
// from the data queries they reference. This is consistent with how expressions work
// in the alerting query runner.
return {
reduce: {
refId: reduceRefId,
datasourceUid: ExpressionDatasourceUID,
queryType: 'expression',
model: reduceExpression,
},
threshold: {
refId: thresholdRefId,
datasourceUid: ExpressionDatasourceUID,
queryType: 'expression',
model: thresholdExpression,
},
condition: thresholdRefId,
};
}
/**
* Compares two AlertQuery arrays for expression-relevant equality.
* Only compares the model content of expression queries to determine
* if an update is actually needed.
*/
function areExpressionQueriesEqual(current: AlertQuery[], next: AlertQuery[]): boolean {
const currentExpressions = current.filter((q) => q.datasourceUid === ExpressionDatasourceUID);
const nextExpressions = next.filter((q) => q.datasourceUid === ExpressionDatasourceUID);
if (currentExpressions.length !== nextExpressions.length) {
return false;
}
try {
return JSON.stringify(currentExpressions) === JSON.stringify(nextExpressions);
} catch {
return false;
}
}
export function RuleConditionSection() {
const base = useStyles2(getStyles);
const { watch, setValue, getValues } = useFormContext<RuleFormValues>();
const evaluateFor = watch('evaluateFor') || '0s';
watch('folder');
const [simpleCondition, setSimpleCondition] = useState<LocalSimpleCondition>({
whenField: ReducerID.last,
evaluator: { params: [0], type: EvalFunction.IsAbove },
});
// Track if we're currently updating to prevent infinite loops
const isUpdatingRef = useRef(false);
// Update expression queries whenever simpleCondition changes
// We use a ref flag to prevent the infinite loop that would occur because:
// simpleCondition changes -> effect runs -> setValue updates queries -> queries change -> effect would run again
useEffect(() => {
// Skip if we're already in an update cycle
if (isUpdatingRef.current) {
return;
}
const currentQueries = getValues('queries');
const dataQueries = currentQueries.filter((q) => q.datasourceUid !== ExpressionDatasourceUID);
if (dataQueries.length === 0) {
return;
}
const { reduce, threshold, condition } = createExpressionQueries(simpleCondition, dataQueries);
const newQueries = [...dataQueries, reduce, threshold];
// Only update if the expression queries actually changed
if (!areExpressionQueriesEqual(currentQueries, newQueries)) {
isUpdatingRef.current = true;
setValue('queries', newQueries, { shouldDirty: false, shouldValidate: false });
setValue('condition', condition, { shouldDirty: false, shouldValidate: false });
// Reset the flag after the update cycle completes
requestAnimationFrame(() => {
isUpdatingRef.current = false;
});
}
}, [simpleCondition, getValues, setValue]);
const reducerOptions: Array<ComboboxOption<string>> = reducerTypes
.filter((o) => typeof o.value === 'string')
.map((o) => ({ value: o.value ?? '', label: o.label ?? String(o.value) }));
const onReducerTypeChange = (v: ComboboxOption<string> | null) => {
const value = v?.value ?? ReducerID.last;
setSimpleCondition((prev) => ({ ...prev, whenField: value }));
};
const isRange = isRangeEvaluator(simpleCondition.evaluator.type);
const thresholdFunction = thresholdFunctions.find((fn) => fn.value === simpleCondition.evaluator?.type);
const onEvalFunctionChange = (v: SelectableValue<EvalFunction>) => {
setSimpleCondition((prev) => ({
...prev,
evaluator: { ...prev.evaluator, type: v.value ?? EvalFunction.IsAbove },
}));
};
const onEvaluateValueChange = (e: React.FormEvent<HTMLInputElement>, index = 0) => {
const value = parseFloat(e.currentTarget.value) || 0;
setSimpleCondition((prev) => ({
...prev,
evaluator: {
...prev.evaluator,
params: index === 0 ? [value, prev.evaluator.params[1]] : [prev.evaluator.params[0], value],
},
}));
};
return (
<section className={base.section} aria-labelledby="condition-section-heading">
<div className={base.sectionHeaderRow}>
<Text element="h3" variant="h4" id="condition-section-heading">
{`2. `}
<Trans i18nKey="alerting.simplified.condition.title">Condition</Trans>
</Text>
</div>
<div>
<Stack direction="column" gap={2}>
<InlineFieldRow>
{simpleCondition.whenField && (
<InlineField label={t('alerting.simple-condition-editor.label-when', 'WHEN')}>
<Combobox
options={reducerOptions}
value={simpleCondition.whenField}
onChange={onReducerTypeChange}
width={20}
aria-label={t('alerting.simple-condition-editor.aria-label-reducer', 'Select reducer function')}
/>
</InlineField>
)}
<InlineField
label={
simpleCondition.whenField
? t('alerting.simple-condition-editor.label-of-query', 'OF QUERY')
: t('alerting.simple-condition-editor.label-when-query', 'WHEN QUERY')
}
>
<Stack direction="row" gap={1} alignItems="center">
<ThresholdSelect onChange={onEvalFunctionChange} value={thresholdFunction} />
{isRange ? (
<>
<Input
type="number"
width={10}
key={simpleCondition.evaluator.params[0]}
defaultValue={simpleCondition.evaluator.params[0] ?? ''}
onBlur={(event) => onEvaluateValueChange(event, 0)}
aria-label={t(
'alerting.simple-condition-editor.aria-label-threshold-from',
'Threshold from value'
)}
/>
<ToLabel />
<Input
type="number"
width={10}
key={simpleCondition.evaluator.params[1]}
defaultValue={simpleCondition.evaluator.params[1] ?? ''}
onBlur={(event) => onEvaluateValueChange(event, 1)}
aria-label={t('alerting.simple-condition-editor.aria-label-threshold-to', 'Threshold to value')}
/>
</>
) : (
<Input
type="number"
width={10}
key={simpleCondition.evaluator.params[0]}
defaultValue={simpleCondition.evaluator.params[0] ?? ''}
onBlur={(event) => onEvaluateValueChange(event, 0)}
aria-label={t('alerting.simple-condition-editor.aria-label-threshold', 'Threshold value')}
/>
)}
</Stack>
</InlineField>
</InlineFieldRow>
<EvaluationGroupFieldRow enableProvisionedGroups={false} />
{evaluateFor === '0s' && (
<Stack direction="row" gap={0.5} alignItems="center">
<Icon name="exclamation-triangle" aria-hidden="true" />
<Text variant="bodySmall" color="secondary">
<Trans i18nKey="alerting.simplified.evaluation.immediate-warning">
Immediate firing might lead to unnecessary alerts being sent for temporary issues
</Trans>
</Text>
</Stack>
)}
</Stack>
</div>
</section>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
section: css({ width: '100%' }),
sectionHeaderRow: css({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
marginBottom: theme.spacing(1),
}),
};
}
@@ -1,116 +0,0 @@
import { css } from '@emotion/css';
import { useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n';
import { Field, Input, Stack, Text, useStyles2 } from '@grafana/ui';
import { RuleFormType, RuleFormValues } from '../types/rule-form';
import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
import { isCloudRecordingRuleByType, isGrafanaManagedRuleByType, isRecordingRuleByType } from '../utils/rules';
import { FolderSelectorV2 } from './rule-editor/FolderSelectorV2';
import { LabelsEditorModal } from './rule-editor/labels/LabelsEditorModal';
import { LabelsFieldInFormV2 } from './rule-editor/labels/LabelsFieldInFormV2';
export function RuleDefinitionSection({ type }: { type: RuleFormType }) {
const styles = useStyles2(getStyles);
const {
register,
formState: { errors },
setValue,
getValues,
} = useFormContext<RuleFormValues>();
const [showLabelsEditor, setShowLabelsEditor] = useState(false);
const isRecording = isRecordingRuleByType(type);
const isCloudRecordingRule = isCloudRecordingRuleByType(type);
const namePlaceholder = isRecording ? 'recording rule' : 'alert rule';
return (
<section className={styles.section} aria-labelledby="rule-definition-section-heading">
<div className={styles.sectionHeaderRow}>
<Text element="h3" variant="h4" id="rule-definition-section-heading">
{`1. `}
<Trans i18nKey="alerting.simplified.rule-definition">Rule Definition</Trans>
</Text>
</div>
<div>
<Stack direction="column" gap={2}>
<Field
noMargin
label={
<Text variant="bodySmall" weight="medium">
<Trans i18nKey="alerting.alert-rule-name-and-metric.label-name">Name</Trans>
</Text>
}
error={errors?.name?.message}
invalid={!!errors.name?.message}
>
<Input
data-testid={selectors.components.AlertRules.ruleNameField}
id="name"
width={38}
{...register('name', {
required: {
value: true,
message: t('alerting.alert-rule-name-and-metric.message.must-enter-a-name', 'Must enter a name'),
},
pattern: isCloudRecordingRule
? {
value: /^[a-zA-Z_:][a-zA-Z0-9_:]*$/,
message: t(
'alerting.alert-rule-name-and-metric.recording-rule-pattern',
'Recording rule name must be valid metric name. It may only contain letters, numbers, and colons. It may not contain whitespace.'
),
}
: undefined,
})}
aria-label={t('alerting.alert-rule-name-and-metric.aria-label-name', 'name')}
placeholder={t(
'alerting.alert-rule-name-and-metric.placeholder-name',
'Give your {{namePlaceholder}} a name',
{ namePlaceholder }
)}
/>
</Field>
{isGrafanaManagedRuleByType(type) && (
<>
<FolderSelectorV2 />
<LabelsFieldInFormV2 onEditClick={() => setShowLabelsEditor(true)} />
<LabelsEditorModal
isOpen={showLabelsEditor}
onClose={(labelsToUpdate) => {
if (labelsToUpdate) {
const filtered = labelsToUpdate.filter(
(l) => (l?.key ?? '').length > 0 || (l?.value ?? '').length > 0
);
setValue('labels', filtered, { shouldDirty: true, shouldValidate: true });
}
setShowLabelsEditor(false);
}}
dataSourceName={GRAFANA_RULES_SOURCE_NAME}
initialLabels={getValues('labels')}
/>
</>
)}
</Stack>
</div>
</section>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
section: css({ width: '100%' }),
sectionHeaderRow: css({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
marginBottom: theme.spacing(1),
}),
};
}
@@ -1,341 +0,0 @@
import { css } from '@emotion/css';
import { useCallback, useMemo } from 'react';
import { useFormContext } from 'react-hook-form';
import { notificationsAPIv0alpha1 } from '@grafana/alerting/unstable';
import { type GrafanaTheme2, textUtil } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import {
Button,
Combobox,
ComboboxOption,
Field,
Input,
Label,
RadioButtonGroup,
Stack,
Text,
TextArea,
TextLink,
useStyles2,
} from '@grafana/ui';
import { useAppNotification } from 'app/core/copy/appNotification';
import { RuleFormValues } from '../types/rule-form';
import { Annotation } from '../utils/constants';
import { NeedHelpInfoForNotificationPolicy } from './rule-editor/NotificationsStep';
// Centralized form path for selected contact point
const CONTACT_POINT_PATH = 'contactPoints.grafana.selectedContactPoint' as const;
/**
* Validates a URL string and returns an error message if invalid.
* Uses the URL constructor for parsing and rejects dangerous protocols.
* Returns undefined if the URL is valid or empty.
*/
function validateRunbookUrl(value: string): string | undefined {
const trimmedValue = value.trim();
// Empty values are allowed (field is optional)
if (!trimmedValue) {
return undefined;
}
try {
const url = new URL(trimmedValue);
// Reject dangerous URL schemes per F4 frontend security rule
if (url.protocol === 'javascript:' || url.protocol === 'data:' || url.protocol === 'vbscript:') {
return t(
'alerting.simplified.notification.runbook-url.invalid-protocol',
'Invalid URL protocol. Please use http or https.'
);
}
return undefined;
} catch {
return t(
'alerting.simplified.notification.runbook-url.invalid-format',
'Invalid URL format. Please enter a valid URL.'
);
}
}
/**
* Sanitizes a URL value before storing it in the form.
* Uses textUtil.sanitizeUrl to ensure safe URL handling.
*/
function sanitizeRunbookUrl(value: string): string {
const trimmedValue = value.trim();
if (!trimmedValue) {
return '';
}
// Use textUtil.sanitizeUrl for consistent URL sanitization across the codebase
return textUtil.sanitizeUrl(trimmedValue);
}
export function RuleNotificationSection() {
const styles = useStyles2(getStyles);
const notifyApp = useAppNotification();
const { watch, setValue } = useFormContext<RuleFormValues>();
const manualRouting = watch('manualRouting');
const useNotificationPolicy = !manualRouting;
const selectedContactPoint = watch(CONTACT_POINT_PATH);
const annotations = watch('annotations');
// Fetch contact points from Alerting API v0alpha1
const { currentData, status, refetch } = notificationsAPIv0alpha1.endpoints.listReceiver.useQuery({});
const options = useMemo<Array<ComboboxOption<string>>>(
() =>
(currentData?.items ?? []).map((item) => ({
value: item?.spec?.title ?? '',
label: item?.spec?.title ?? '',
})),
[currentData]
);
// Helper functions to get and set annotation values
const getAnnotationValue = useCallback(
(key: string) => {
return annotations.find((a) => a.key === key)?.value ?? '';
},
[annotations]
);
const updateAnnotationValue = useCallback(
(key: string, value: string) => {
const updatedAnnotations = [...annotations];
const index = updatedAnnotations.findIndex((a) => a.key === key);
if (index >= 0) {
updatedAnnotations[index] = { key, value };
} else {
updatedAnnotations.push({ key, value });
}
setValue('annotations', updatedAnnotations, { shouldDirty: true, shouldValidate: true });
},
[annotations, setValue]
);
const summaryValue = getAnnotationValue(Annotation.summary);
const descriptionValue = getAnnotationValue(Annotation.description);
const runbookUrlValue = getAnnotationValue(Annotation.runbookURL);
// Validate runbook URL for form-level validation feedback
const runbookUrlError = useMemo(() => validateRunbookUrl(runbookUrlValue), [runbookUrlValue]);
const recipientLabelId = 'recipient-label';
return (
<section className={styles.section} aria-labelledby="notification-section-heading">
<div className={styles.sectionHeaderRow}>
<Text element="h3" variant="h4" id="notification-section-heading">
{`3. `}
<Trans i18nKey="alerting.simplified.notification.title">Notification</Trans>
</Text>
</div>
<div>
<Stack direction="column" gap={2}>
<Stack direction="column" gap={1}>
<Stack direction="column" gap={1}>
<Stack direction="row" alignItems="end" justifyContent="space-between" gap={1}>
<Label htmlFor={recipientLabelId}>
<span id={recipientLabelId}>
{t('alerting.simplified.notification.recipient.label', 'Recipient')}
</span>
</Label>
<div className={styles.manualRoutingInline}>
<RadioButtonGroup
size="sm"
options={[
{
label: t(
'alerting.manual-and-automatic-routing.routing-options.label.contact-point',
'Contact point'
),
value: 'contact',
},
{
label: t(
'alerting.manual-and-automatic-routing.routing-options.label.notification-policy',
'Notification policy'
),
value: 'policy',
},
]}
value={manualRouting ? 'contact' : 'policy'}
onChange={(val: 'contact' | 'policy') => {
const next = val === 'contact';
setValue('manualRouting', next, { shouldDirty: true, shouldValidate: true });
setValue('editorSettings.simplifiedNotificationEditor', next, {
shouldDirty: true,
shouldValidate: true,
});
}}
aria-label={t('alerting.simplified.notification.manual-routing.aria', 'Toggle manual routing')}
/>
</div>
</Stack>
<Text variant="bodySmall" color="secondary">
{useNotificationPolicy ? (
<Trans i18nKey="alerting.simplified.notification.policy-selected">
Notifications for firing alerts are routed to contact points based on matching labels and the
notification policy tree.
</Trans>
) : (
<Trans i18nKey="alerting.simplified.notification.contact-point-selected">
Notifications for firing alerts are routed to a selected contact point.
</Trans>
)}
</Text>
</Stack>
{useNotificationPolicy ? (
<div className={styles.contentTopSpacer}>
<NeedHelpInfoForNotificationPolicy />
</div>
) : (
<div className={styles.contentTopSpacer}>
<Stack direction="row" gap={1} alignItems="center">
<Combobox<ComboboxOption<string>['value']>
options={options}
value={
selectedContactPoint ? (options.find((o) => o.value === selectedContactPoint) ?? null) : null
}
onChange={(opt) =>
setValue(CONTACT_POINT_PATH, opt?.value ?? '', {
shouldDirty: true,
shouldValidate: true,
})
}
width={30}
placeholder={t(
'alerting.simplified.notification.select-contact-point',
'Select a contact point...'
)}
isClearable
data-testid="contact-point"
loading={status === 'pending'}
/>
<Button
icon="sync"
variant="secondary"
fill="text"
size="sm"
aria-label={t('alerting.common.refresh', 'Refresh')}
onClick={async () => {
try {
await refetch();
} catch (error) {
notifyApp.error(
t('alerting.simplified.notification.refresh-error', 'Failed to refresh contact points')
);
}
}}
/>
<TextLink
href={'/alerting/notifications'}
aria-label={t(
'alerting.link-to-contact-points.aria-label-view-or-create-contact-points',
'View or create contact points'
)}
>
<Trans i18nKey="alerting.link-to-contact-points.view-or-create-contact-points">
View or create contact points
</Trans>
</TextLink>
</Stack>
</div>
)}
</Stack>
<Field label={t('alerting.simplified.notification.summary.label', 'Summary (optional)')} noMargin>
<TextArea
id="summary-text-area"
value={summaryValue}
onChange={(e) => updateAnnotationValue(Annotation.summary, e.currentTarget.value)}
placeholder={t(
'alerting.simplified.notification.summary.placeholder',
'Enter a summary of what happened and why…'
)}
aria-label={t('alerting.simplified.notification.summary.aria-label', 'Summary')}
/>
</Field>
<Field label={t('alerting.simplified.notification.description.label', 'Description (optional)')} noMargin>
<TextArea
id="description-text-area"
value={descriptionValue}
onChange={(e) => updateAnnotationValue(Annotation.description, e.currentTarget.value)}
placeholder={t(
'alerting.simplified.notification.description.placeholder',
'Enter a description of what the alert rule does…'
)}
aria-label={t('alerting.simplified.notification.description.aria-label', 'Description')}
/>
</Field>
<Field
label={t('alerting.simplified.notification.runbook-url.label', 'Runbook URL (optional)')}
noMargin
invalid={!!runbookUrlError}
error={runbookUrlError}
>
<Input
id="runbook-url-input"
type="url"
value={runbookUrlValue}
onChange={(e) => {
const value = e.currentTarget.value;
updateAnnotationValue(Annotation.runbookURL, value);
}}
onBlur={(e) => {
const value = e.currentTarget.value.trim();
// Sanitize and update the URL on blur if it's valid
if (value) {
const error = validateRunbookUrl(value);
if (!error) {
// Sanitize the URL before storing
const sanitizedUrl = sanitizeRunbookUrl(value);
if (sanitizedUrl !== value) {
updateAnnotationValue(Annotation.runbookURL, sanitizedUrl);
}
}
}
}}
placeholder={t(
'alerting.simplified.notification.runbook-url.placeholder',
'Enter the webpage where you keep your runbook for the alert…'
)}
aria-label={t('alerting.simplified.notification.runbook-url.aria-label', 'Runbook URL')}
aria-invalid={!!runbookUrlError}
aria-describedby={runbookUrlError ? 'runbook-url-error' : undefined}
/>
</Field>
</Stack>
</div>
</section>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
section: css({ width: '100%' }),
sectionHeaderRow: css({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
marginBottom: theme.spacing(1),
}),
contentTopSpacer: css({ marginTop: theme.spacing(0.5) }),
manualRoutingInline: css({
display: 'inline-flex',
alignItems: 'center',
gap: theme.spacing(0.5),
whiteSpace: 'nowrap',
maxWidth: '100%',
}),
};
}
@@ -27,8 +27,8 @@ export const CreateNewFolder = ({ onCreate }: { onCreate: (folder: Folder) => vo
onClick={() => setIsCreatingFolder(true)}
type="button"
icon="plus"
fill="outline"
variant="secondary"
size="sm"
disabled={!contextSrv.hasPermission(AccessControlAction.FoldersCreate)}
>
<Trans i18nKey="alerting.create-new-folder.new-folder">New folder</Trans>
@@ -24,20 +24,6 @@ jest.mock('react-use', () => ({
useAsync: () => ({ loading: false, value: {} }),
}));
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
config: {
...jest.requireActual('@grafana/runtime').config,
featureToggles: {
createAlertRuleFromPanel: true,
},
},
}));
jest.mock('../../components/AlertRuleDrawerForm', () => ({
AlertRuleDrawerForm: () => null,
}));
describe('Analytics', () => {
it('Sends log info when creating an alert rule from a panel', async () => {
const panel = new PanelModel({
@@ -1,17 +1,14 @@
import { useState } from 'react';
import { useLocation } from 'react-router-dom-v5-compat';
import { useAsync } from 'react-use';
import { urlUtil } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { Alert, Button } from '@grafana/ui';
import { Alert, Button, LinkButton } from '@grafana/ui';
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
import { useSelector } from 'app/types/store';
import { LogMessages, logInfo } from '../../Analytics';
import { AlertRuleDrawerForm } from '../../components/AlertRuleDrawerForm';
import { createPanelAlertRuleNavigation } from '../../utils/navigation';
import { panelToRuleFormValues } from '../../utils/rule-form';
interface Props {
@@ -32,7 +29,6 @@ export const NewRuleFromPanelButton = ({ dashboard, panel, className }: Props) =
// Templating variables are required to update formValues on each variable's change. It's used implicitly by the templating engine
[panel, dashboard, templating]
);
const [isOpen, setIsOpen] = useState(false);
if (loading) {
return (
@@ -58,45 +54,20 @@ export const NewRuleFromPanelButton = ({ dashboard, panel, className }: Props) =
);
}
const { onContinueInAlertingFromDrawer, onButtonClick: onContinueInAlertingButton } = createPanelAlertRuleNavigation(
() => panelToRuleFormValues(panel, dashboard),
location
);
const shouldUseDrawer = config.featureToggles.createAlertRuleFromPanel;
if (shouldUseDrawer) {
return (
<>
<Button
icon="bell"
className={className}
data-testid="create-alert-rule-button-drawer"
onClick={() => {
logInfo(LogMessages.alertRuleFromPanel);
setIsOpen(true);
}}
>
<Trans i18nKey="alerting.new-rule-from-panel-button.new-alert-rule">New alert rule</Trans>
</Button>
<AlertRuleDrawerForm
isOpen={isOpen}
onClose={() => setIsOpen(false)}
onContinueInAlerting={onContinueInAlertingFromDrawer}
prefill={formValues ?? undefined}
/>
</>
);
}
const ruleFormUrl = urlUtil.renderUrl('alerting/new', {
defaults: JSON.stringify(formValues),
returnTo: location.pathname + location.search,
});
return (
<Button
<LinkButton
icon="bell"
onClick={onContinueInAlertingButton}
onClick={() => logInfo(LogMessages.alertRuleFromPanel)}
href={ruleFormUrl}
className={className}
data-testid="create-alert-rule-button"
>
<Trans i18nKey="alerting.new-rule-from-panel-button.new-alert-rule">New alert rule</Trans>
</Button>
</LinkButton>
);
};
@@ -1,195 +0,0 @@
import { css } from '@emotion/css';
import { useId, useMemo, useState } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { Box, Button, Field, Select, Stack, Text, useStyles2 } from '@grafana/ui';
import { RulerRuleGroupDTO } from 'app/types/unified-alerting-dto';
import { useFetchGroupsForFolder } from '../../hooks/useFetchGroupsForFolder';
import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../../rule-editor/formDefaults';
import { RuleFormValues } from '../../types/rule-form';
import { isProvisionedRuleGroup } from '../../utils/rules';
import { ProvisioningBadge } from '../Provisioning';
import { EvaluationGroupCreationModal } from './GrafanaEvaluationBehavior';
export type GroupOption = SelectableValue<string> & { isProvisioned?: boolean };
export function EvaluationGroupFieldRow({ enableProvisionedGroups }: { enableProvisionedGroups: boolean }) {
const styles = useStyles2(getStyles);
const groupInputId = useId();
const {
watch,
setValue,
getValues,
formState: { errors },
control,
} = useFormContext<RuleFormValues>();
const [group, folder] = watch(['group', 'folder']);
const { currentData: rulerNamespace, isLoading: loadingGroups } = useFetchGroupsForFolder(folder?.uid ?? '');
const collator = useMemo(() => new Intl.Collator(), []);
const groupOptions = useMemo<GroupOption[]>(() => {
if (!rulerNamespace) {
return [];
}
const folderGroups = Object.values(rulerNamespace).flat();
return folderGroups
.map<GroupOption>((g: RulerRuleGroupDTO) => {
const provisioned = isProvisionedRuleGroup(g);
return {
label: g.name,
value: g.name,
description: g.interval ?? DEFAULT_GROUP_EVALUATION_INTERVAL,
isDisabled: !enableProvisionedGroups ? provisioned : false,
isProvisioned: provisioned,
};
})
.sort((a, b) => collator.compare(a.label ?? '', b.label ?? ''));
}, [collator, enableProvisionedGroups, rulerNamespace]);
const defaultGroupValue = group ? { value: group, label: group } : undefined;
const [isCreatingEvaluationGroup, setIsCreatingEvaluationGroup] = useState(false);
const onOpenEvaluationGroupCreationModal = () => setIsCreatingEvaluationGroup(true);
const handleEvalGroupCreation = (groupName: string, evaluationInterval: string) => {
setValue('group', groupName);
setValue('evaluateEvery', evaluationInterval);
setIsCreatingEvaluationGroup(false);
};
const label = !folder?.uid
? t(
'alerting.rule-form.evaluation.select-folder-before',
'Select a folder before setting evaluation group and interval'
)
: t('alerting.rule-form.evaluation.evaluation-group-and-interval', 'Evaluation group and interval');
return (
<Stack alignItems="end">
<div className={styles.formContainer}>
<Field
noMargin
label={label}
data-testid="group-picker"
className={styles.formInput}
error={errors.group?.message}
invalid={!!errors.group?.message}
htmlFor="group"
>
<Controller
render={({ field: { ref, ...field }, fieldState }) => (
<Select
disabled={!folder?.uid || loadingGroups}
inputId={groupInputId}
{...field}
onChange={(group) => {
field.onChange(group.label ?? '');
}}
isLoading={loadingGroups}
invalid={Boolean(folder?.uid) && !group && Boolean(fieldState.error)}
cacheOptions
loadingMessage={t(
'alerting.grafana-evaluation-behavior-step.loadingMessage-loading-groups',
'Loading groups...'
)}
defaultValue={defaultGroupValue}
options={groupOptions}
getOptionLabel={(option: GroupOption) => (
<div>
<span>{option.label}</span>
{option.isProvisioned && (
<>
{' '}
<ProvisioningBadge />
</>
)}
</div>
)}
placeholder={t(
'alerting.grafana-evaluation-behavior-step.placeholder-select-an-evaluation-group',
'Select an evaluation group...'
)}
/>
)}
name="group"
control={control}
rules={{
required: {
value: true,
message: t(
'alerting.grafana-evaluation-behavior-step.message.must-enter-a-group-name',
'Must enter a group name'
),
},
}}
/>
</Field>
</div>
<Box gap={1} display={'flex'} alignItems={'center'}>
<Text color="secondary">
<Trans i18nKey="alerting.grafana-evaluation-behavior-step.or">or</Trans>
</Text>
<Button
onClick={onOpenEvaluationGroupCreationModal}
type="button"
icon="plus"
fill="outline"
variant="secondary"
disabled={!folder?.uid}
data-testid={'new-evaluation-group-button'}
>
<Trans i18nKey="alerting.rule-form.evaluation.new-group">New evaluation group</Trans>
</Button>
</Box>
{isCreatingEvaluationGroup && (
<EvaluationGroupCreationModal
onCreate={handleEvalGroupCreation}
onClose={() => setIsCreatingEvaluationGroup(false)}
groupfoldersForGrafana={rulerNamespace}
/>
)}
{getValues('group') && getValues('evaluateEvery') && (
<div className={styles.evaluationContainer}>
<Stack direction="column" gap={0}>
<div className={styles.marginTop}>
<Stack direction="column" gap={1}>
<Trans
i18nKey="alerting.rule-form.evaluation.group-text"
values={{ evaluateEvery: getValues('evaluateEvery') }}
>
All rules in the selected group are evaluated every {{ evaluateEvery: getValues('evaluateEvery') }}.
</Trans>
</Stack>
</div>
</Stack>
</div>
)}
</Stack>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
formContainer: css({
width: '100%',
maxWidth: theme.breakpoints.values.sm,
}),
formInput: css({
flexGrow: 1,
}),
evaluationContainer: css({
color: theme.colors.text.secondary,
maxWidth: `${theme.breakpoints.values.sm}px`,
fontSize: theme.typography.size.sm,
}),
marginTop: css({
marginTop: theme.spacing(1),
}),
};
}
@@ -5,7 +5,7 @@ import { Button, Stack } from '@grafana/ui';
import { formatPrometheusDuration, parsePrometheusDuration, safeParsePrometheusDuration } from '../../utils/time';
const MIN_INTERVAl = config.unifiedAlerting?.minInterval ?? '10s';
const MIN_INTERVAl = config.unifiedAlerting.minInterval ?? '10s';
export const getEvaluationGroupOptions = (minInterval = MIN_INTERVAl) => {
const MIN_OPTIONS_TO_SHOW = 8;
const DEFAULT_INTERVAL_OPTIONS: number[] = [
@@ -1,94 +0,0 @@
import { useCallback } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { Trans, t } from '@grafana/i18n';
import { Field, Icon, Label, Stack, Tooltip } from '@grafana/ui';
import { NestedFolderPicker } from 'app/core/components/NestedFolderPicker/NestedFolderPicker';
import { Folder, RuleFormValues } from '../../types/rule-form';
import { CreateNewFolder } from '../create-folder/CreateNewFolder';
export function FolderSelectorV2() {
const {
formState: { errors },
setValue,
watch,
} = useFormContext<RuleFormValues>();
const resetGroup = useCallback(() => {
setValue('group', '');
}, [setValue]);
const folder = watch('folder');
const handleFolderCreation = (folder: Folder) => {
resetGroup();
setValue('folder', folder);
};
return (
<Stack alignItems="center">
{
<Field
noMargin
label={
<Label
htmlFor="folder"
description={t(
'alerting.folder-selector.description-select-folder',
'Select a folder to store your rule in.'
)}
>
<Stack direction="row" alignItems="center" gap={0.5}>
<Trans i18nKey="alerting.rule-form.folder.label">Folder</Trans>
<Tooltip
content={t(
'alerting.rule-form.folders.help-info',
'Folders are used for storing alert rules. You can extend the access provided by a role to alert rules and assign permissions to individual folders.'
)}
>
<Icon name="info-circle" size="sm" />
</Tooltip>
</Stack>
</Label>
}
error={errors.folder?.message}
data-testid="folder-picker"
>
<Stack direction="column" alignItems="flex-start" gap={1}>
<Controller
render={({ field: { ref, ...field } }) => (
<div style={{ width: 420 }}>
<NestedFolderPicker
permission="view"
showRootFolder={false}
invalid={!!errors.folder?.message}
{...field}
value={folder?.uid}
onChange={(uid, title) => {
if (uid && title) {
setValue('folder', { title, uid });
} else {
setValue('folder', undefined);
}
resetGroup();
}}
/>
</div>
)}
name="folder"
rules={{
required: {
value: true,
message: t('alerting.folder-selector.message.select-a-folder', 'Select a folder'),
},
}}
/>
<CreateNewFolder onCreate={handleFolderCreation} />
</Stack>
</Field>
}
</Stack>
);
}
@@ -439,7 +439,7 @@ export function GrafanaEvaluationBehaviorStep({
);
}
export function EvaluationGroupCreationModal({
function EvaluationGroupCreationModal({
onClose,
onCreate,
groupfoldersForGrafana,
@@ -250,7 +250,7 @@ function AutomaticRooting({ alertUid }: AutomaticRootingProps) {
}
// Auxiliar components to build the texts and descriptions in the NotificationsStep
export function NeedHelpInfoForNotificationPolicy() {
function NeedHelpInfoForNotificationPolicy() {
return (
<NeedHelpInfo
contentText={
@@ -97,7 +97,7 @@ exports[`Can create a new grafana managed alert using simplified routing can cre
"refId": "B",
"type": "reduce",
},
"queryType": "expression",
"queryType": "",
"refId": "B",
},
{
@@ -115,9 +115,7 @@ exports[`Can create a new grafana managed alert using simplified routing can cre
"type": "and",
},
"query": {
"params": [
"C",
],
"params": [],
},
"reducer": {
"params": [],
@@ -134,7 +132,7 @@ exports[`Can create a new grafana managed alert using simplified routing can cre
"refId": "C",
"type": "threshold",
},
"queryType": "expression",
"queryType": "",
"refId": "C",
},
],
@@ -265,7 +263,7 @@ exports[`Can create a new grafana managed alert using simplified routing switch
"refId": "B",
"type": "reduce",
},
"queryType": "expression",
"queryType": "",
"refId": "B",
},
{
@@ -283,9 +281,7 @@ exports[`Can create a new grafana managed alert using simplified routing switch
"type": "and",
},
"query": {
"params": [
"C",
],
"params": [],
},
"reducer": {
"params": [],
@@ -302,7 +298,7 @@ exports[`Can create a new grafana managed alert using simplified routing switch
"refId": "C",
"type": "threshold",
},
"queryType": "expression",
"queryType": "",
"refId": "C",
},
],
@@ -436,7 +432,7 @@ exports[`Can create a new grafana managed alert using simplified routing switch
"refId": "B",
"type": "reduce",
},
"queryType": "expression",
"queryType": "",
"refId": "B",
},
{
@@ -454,9 +450,7 @@ exports[`Can create a new grafana managed alert using simplified routing switch
"type": "and",
},
"query": {
"params": [
"C",
],
"params": [],
},
"reducer": {
"params": [],
@@ -473,7 +467,7 @@ exports[`Can create a new grafana managed alert using simplified routing switch
"refId": "C",
"type": "threshold",
},
"queryType": "expression",
"queryType": "",
"refId": "C",
},
],
@@ -610,7 +604,7 @@ exports[`Can create a new grafana managed alert using simplified routing switch
"refId": "B",
"type": "reduce",
},
"queryType": "expression",
"queryType": "",
"refId": "B",
},
{
@@ -628,9 +622,7 @@ exports[`Can create a new grafana managed alert using simplified routing switch
"type": "and",
},
"query": {
"params": [
"C",
],
"params": [],
},
"reducer": {
"params": [],
@@ -647,7 +639,7 @@ exports[`Can create a new grafana managed alert using simplified routing switch
"refId": "C",
"type": "threshold",
},
"queryType": "expression",
"queryType": "",
"refId": "C",
},
],
@@ -781,7 +773,7 @@ exports[`Can create a new grafana managed alert using simplified routing switch
"refId": "B",
"type": "reduce",
},
"queryType": "expression",
"queryType": "",
"refId": "B",
},
{
@@ -799,9 +791,7 @@ exports[`Can create a new grafana managed alert using simplified routing switch
"type": "and",
},
"query": {
"params": [
"C",
],
"params": [],
},
"reducer": {
"params": [],
@@ -818,7 +808,7 @@ exports[`Can create a new grafana managed alert using simplified routing switch
"refId": "C",
"type": "threshold",
},
"queryType": "expression",
"queryType": "",
"refId": "C",
},
],
@@ -97,7 +97,7 @@ export function ContactPointSelector({ alertManager }: ContactPointSelectorProps
</Stack>
);
}
export function LinkToContactPoints() {
function LinkToContactPoints() {
const hrefToContactPoints = '/alerting/notifications';
return (
<TextLink
@@ -39,12 +39,11 @@ function mapLabelsToOptions(
}
export interface LabelsInRuleProps {
labels: Array<{ key: string; value: string }> | undefined | null;
labels: Array<{ key: string; value: string }>;
}
export const LabelsInRule = ({ labels }: LabelsInRuleProps) => {
const safeLabels = Array.isArray(labels) ? labels : [];
const labelsObj: Record<string, string> = safeLabels.reduce((acc: Record<string, string>, label) => {
const labelsObj: Record<string, string> = labels.reduce((acc: Record<string, string>, label) => {
if (label.key) {
acc[label.key] = label.value;
}
@@ -1,86 +0,0 @@
import { useFormContext, useWatch } from 'react-hook-form';
import { Trans, t } from '@grafana/i18n';
import { Button, Field, Stack, Text } from '@grafana/ui';
import { AIImproveLabelsButtonComponent } from '../../../enterprise-components/AI/AIGenImproveLabelsButton/addAIImproveLabelsButton';
import { RuleFormValues } from '../../../types/rule-form';
import { isGrafanaManagedRuleByType, isRecordingRuleByType } from '../../../utils/rules';
import { LabelsInRule } from './LabelsField';
interface LabelsFieldInFormProps {
onEditClick: () => void;
}
export function LabelsFieldInFormV2({ onEditClick }: LabelsFieldInFormProps) {
const { control, watch } = useFormContext<RuleFormValues>();
// Subscribe to label changes so UI updates when modal saves
const labels = useWatch({ control, name: 'labels' }) ?? [];
const type = watch('type');
const isRecordingRule = type ? isRecordingRuleByType(type) : false;
const isGrafanaManaged = type ? isGrafanaManagedRuleByType(type) : false;
const text = isRecordingRule
? t('alerting.alertform.labels.recording', 'Add labels to your rule.')
: t(
'alerting.alertform.labels.alerting',
'Add labels to your rule for searching, silencing, or routing to a notification policy.'
);
const hasLabels = Array.isArray(labels) && labels.length > 0 && labels.some((label) => label?.key || label?.value);
return (
<Field
noMargin
label={
<Stack direction="row" alignItems="center" gap={0.5}>
<Text variant="bodySmall">
<Trans i18nKey="alerting.labels-field-in-form.labels">Labels</Trans>
</Text>
<Text variant="bodySmall" color="secondary">
{t('alerting.common.optional', '(optional)')}
</Text>
</Stack>
}
>
<Stack direction={'column'} gap={2}>
<Stack direction={'column'} gap={1}>
<Stack direction={'row'} gap={1}>
<Text variant="bodySmall" color="secondary">
{text}
</Text>
</Stack>
{isGrafanaManaged && <AIImproveLabelsButtonComponent />}
</Stack>
<Stack>
{hasLabels ? (
<Stack direction="row" gap={1} alignItems="center">
<LabelsInRule labels={labels} />
<Button variant="secondary" type="button" onClick={onEditClick} size="sm">
<Trans i18nKey="alerting.labels-field-in-form.edit-labels">Edit labels</Trans>
</Button>
</Stack>
) : (
<Stack direction="column" gap={0.5} alignItems="start">
<Text color="secondary">
<Trans i18nKey="alerting.labels-field-in-form.no-labels-selected">No labels selected</Trans>
</Text>
<Button
icon="plus"
type="button"
variant="secondary"
onClick={onEditClick}
size="sm"
data-testid="add-labels-button"
>
<Trans i18nKey="alerting.labels-field-in-form.add-labels">Add labels</Trans>
</Button>
</Stack>
)}
</Stack>
</Stack>
</Field>
);
}
@@ -99,7 +99,7 @@ exports[`RuleEditor grafana managed rules can create new grafana managed alert 1
"refId": "B",
"type": "reduce",
},
"queryType": "expression",
"queryType": "",
"refId": "B",
},
{
@@ -117,9 +117,7 @@ exports[`RuleEditor grafana managed rules can create new grafana managed alert 1
"type": "and",
},
"query": {
"params": [
"C",
],
"params": [],
},
"reducer": {
"params": [],
@@ -136,7 +134,7 @@ exports[`RuleEditor grafana managed rules can create new grafana managed alert 1
"refId": "C",
"type": "threshold",
},
"queryType": "expression",
"queryType": "",
"refId": "C",
},
],
@@ -251,12 +251,10 @@ export function formValuesFromPrefill(rule: Partial<RuleFormValues>): RuleFormVa
parsedRule = alertingAlertRuleFormSchema.parse(rule);
}
return setQueryEditorSettings(
revealHiddenQueries({
...getDefaultFormValues(rule.type),
...parsedRule,
})
);
return revealHiddenQueries({
...getDefaultFormValues(rule.type),
...parsedRule,
});
}
export function formValuesFromExistingRule(rule: RuleWithLocation<RulerRuleDTO>) {
@@ -27,34 +27,8 @@ export function setQueryEditorSettings(values: RuleFormValues): RuleFormValues {
// data queries only
const dataQueries = values.queries.filter((query) => !isExpressionQuery(query.model));
// expression queries only - but filter out invalid ones that don't have a type field
const expressionQueries = values.queries.filter((query): query is AlertQuery<ExpressionQuery> => {
if (!isExpressionQueryInAlert(query)) {
return false;
}
// Check if the expression has a valid type field
// React Hook Form might strip the type field, so we need to check it exists
return 'type' in query.model && query.model.type !== undefined;
});
// If we have data queries but no VALID expressions (e.g., coming from dashboard panel with malformed expressions),
// remove the invalid expressions and set condition to empty so simplified mode can regenerate them
const hasDataQueries = dataQueries.length > 0;
const hasValidExpressions = expressionQueries.length > 0;
const totalExpressions = values.queries.filter((query) => isExpressionQueryInAlert(query)).length;
const hasInvalidExpressions = totalExpressions > expressionQueries.length;
if (hasDataQueries && (!hasValidExpressions || hasInvalidExpressions)) {
return {
...values,
queries: dataQueries, // Only keep data queries, remove invalid expressions
condition: '', // Clear condition so simplified editor can set it
editorSettings: {
simplifiedQueryEditor: true,
simplifiedNotificationEditor: true,
},
};
}
// expression queries only
const expressionQueries = values.queries.filter((query) => isExpressionQueryInAlert(query));
const queryParamsAreTransformable = areQueriesTransformableToSimpleCondition(dataQueries, expressionQueries);
return {
@@ -1,11 +1,7 @@
import { urlUtil } from '@grafana/data';
import { locationService, logInfo } from '@grafana/runtime';
import { ObjectMatcher } from 'app/plugins/datasource/alertmanager/types';
import { RuleGroupIdentifierV2, RuleIdentifier } from 'app/types/unified-alerting';
import { LogMessages } from '../Analytics';
import { createReturnTo } from '../hooks/useReturnTo';
import { RuleFormValues } from '../types/rule-form';
import { stringifyIdentifier } from './rule-id';
import { createRelativeUrl } from './url';
@@ -103,35 +99,3 @@ export const notificationPolicies = {
});
},
};
export const createPanelAlertRuleNavigation = (
getFormValues: () => Promise<Partial<RuleFormValues> | undefined>,
location: { pathname: string; search: string }
) => {
const navigateToAlerting = async (currentValues?: RuleFormValues) => {
logInfo(LogMessages.alertRuleFromPanel);
const updateToDateFormValues = currentValues ?? (await getFormValues());
const ruleFormUrl = urlUtil.renderUrl('/alerting/new', {
defaults: JSON.stringify(updateToDateFormValues),
returnTo: location.pathname + location.search,
});
locationService.push(ruleFormUrl);
};
const onContinueInAlertingFromDrawer = (values: RuleFormValues) => {
void navigateToAlerting(values);
};
const onButtonClick = () => {
void navigateToAlerting(undefined);
};
return {
navigateToAlerting,
onContinueInAlertingFromDrawer,
onButtonClick,
};
};
@@ -1,5 +1,5 @@
import { PromQuery } from '@grafana/prometheus';
import { ExpressionDatasourceUID, ExpressionQuery, ExpressionQueryType } from 'app/features/expressions/types';
import { ExpressionDatasourceUID, ExpressionQueryType } from 'app/features/expressions/types';
import { RuleWithLocation } from 'app/types/unified-alerting';
import {
AlertDataQuery,
@@ -52,35 +52,6 @@ describe('formValuesToRulerGrafanaRuleDTO', () => {
expect(formValuesToRulerGrafanaRuleDTO(formValues)).toMatchSnapshot();
});
it('sets notification_settings.receiver only when manualRouting is true', () => {
const base: RuleFormValues = {
...getDefaultFormValues(),
type: RuleFormType.grafana,
condition: 'A',
contactPoints: {
grafana: {
selectedContactPoint: 'team-receiver',
muteTimeIntervals: [],
activeTimeIntervals: [],
overrideGrouping: false,
overrideTimings: false,
groupBy: [],
groupWaitValue: '',
groupIntervalValue: '',
repeatIntervalValue: '',
},
},
};
// manualRouting false → no notification_settings
const dtoNoManual = formValuesToRulerGrafanaRuleDTO({ ...base, manualRouting: false });
expect(dtoNoManual.grafana_alert.notification_settings).toBeUndefined();
// manualRouting true → notification_settings.receiver present
const dtoManual = formValuesToRulerGrafanaRuleDTO({ ...base, manualRouting: true });
expect(dtoManual.grafana_alert.notification_settings?.receiver).toBe('team-receiver');
});
it('should not save both instant and range type queries', () => {
const defaultValues = getDefaultFormValues();
@@ -452,24 +423,15 @@ describe('getInstantFromDataQuery', () => {
});
});
function isExpressionQuery(model: unknown): model is ExpressionQuery {
return typeof model === 'object' && model !== null && 'type' in model;
}
describe('getDefaultExpressions', () => {
it('should create a reduce expression as the first query', () => {
const result = getDefaultExpressions('B', 'C');
const reduceQuery = result[0];
const { model } = reduceQuery;
const model = reduceQuery.model;
expect(reduceQuery.refId).toBe('B');
expect(reduceQuery.datasourceUid).toBe(ExpressionDatasourceUID);
expect(reduceQuery.queryType).toBe('expression');
if (!isExpressionQuery(model)) {
throw new Error('Expected ExpressionQuery');
}
expect(reduceQuery.queryType).toBe('');
expect(model.type).toBe(ExpressionQueryType.reduce);
expect(model.datasource?.uid).toBe(ExpressionDatasourceUID);
expect(model.reducer).toBe('last');
@@ -478,11 +440,7 @@ describe('getDefaultExpressions', () => {
it('should create reduce expression with proper conditions structure', () => {
const result = getDefaultExpressions('B', 'C');
const reduceQuery = result[0];
const { model } = reduceQuery;
if (!isExpressionQuery(model)) {
throw new Error('Expected ExpressionQuery');
}
const model = reduceQuery.model;
expect(model.conditions).toHaveLength(1);
expect(model.expression).toBe('A');
@@ -508,16 +466,11 @@ describe('getDefaultExpressions', () => {
it('should create a threshold expression as the second query', () => {
const result = getDefaultExpressions('B', 'C');
const thresholdQuery = result[1];
const { model } = thresholdQuery;
const model = thresholdQuery.model;
expect(thresholdQuery.refId).toBe('C');
expect(thresholdQuery.datasourceUid).toBe(ExpressionDatasourceUID);
expect(thresholdQuery.queryType).toBe('expression');
if (!isExpressionQuery(model)) {
throw new Error('Expected ExpressionQuery');
}
expect(thresholdQuery.queryType).toBe('');
expect(model.type).toBe(ExpressionQueryType.threshold);
expect(model.datasource?.uid).toBe(ExpressionDatasourceUID);
});
@@ -525,11 +478,7 @@ describe('getDefaultExpressions', () => {
it('should create threshold expression with proper conditions structure', () => {
const result = getDefaultExpressions('B', 'C');
const thresholdQuery = result[1];
const { model } = thresholdQuery;
if (!isExpressionQuery(model)) {
throw new Error('Expected ExpressionQuery');
}
const model = thresholdQuery.model;
expect(model.conditions).toHaveLength(1);
expect(model.conditions?.[0]).toEqual({
@@ -542,7 +491,7 @@ describe('getDefaultExpressions', () => {
type: 'and',
},
query: {
params: ['C'],
params: [],
},
reducer: {
params: [],
@@ -554,11 +503,7 @@ describe('getDefaultExpressions', () => {
it('should reference the reduce expression in the threshold expression', () => {
const result = getDefaultExpressions('B', 'C');
const thresholdQuery = result[1];
const { model } = thresholdQuery;
if (!isExpressionQuery(model)) {
throw new Error('Expected ExpressionQuery');
}
const model = thresholdQuery.model;
expect(model.expression).toBe('B');
});
@@ -568,10 +513,6 @@ describe('getDefaultExpressions', () => {
const reduceModel = result[0].model;
const thresholdModel = result[1].model;
if (!isExpressionQuery(reduceModel) || !isExpressionQuery(thresholdModel)) {
throw new Error('Expected ExpressionQuery');
}
expect(result[0].refId).toBe('X');
expect(reduceModel.refId).toBe('X');
expect(reduceModel.conditions?.[0].query.params).toEqual([]);
@@ -559,85 +559,14 @@ export const getDefaultRecordingRulesQueries = (
];
};
export const getDefaultExpressions = (...refIds: [string, string] | [string, string, string]): AlertQuery[] => {
export const getDefaultExpressions = (...refIds: [string, string]) => {
const refOne = refIds[0];
const refTwo = refIds[1];
// If a third parameter is provided, use it as the source query refId, otherwise default to 'A'
const sourceRefId = refIds.length === 3 ? refIds[2] : 'A';
const reduceExpression: ExpressionQuery = {
refId: refIds[0],
type: ExpressionQueryType.reduce,
datasource: {
uid: ExpressionDatasourceUID,
type: ExpressionDatasourceRef.type,
},
conditions: [
{
type: 'query',
evaluator: {
params: [],
type: EvalFunction.IsAbove,
},
operator: {
type: 'and',
},
query: {
params: [],
},
reducer: {
params: [],
type: 'last',
},
},
],
reducer: 'last',
expression: sourceRefId,
};
const reduceQuery = getDefaultReduceExpression({ inputRefId: 'A', reduceRefId: refOne });
const thresholdQuery = getDefaultThresholdExpression({ inputRefId: refOne, thresholdRefId: refTwo });
const thresholdExpression: ExpressionQuery = {
refId: refTwo,
type: ExpressionQueryType.threshold,
datasource: {
uid: ExpressionDatasourceUID,
type: ExpressionDatasourceRef.type,
},
conditions: [
{
type: 'query',
evaluator: {
params: [0],
type: EvalFunction.IsAbove,
},
operator: {
type: 'and',
},
query: {
params: [refTwo],
},
reducer: {
params: [],
type: 'last',
},
},
],
expression: refOne,
};
return [
{
refId: refOne,
datasourceUid: ExpressionDatasourceUID,
queryType: 'expression',
model: reduceExpression,
},
{
refId: refTwo,
datasourceUid: ExpressionDatasourceUID,
queryType: 'expression',
model: thresholdExpression,
},
];
return [reduceQuery, thresholdQuery] as const;
};
const getDefaultExpressionsForRecording = (refOne: string): Array<AlertQuery<ExpressionQuery>> => {
@@ -681,6 +610,95 @@ const getDefaultExpressionsForRecording = (refOne: string): Array<AlertQuery<Exp
];
};
function getDefaultReduceExpression({
inputRefId,
reduceRefId,
}: {
inputRefId: string;
reduceRefId: string;
}): AlertQuery<ExpressionQuery> {
const reduceExpression: ExpressionQuery = {
refId: reduceRefId,
type: ExpressionQueryType.reduce,
datasource: {
uid: ExpressionDatasourceUID,
type: ExpressionDatasourceRef.type,
},
conditions: [
{
type: 'query',
evaluator: {
params: [],
type: EvalFunction.IsAbove,
},
operator: {
type: 'and',
},
query: {
params: [],
},
reducer: {
params: [],
type: 'last',
},
},
],
reducer: 'last',
expression: inputRefId,
};
return {
refId: reduceRefId,
datasourceUid: ExpressionDatasourceUID,
queryType: '',
model: reduceExpression,
};
}
function getDefaultThresholdExpression({
inputRefId,
thresholdRefId,
}: {
inputRefId: string;
thresholdRefId: string;
}): AlertQuery<ExpressionQuery> {
const thresholdExpression: ExpressionQuery = {
refId: thresholdRefId,
type: ExpressionQueryType.threshold,
datasource: {
uid: ExpressionDatasourceUID,
type: ExpressionDatasourceRef.type,
},
conditions: [
{
type: 'query',
evaluator: {
params: [0],
type: EvalFunction.IsAbove,
},
operator: {
type: 'and',
},
query: {
params: [],
},
reducer: {
params: [],
type: 'last',
},
},
],
expression: inputRefId,
};
return {
refId: thresholdRefId,
datasourceUid: ExpressionDatasourceUID,
queryType: '',
model: thresholdExpression,
};
}
const dataQueriesToGrafanaQueries = async (
queries: DataQuery[],
relativeTimeRange: RelativeTimeRange,
@@ -765,15 +783,24 @@ export const panelToRuleFormValues = async (
return undefined;
}
// Add default expression queries if they don't exist
const lastQuery = queries.at(-1);
if (!lastQuery) {
return undefined;
}
if (!queries.find((query) => query.datasourceUid === ExpressionDatasourceUID)) {
// Get the last data query's refId to use as the source for the reduce expression
const lastDataQueryRefId = queries[queries.length - 1].refId;
const reduceRefId = getNextRefId(queries);
const queriesWithReduce = [...queries, { refId: reduceRefId, datasourceUid: '', queryType: '', model: {} }];
const thresholdRefId = getNextRefId(queriesWithReduce);
const expressions = getDefaultExpressions(reduceRefId, thresholdRefId, lastDataQueryRefId);
queries.push(...expressions);
const reduceExpression = getDefaultReduceExpression({
inputRefId: lastQuery.refId,
reduceRefId: getNextRefId(queries),
});
queries.push(reduceExpression);
const thresholdExpression = getDefaultThresholdExpression({
inputRefId: reduceExpression.refId,
thresholdRefId: getNextRefId(queries),
});
queries.push(thresholdExpression);
}
const { folderTitle, folderUid } = dashboard.meta;
@@ -843,15 +870,24 @@ export const scenesPanelToRuleFormValues = async (vizPanel: VizPanel): Promise<P
return undefined;
}
// Add default expression queries if they don't exist
const lastQuery = grafanaQueries.at(-1);
if (!lastQuery) {
return undefined;
}
if (!grafanaQueries.find((query) => query.datasourceUid === ExpressionDatasourceUID)) {
// Get the last data query's refId to use as the source for the reduce expression
const lastDataQueryRefId = grafanaQueries[grafanaQueries.length - 1].refId;
const reduceRefId = getNextRefId(grafanaQueries);
const queriesWithReduce = [...grafanaQueries, { refId: reduceRefId, datasourceUid: '', queryType: '', model: {} }];
const thresholdRefId = getNextRefId(queriesWithReduce);
const expressions = getDefaultExpressions(reduceRefId, thresholdRefId, lastDataQueryRefId);
grafanaQueries.push(...expressions);
const reduceExpression = getDefaultReduceExpression({
inputRefId: lastQuery.refId,
reduceRefId: getNextRefId(grafanaQueries),
});
grafanaQueries.push(reduceExpression);
const thresholdExpression = getDefaultThresholdExpression({
inputRefId: reduceExpression.refId,
thresholdRefId: getNextRefId(grafanaQueries),
});
grafanaQueries.push(thresholdExpression);
}
const { folderTitle, folderUid } = dashboard.state.meta;
+2 -3
View File
@@ -28,18 +28,17 @@ describe('DatasourceAPIVersions', () => {
it('get', async () => {
const getMock = jest.fn().mockResolvedValue({
groups: [
{ name: 'testdata.datasource.grafana.app', preferredVersion: { version: 'v1' } },
{ name: 'grafana-testdata-datasource.datasource.grafana.app', preferredVersion: { version: 'v1' } },
{ name: 'prometheus.datasource.grafana.app', preferredVersion: { version: 'v2' } },
{ name: 'myorg-myplugin.datasource.grafana.app', preferredVersion: { version: 'v3' } },
],
});
getBackendSrv().get = getMock;
const apiVersions = new DatasourceAPIVersions();
expect(await apiVersions.get('testdata')).toBe('v1');
expect(await apiVersions.get('grafana-testdata-datasource')).toBe('v1');
expect(await apiVersions.get('prometheus')).toBe('v2');
expect(await apiVersions.get('graphite')).toBeUndefined();
expect(await apiVersions.get('myorg-myplugin-datasource')).toBe('v3');
expect(await apiVersions.get('myorg-myplugin')).toBe('v3');
expect(getMock).toHaveBeenCalledTimes(1);
expect(getMock).toHaveBeenCalledWith('/apis');
});
-11
View File
@@ -162,17 +162,6 @@ export class DatasourceAPIVersions {
if (group.name.includes('datasource.grafana.app')) {
const id = group.name.split('.')[0];
apiVersions[id] = group.preferredVersion.version;
// workaround for plugins that don't append '-datasource' for the group name
// e.g. org-plugin-datasource uses org-plugin.datasource.grafana.app
if (!id.endsWith('-datasource')) {
if (!id.includes('-')) {
// workaroud for Grafana plugins that don't include the org either
// e.g. testdata uses testdata.datasource.grafana.app
apiVersions[`grafana-${id}-datasource`] = group.preferredVersion.version;
} else {
apiVersions[`${id}-datasource`] = group.preferredVersion.version;
}
}
}
});
this.apiVersions = apiVersions;
@@ -4,6 +4,7 @@ import { useAsyncRetry } from 'react-use';
import { GrafanaTheme2, store } from '@grafana/data';
import { t, Trans } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { evaluateBooleanFlag } from '@grafana/runtime/internal';
import { Button, CollapsableSection, Spinner, Stack, Text, useStyles2, Grid } from '@grafana/ui';
import { contextSrv } from 'app/core/services/context_srv';
@@ -35,10 +36,18 @@ export function RecentlyViewedDashboards() {
const { foldersByUid } = useDashboardLocationInfo(recentDashboards.length > 0);
const handleClearHistory = () => {
reportInteraction('grafana_recently_viewed_dashboards_clear_history');
store.set(recentDashboardsKey, JSON.stringify([]));
retry();
};
const handleSectionToggle = () => {
reportInteraction('grafana_recently_viewed_dashboards_toggle_section', {
expanded: !isOpen,
});
setIsOpen(!isOpen);
};
if (!evaluateBooleanFlag('recentlyViewedDashboards', false) || recentDashboards.length === 0) {
return null;
}
@@ -48,7 +57,7 @@ export function RecentlyViewedDashboards() {
headerDataTestId="browseDashboardsRecentlyViewedTitle"
label={
<Stack direction="row" justifyContent="space-between" alignItems="baseline" width="100%">
<Text variant="h5" element="h3" onClick={() => setIsOpen(!isOpen)}>
<Text variant="h5" element="h3" onClick={handleSectionToggle}>
<Trans i18nKey="browse-dashboards.recently-viewed.title">Recently viewed</Trans>
</Text>
<Button icon="times" size="xs" variant="secondary" fill="text" onClick={handleClearHistory}>
@@ -80,9 +89,10 @@ export function RecentlyViewedDashboards() {
{!loading && recentDashboards.length > 0 && (
<ul className={styles.list}>
<Grid columns={{ xs: 1, sm: 2, md: 3, lg: 5 }} gap={2}>
{recentDashboards.map((dash) => (
{recentDashboards.map((dash, idx) => (
<li key={dash.uid} className={styles.listItem}>
<DashListItem
order={idx + 1}
key={dash.uid}
dashboard={dash}
url={dash.url}
@@ -1,14 +1,12 @@
import { useState } from 'react';
import { useLocation } from 'react-router-dom-v5-compat';
import { useAsync } from 'react-use';
import { urlUtil } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { locationService, logInfo } from '@grafana/runtime';
import { VizPanel } from '@grafana/scenes';
import { Alert, Button } from '@grafana/ui';
import { LogMessages, logInfo } from 'app/features/alerting/unified/Analytics';
import { AlertRuleDrawerForm } from 'app/features/alerting/unified/components/AlertRuleDrawerForm';
import { createPanelAlertRuleNavigation } from 'app/features/alerting/unified/utils/navigation';
import { LogMessages } from 'app/features/alerting/unified/Analytics';
import { scenesPanelToRuleFormValues } from 'app/features/alerting/unified/utils/rule-form';
interface ScenesNewRuleFromPanelButtonProps {
@@ -19,7 +17,6 @@ export const ScenesNewRuleFromPanelButton = ({ panel, className }: ScenesNewRule
const location = useLocation();
const { loading, value: formValues } = useAsync(() => scenesPanelToRuleFormValues(panel), [panel]);
const [isOpen, setIsOpen] = useState(false);
if (loading) {
return (
@@ -45,45 +42,22 @@ export const ScenesNewRuleFromPanelButton = ({ panel, className }: ScenesNewRule
);
}
const { onContinueInAlertingFromDrawer, onButtonClick: onContinueInAlertingButton } = createPanelAlertRuleNavigation(
() => scenesPanelToRuleFormValues(panel),
location
);
const onClick = async () => {
logInfo(LogMessages.alertRuleFromPanel);
const shouldUseDrawer = config.featureToggles.createAlertRuleFromPanel;
const updateToDateFormValues = await scenesPanelToRuleFormValues(panel);
if (shouldUseDrawer) {
return (
<>
<Button
icon="bell"
className={className}
data-testid="create-alert-rule-button-drawer"
onClick={() => {
logInfo(LogMessages.alertRuleFromPanel);
setIsOpen(true);
}}
>
<Trans i18nKey="alerting.new-rule-from-panel-button.new-alert-rule">New alert rule</Trans>
</Button>
<AlertRuleDrawerForm
isOpen={isOpen}
onClose={() => setIsOpen(false)}
onContinueInAlerting={onContinueInAlertingFromDrawer}
prefill={formValues ?? undefined}
/>
</>
);
}
const ruleFormUrl = urlUtil.renderUrl('/alerting/new', {
defaults: JSON.stringify(updateToDateFormValues),
returnTo: location.pathname + location.search,
});
locationService.push(ruleFormUrl);
};
return (
<Button
icon="bell"
onClick={onContinueInAlertingButton}
className={className}
data-testid="create-alert-rule-button"
>
<Trans i18nKey="alerting.new-rule-from-panel-button.new-alert-rule">New alert rule</Trans>
<Button icon="bell" onClick={onClick} className={className} data-testid="create-alert-rule-button">
<Trans i18nKey="dashboard-scene.scenes-new-rule-from-panel-button.new-alert-rule">New alert rule</Trans>
</Button>
);
};
@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/consistent-type-assertions */
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
@@ -40,9 +39,9 @@ export class PanelDataPane extends SceneObjectBase<PanelDataPaneState> {
new PanelDataTransformationsTab({ panelRef }),
];
// if (shouldShowAlertingTab(panel.state.pluginId)) {
tabs.push(new PanelDataAlertingTab({ panelRef }));
// }
if (shouldShowAlertingTab(panel.state.pluginId)) {
tabs.push(new PanelDataAlertingTab({ panelRef }));
}
return new PanelDataPane({
panelRef,
@@ -65,7 +65,7 @@ exports[`PanelAlertTabContent Will render alerts belonging to panel and a button
"refId": "B",
"type": "reduce",
},
"queryType": "expression",
"queryType": "",
"refId": "B",
},
{
@@ -83,9 +83,7 @@ exports[`PanelAlertTabContent Will render alerts belonging to panel and a button
"type": "and",
},
"query": {
"params": [
"C",
],
"params": [],
},
"reducer": {
"params": [],
@@ -102,7 +100,7 @@ exports[`PanelAlertTabContent Will render alerts belonging to panel and a button
"refId": "C",
"type": "threshold",
},
"queryType": "expression",
"queryType": "",
"refId": "C",
},
],
@@ -48,9 +48,6 @@ jest.mock('@grafana/runtime', () => ({
featureToggles: {
newVariables: false,
},
unifiedAlerting: {
minInterval: '10s',
},
},
}));
@@ -1,6 +1,13 @@
import { defaultDataQueryKind, PanelQueryKind } from '@grafana/schema/dist/esm/schema/dashboard/v2';
import {
defaultDataQueryKind,
defaultPanelSpec,
PanelKind,
PanelQueryKind,
} from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/constants';
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
import { ensureUniqueRefIds, getRuntimePanelDataSource } from './utils';
import { ensureUniqueRefIds, getPanelDataSource, getRuntimePanelDataSource } from './utils';
describe('getRuntimePanelDataSource', () => {
it('should return uid and type when explicit datasource UID is provided', () => {
@@ -141,6 +148,159 @@ describe('getRuntimePanelDataSource', () => {
});
});
describe('getPanelDataSource', () => {
const createPanelWithQueries = (queries: PanelQueryKind[]): PanelKind => ({
kind: 'Panel',
spec: {
...defaultPanelSpec(),
id: 1,
title: 'Test Panel',
data: {
kind: 'QueryGroup',
spec: {
queries,
queryOptions: {},
transformations: [],
},
},
},
});
const createQuery = (datasourceName: string, group: string, refId = 'A'): PanelQueryKind => ({
kind: 'PanelQuery',
spec: {
refId,
hidden: false,
query: {
kind: 'DataQuery',
version: defaultDataQueryKind().version,
group,
datasource: {
name: datasourceName,
},
spec: {},
},
},
});
const createQueryWithoutDatasourceName = (group: string, refId = 'A'): PanelQueryKind => ({
kind: 'PanelQuery',
spec: {
refId,
hidden: false,
query: {
kind: 'DataQuery',
version: defaultDataQueryKind().version,
group,
spec: {},
},
},
});
it('should return undefined when panel has no queries', () => {
const panel = createPanelWithQueries([]);
const result = getPanelDataSource(panel);
expect(result).toBeUndefined();
});
it('should return undefined for a single query with specific datasource (not mixed)', () => {
const panel = createPanelWithQueries([createQuery('prometheus-uid', 'prometheus')]);
const result = getPanelDataSource(panel);
expect(result).toBeUndefined();
});
it('should return undefined for multiple queries with the same datasource', () => {
const panel = createPanelWithQueries([
createQuery('prometheus-uid', 'prometheus', 'A'),
createQuery('prometheus-uid', 'prometheus', 'B'),
createQuery('prometheus-uid', 'prometheus', 'C'),
]);
const result = getPanelDataSource(panel);
expect(result).toBeUndefined();
});
it('should return mixed datasource when queries use different datasource UIDs', () => {
const panel = createPanelWithQueries([
createQuery('prometheus-uid', 'prometheus', 'A'),
createQuery('loki-uid', 'loki', 'B'),
]);
const result = getPanelDataSource(panel);
expect(result).toEqual({ type: 'mixed', uid: MIXED_DATASOURCE_NAME });
});
it('should return mixed datasource when queries use different datasource types', () => {
const panel = createPanelWithQueries([
createQuery('ds-uid', 'prometheus', 'A'),
createQuery('ds-uid', 'loki', 'B'),
]);
const result = getPanelDataSource(panel);
expect(result).toEqual({ type: 'mixed', uid: MIXED_DATASOURCE_NAME });
});
it('should return mixed datasource when multiple queries use Dashboard datasource', () => {
const panel = createPanelWithQueries([
createQuery(SHARED_DASHBOARD_QUERY, 'datasource', 'A'),
createQuery(SHARED_DASHBOARD_QUERY, 'datasource', 'B'),
createQuery(SHARED_DASHBOARD_QUERY, 'datasource', 'C'),
]);
const result = getPanelDataSource(panel);
expect(result).toEqual({ type: 'mixed', uid: MIXED_DATASOURCE_NAME });
});
it('should return Dashboard datasource when single query uses Dashboard datasource', () => {
const panel = createPanelWithQueries([createQuery(SHARED_DASHBOARD_QUERY, 'datasource')]);
const result = getPanelDataSource(panel);
expect(result).toEqual({ type: 'datasource', uid: SHARED_DASHBOARD_QUERY });
});
it('should return mixed when Dashboard datasource is mixed with other datasources', () => {
const panel = createPanelWithQueries([
createQuery(SHARED_DASHBOARD_QUERY, 'datasource', 'A'),
createQuery('prometheus-uid', 'prometheus', 'B'),
]);
const result = getPanelDataSource(panel);
expect(result).toEqual({ type: 'mixed', uid: MIXED_DATASOURCE_NAME });
});
it('should return undefined when queries have no explicit datasource name but same type', () => {
const panel = createPanelWithQueries([
createQueryWithoutDatasourceName('prometheus', 'A'),
createQueryWithoutDatasourceName('prometheus', 'B'),
]);
const result = getPanelDataSource(panel);
expect(result).toBeUndefined();
});
it('should return mixed when queries have no explicit datasource name but different types', () => {
const panel = createPanelWithQueries([
createQueryWithoutDatasourceName('prometheus', 'A'),
createQueryWithoutDatasourceName('loki', 'B'),
]);
const result = getPanelDataSource(panel);
expect(result).toEqual({ type: 'mixed', uid: MIXED_DATASOURCE_NAME });
});
});
describe('ensureUniqueRefIds', () => {
const createQuery = (refId: string): PanelQueryKind => ({
kind: 'PanelQuery',
@@ -23,6 +23,7 @@ import {
DataQueryKind,
defaultPanelQueryKind,
} from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/constants';
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
import { ConditionalRenderingGroup } from '../../conditional-rendering/group/ConditionalRenderingGroup';
@@ -228,29 +229,45 @@ export function createPanelDataProvider(panelKind: PanelKind): SceneDataProvider
* This ensures v2Scenev1 conversion produces the same output as the Go backend,
* which does NOT add panel-level datasource for non-mixed panels.
*/
function getPanelDataSource(panel: PanelKind): DataSourceRef | undefined {
if (!panel.spec.data?.spec.queries?.length) {
export function getPanelDataSource(panel: PanelKind): DataSourceRef | undefined {
const queries = panel.spec.data?.spec.queries;
if (!queries?.length) {
return undefined;
}
let firstDatasource: DataSourceRef | undefined = undefined;
let isMixedDatasource = false;
// Check if multiple queries use Dashboard datasource - this needs mixed mode
const dashboardDsQueryCount = queries.filter((q) => q.spec.query.datasource?.name === SHARED_DASHBOARD_QUERY).length;
if (dashboardDsQueryCount > 1) {
return { type: 'mixed', uid: MIXED_DATASOURCE_NAME };
}
panel.spec.data.spec.queries.forEach((query) => {
const queryDs = query.spec.query.datasource?.name
// Get all datasources from queries
const datasources = queries.map((query) =>
query.spec.query.datasource?.name
? { uid: query.spec.query.datasource.name, type: query.spec.query.group }
: getRuntimePanelDataSource(query.spec.query);
: getRuntimePanelDataSource(query.spec.query)
);
if (!firstDatasource) {
firstDatasource = queryDs;
} else if (firstDatasource.uid !== queryDs?.uid || firstDatasource.type !== queryDs?.type) {
isMixedDatasource = true;
}
});
const firstDatasource = datasources[0];
// Check if queries use different datasources
const isMixedDatasource = datasources.some(
(ds) => ds?.uid !== firstDatasource?.uid || ds?.type !== firstDatasource?.type
);
if (isMixedDatasource) {
return { type: 'mixed', uid: MIXED_DATASOURCE_NAME };
}
// Handle case where all queries use Dashboard datasource - needs to set datasource for proper data fetching
// See DashboardDatasourceBehaviour.tsx for more details
if (firstDatasource?.uid === SHARED_DASHBOARD_QUERY) {
return { type: 'datasource', uid: SHARED_DASHBOARD_QUERY };
}
// Only return mixed datasource - for non-mixed panels, each query already has its own datasource
// This matches the Go backend behavior which doesn't add panel.datasource for non-mixed panels
return isMixedDatasource ? { type: 'mixed', uid: MIXED_DATASOURCE_NAME } : undefined;
return undefined;
}
/**
@@ -40,9 +40,6 @@ jest.mock('@grafana/runtime', () => ({
featureToggles: {
newVariables: false,
},
unifiedAlerting: {
minInterval: '10s',
},
},
}));
@@ -149,9 +149,6 @@ const getDefaultVisualisationType = (): LogsVisualisationType => {
if (visualisationType === 'logs') {
return 'logs';
}
if (config.featureToggles.logsExploreTableDefaultVisualization) {
return 'table';
}
return 'logs';
};
@@ -447,7 +444,6 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
reportInteraction('grafana_explore_logs_visualisation_changed', {
newVisualizationType: visualisation,
datasourceType: props.datasourceType ?? 'unknown',
defaultVisualisationType: config.featureToggles.logsExploreTableDefaultVisualization ? 'table' : 'logs',
});
},
[panelState?.logs, props.datasourceType, updatePanelState]
@@ -3,9 +3,19 @@ import { Unsubscribable } from 'rxjs';
import { getAppEvents } from '@grafana/runtime';
import { useGrafana } from 'app/core/context/GrafanaContext';
import { AbsoluteTimeEvent, CopyTimeEvent, PasteTimeEvent, ShiftTimeEvent, ZoomOutEvent } from 'app/types/events';
import { getState } from 'app/store/store';
import {
AbsoluteTimeEvent,
CopyTimeEvent,
PasteTimeEvent,
RunQueriesEvent,
ShiftTimeEvent,
ZoomOutEvent,
} from 'app/types/events';
import { useDispatch } from 'app/types/store';
import { runQueries } from '../state/query';
import { selectPanesEntries } from '../state/selectors';
import {
copyTimeRangeToClipboard,
makeAbsoluteTime,
@@ -21,8 +31,23 @@ export function useKeyboardShortcuts() {
useEffect(() => {
keybindings.setupTimeRangeBindings(false);
// Explore-specific: run queries shortcut
keybindings.bind('e r', () => {
getAppEvents().publish(new RunQueriesEvent());
});
const tearDown: Unsubscribable[] = [];
tearDown.push(
getAppEvents().subscribe(RunQueriesEvent, () => {
// Read panes at event time to avoid re-subscribing when panes change
const panes = selectPanesEntries(getState());
panes.forEach(([exploreId]) => {
dispatch(runQueries({ exploreId }));
});
})
);
tearDown.push(
getAppEvents().subscribe(AbsoluteTimeEvent, () => {
dispatch(makeAbsoluteTime());
@@ -54,6 +79,7 @@ export function useKeyboardShortcuts() {
);
return () => {
keybindings.unbind('e r');
tearDown.forEach((u) => u.unsubscribe());
};
}, [dispatch, keybindings]);
@@ -190,6 +190,22 @@ describe('preProcessLogs', () => {
expect(logListModel.body).not.toBe(entry);
});
test('Prettifies JSON with duplicate keys', () => {
const entry = '{"key": "value", "key": "otherValue"}';
const logListModel = createLogLine(
{ entry },
{
escape: false,
order: LogsSortOrder.Descending,
timeZone: 'browser',
wrapLogMessage: true, // wrapped
prettifyJSON: true,
}
);
expect(logListModel.entry).toBe(entry);
expect(logListModel.body).not.toBe(entry);
});
test('Prettifies and escapes wrapped JSON', () => {
const entry = '{"key": "value", "otherKey": "other\\nValue"}';
const logListModel = createLogLine(

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