Compare commits

...

12 Commits

Author SHA1 Message Date
ismail simsek
e3448f34f9 fix some styling issues 2026-01-13 22:43:40 +01:00
ismail simsek
a231cf3318 refactoring 2026-01-13 18:10:18 +01:00
ismail simsek
c923b58ef7 unit tests 2026-01-13 00:06:30 +01:00
ismail simsek
d84652d8aa create abstraction 2026-01-12 23:06:32 +01:00
ismail simsek
317bc94634 implement codemirror instead slate-react 2026-01-12 19:02:46 +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
51 changed files with 4838 additions and 376 deletions

View File

@@ -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 }}"

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'

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

View File

@@ -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"
}
}
}
]
}

View File

@@ -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"
}
}
}
]
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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"
}
}
}
]
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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"
}
}
}
]
}

View File

@@ -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

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.

View File

@@ -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

View File

@@ -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)

View File

@@ -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', () => {

View File

@@ -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 = {};

View File

@@ -527,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;

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

View File

@@ -63,6 +63,11 @@
"not IE 11"
],
"dependencies": {
"@codemirror/autocomplete": "^6.12.0",
"@codemirror/commands": "^6.3.3",
"@codemirror/language": "^6.10.0",
"@codemirror/state": "^6.4.0",
"@codemirror/view": "^6.23.0",
"@emotion/css": "11.13.5",
"@emotion/react": "11.14.0",
"@emotion/serialize": "1.3.3",
@@ -73,6 +78,7 @@
"@grafana/i18n": "12.4.0-pre",
"@grafana/schema": "12.4.0-pre",
"@hello-pangea/dnd": "18.0.1",
"@lezer/highlight": "^1.2.0",
"@monaco-editor/react": "4.7.0",
"@popperjs/core": "2.11.8",
"@rc-component/drawer": "1.3.0",

View File

@@ -0,0 +1,404 @@
import { Extension } from '@codemirror/state';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useEffect } from 'react';
import * as React from 'react';
import { createTheme, GrafanaTheme2 } from '@grafana/data';
import { CodeMirrorEditor } from './CodeMirrorEditor';
import { createGenericHighlighter } from './highlight';
import { createGenericTheme } from './styles';
import { HighlighterFactory, SyntaxHighlightConfig, ThemeFactory } from './types';
// Mock DOM elements required by CodeMirror
beforeAll(() => {
Range.prototype.getClientRects = jest.fn(() => ({
item: () => null,
length: 0,
[Symbol.iterator]: jest.fn(),
}));
Range.prototype.getBoundingClientRect = jest.fn(() => ({
x: 0,
y: 0,
bottom: 0,
height: 0,
left: 0,
right: 0,
top: 0,
width: 0,
toJSON: () => {},
}));
});
describe('CodeMirrorEditor', () => {
describe('basic rendering', () => {
it('renders with initial value', async () => {
const onChange = jest.fn();
render(<CodeMirrorEditor value="Hello World" onChange={onChange} />);
await waitFor(() => {
const editor = screen.getByRole('textbox');
expect(editor).toBeInTheDocument();
});
});
it('renders with placeholder when value is empty', async () => {
const onChange = jest.fn();
const placeholder = 'Enter text here';
render(<CodeMirrorEditor value="" onChange={onChange} placeholder={placeholder} />);
await waitFor(() => {
const editor = screen.getByRole('textbox');
expect(editor).toHaveAttribute('aria-placeholder', placeholder);
});
});
it('renders with aria-label', async () => {
const onChange = jest.fn();
const ariaLabel = 'Code editor';
render(<CodeMirrorEditor value="" onChange={onChange} ariaLabel={ariaLabel} />);
await waitFor(() => {
const editor = screen.getByRole('textbox');
// aria-label is set on the parent .cm-editor element
expect(editor.closest('.cm-editor')).toHaveAttribute('aria-label', ariaLabel);
});
});
});
describe('user interaction', () => {
it('calls onChange when user types', async () => {
const onChange = jest.fn();
const user = userEvent.setup();
render(<CodeMirrorEditor value="" onChange={onChange} />);
await waitFor(() => {
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
const editor = screen.getByRole('textbox');
await user.click(editor);
await user.keyboard('test');
await waitFor(() => {
expect(onChange).toHaveBeenCalled();
});
});
it('updates when external value prop changes', async () => {
const onChange = jest.fn();
function TestWrapper({ initialValue }: { initialValue: string }) {
const [value, setValue] = React.useState(initialValue);
useEffect(() => {
setValue(initialValue);
}, [initialValue]);
return <CodeMirrorEditor value={value} onChange={onChange} />;
}
const { rerender } = render(<TestWrapper initialValue="first" />);
await waitFor(() => {
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
rerender(<TestWrapper initialValue="second" />);
await waitFor(() => {
const editor = screen.getByRole('textbox');
expect(editor).toBeInTheDocument();
});
});
});
describe('highlight functionality', () => {
it('renders with default highlighter using highlightConfig', async () => {
const onChange = jest.fn();
const highlightConfig: SyntaxHighlightConfig = {
pattern: /\$\{[^}]+\}/g,
className: 'variable-highlight',
};
render(<CodeMirrorEditor value="${test}" onChange={onChange} highlightConfig={highlightConfig} />);
await waitFor(() => {
const editor = screen.getByRole('textbox');
expect(editor).toBeInTheDocument();
});
});
it('renders with custom highlighter factory', async () => {
const onChange = jest.fn();
const customHighlighter: HighlighterFactory = (config) => {
return config ? createGenericHighlighter(config) : [];
};
const highlightConfig: SyntaxHighlightConfig = {
pattern: /\btest\b/g,
className: 'keyword',
};
render(
<CodeMirrorEditor
value="test keyword"
onChange={onChange}
highlighterFactory={customHighlighter}
highlightConfig={highlightConfig}
/>
);
await waitFor(() => {
const editor = screen.getByRole('textbox');
expect(editor).toBeInTheDocument();
});
});
it('updates highlights when highlightConfig changes', async () => {
const onChange = jest.fn();
function TestWrapper({ pattern }: { pattern: RegExp }) {
const [config, setConfig] = React.useState<SyntaxHighlightConfig>({
pattern,
className: 'highlight',
});
useEffect(() => {
setConfig({ pattern, className: 'highlight' });
}, [pattern]);
return <CodeMirrorEditor value="${var}" onChange={onChange} highlightConfig={config} />;
}
const { rerender } = render(<TestWrapper pattern={/\$\{[^}]+\}/g} />);
await waitFor(() => {
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
rerender(<TestWrapper pattern={/\d+/g} />);
await waitFor(() => {
const editor = screen.getByRole('textbox');
expect(editor).toBeInTheDocument();
});
});
it('renders without highlighting when highlightConfig is not provided', async () => {
const onChange = jest.fn();
render(<CodeMirrorEditor value="plain text" onChange={onChange} />);
await waitFor(() => {
const editor = screen.getByRole('textbox');
expect(editor).toBeInTheDocument();
});
});
});
describe('theme functionality', () => {
it('renders with default theme', async () => {
const onChange = jest.fn();
render(<CodeMirrorEditor value="test" onChange={onChange} />);
await waitFor(() => {
const editor = screen.getByRole('textbox');
expect(editor).toBeInTheDocument();
});
});
it('renders with custom theme factory', async () => {
const onChange = jest.fn();
const customTheme: ThemeFactory = (theme) => {
return createGenericTheme(theme);
};
render(<CodeMirrorEditor value="test" onChange={onChange} themeFactory={customTheme} />);
await waitFor(() => {
const editor = screen.getByRole('textbox');
expect(editor).toBeInTheDocument();
});
});
it('updates theme when themeFactory changes', async () => {
const onChange = jest.fn();
const theme1: ThemeFactory = (theme) => createGenericTheme(theme);
const theme2: ThemeFactory = (theme) => createGenericTheme(theme);
function TestWrapper({ themeFactory }: { themeFactory: ThemeFactory }) {
return <CodeMirrorEditor value="test" onChange={onChange} themeFactory={themeFactory} />;
}
const { rerender } = render(<TestWrapper themeFactory={theme1} />);
await waitFor(() => {
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
rerender(<TestWrapper themeFactory={theme2} />);
await waitFor(() => {
const editor = screen.getByRole('textbox');
expect(editor).toBeInTheDocument();
});
});
});
describe('combined highlight and theme', () => {
it('renders with both custom theme and highlighter', async () => {
const onChange = jest.fn();
const customTheme: ThemeFactory = (theme) => createGenericTheme(theme);
const highlightConfig: SyntaxHighlightConfig = {
pattern: /\$\{[^}]+\}/g,
className: 'variable',
};
render(
<CodeMirrorEditor
value="${variable} test"
onChange={onChange}
themeFactory={customTheme}
highlightConfig={highlightConfig}
/>
);
await waitFor(() => {
const editor = screen.getByRole('textbox');
expect(editor).toBeInTheDocument();
});
});
it('updates both theme and highlights together', async () => {
const onChange = jest.fn();
function TestWrapper({ pattern, mode }: { pattern: RegExp; mode: 'light' | 'dark' }) {
const [config, setConfig] = React.useState<SyntaxHighlightConfig>({
pattern,
className: 'highlight',
});
const [themeFactory, setThemeFactory] = React.useState<ThemeFactory>(
() => (theme: GrafanaTheme2) => createGenericTheme(theme)
);
useEffect(() => {
setConfig({ pattern, className: 'highlight' });
setThemeFactory(() => (theme: GrafanaTheme2) => {
const customTheme = createTheme({ colors: { mode } });
return createGenericTheme(customTheme);
});
}, [pattern, mode]);
return (
<CodeMirrorEditor
value="${var} 123"
onChange={onChange}
themeFactory={themeFactory}
highlightConfig={config}
/>
);
}
const { rerender } = render(<TestWrapper pattern={/\$\{[^}]+\}/g} mode="light" />);
await waitFor(() => {
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
rerender(<TestWrapper pattern={/\d+/g} mode="dark" />);
await waitFor(() => {
const editor = screen.getByRole('textbox');
expect(editor).toBeInTheDocument();
});
});
});
describe('additional features with highlight and theme', () => {
it('renders with showLineNumbers and highlighting', async () => {
const onChange = jest.fn();
const highlightConfig: SyntaxHighlightConfig = {
pattern: /\d+/g,
className: 'number',
};
render(
<CodeMirrorEditor
value="Line 1\nLine 2\nLine 3"
onChange={onChange}
showLineNumbers={true}
highlightConfig={highlightConfig}
/>
);
await waitFor(() => {
const editor = screen.getByRole('textbox');
expect(editor).toBeInTheDocument();
});
});
it('renders with custom extensions alongside theme and highlighter', async () => {
const onChange = jest.fn();
const customExtension: Extension[] = [];
const highlightConfig: SyntaxHighlightConfig = {
pattern: /test/g,
className: 'keyword',
};
render(
<CodeMirrorEditor
value="test"
onChange={onChange}
extensions={customExtension}
highlightConfig={highlightConfig}
/>
);
await waitFor(() => {
const editor = screen.getByRole('textbox');
expect(editor).toBeInTheDocument();
});
});
it('applies custom className with theme', async () => {
const onChange = jest.fn();
const customClassName = 'custom-editor';
render(<CodeMirrorEditor value="test" onChange={onChange} className={customClassName} />);
await waitFor(() => {
const editor = screen.getByRole('textbox');
expect(editor).toBeInTheDocument();
});
});
});
describe('useInputStyles prop', () => {
it('renders with input styles enabled', async () => {
const onChange = jest.fn();
render(<CodeMirrorEditor value="test" onChange={onChange} useInputStyles={true} />);
await waitFor(() => {
const editor = screen.getByRole('textbox');
expect(editor).toBeInTheDocument();
});
});
it('renders with input styles disabled', async () => {
const onChange = jest.fn();
render(<CodeMirrorEditor value="test" onChange={onChange} useInputStyles={false} />);
await waitFor(() => {
const editor = screen.getByRole('textbox');
expect(editor).toBeInTheDocument();
});
});
});
});

View File

@@ -0,0 +1,193 @@
import { closeBrackets, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { bracketMatching, foldGutter, indentOnInput } from '@codemirror/language';
import { Compartment, EditorState } from '@codemirror/state';
import {
drawSelection,
dropCursor,
EditorView,
highlightActiveLine,
highlightSpecialChars,
keymap,
lineNumbers,
placeholder as placeholderExtension,
rectangularSelection,
ViewUpdate,
} from '@codemirror/view';
import { css, cx } from '@emotion/css';
import { memo, useEffect, useRef } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2, useTheme2 } from '../../themes/ThemeContext';
import { getInputStyles } from '../Input/Input';
import { createGenericHighlighter } from './highlight';
import { createGenericTheme } from './styles';
import { CodeMirrorEditorProps } from './types';
export const CodeMirrorEditor = memo((props: CodeMirrorEditorProps) => {
const {
value,
onChange,
placeholder = '',
themeFactory,
highlighterFactory,
highlightConfig,
autocompletion: autocompletionExtension,
extensions = [],
showLineNumbers = false,
lineWrapping = true,
ariaLabel,
className,
useInputStyles = true,
closeBrackets: enableCloseBrackets = true,
} = props;
const editorContainerRef = useRef<HTMLDivElement>(null);
const editorViewRef = useRef<EditorView | null>(null);
const styles = useStyles2((theme) => getStyles(theme, useInputStyles));
const theme = useTheme2();
const themeCompartment = useRef(new Compartment());
const autocompletionCompartment = useRef(new Compartment());
const customKeymap = keymap.of([...closeBracketsKeymap, ...completionKeymap, ...historyKeymap, ...defaultKeymap]);
// Build theme extensions
const getThemeExtensions = () => {
const themeExt = themeFactory ? themeFactory(theme) : createGenericTheme(theme);
const highlighterExt = highlighterFactory
? highlighterFactory(highlightConfig)
: highlightConfig
? createGenericHighlighter(highlightConfig)
: [];
return [themeExt, highlighterExt];
};
// Initialize CodeMirror editor
useEffect(() => {
if (!editorContainerRef.current || editorViewRef.current) {
return;
}
const baseExtensions = [
highlightActiveLine(),
highlightSpecialChars(),
history(),
foldGutter(),
drawSelection(),
dropCursor(),
EditorState.allowMultipleSelections.of(true),
indentOnInput(),
bracketMatching(),
rectangularSelection(),
customKeymap,
placeholderExtension(placeholder),
EditorView.updateListener.of((update: ViewUpdate) => {
if (update.docChanged) {
const newValue = update.state.doc.toString();
onChange(newValue);
}
}),
themeCompartment.current.of(getThemeExtensions()),
EditorState.phrases.of({
next: 'Next',
previous: 'Previous',
Completions: 'Completions',
}),
EditorView.editorAttributes.of({ 'aria-label': ariaLabel || placeholder }),
];
// Conditionally add closeBrackets extension
if (enableCloseBrackets) {
baseExtensions.push(closeBrackets());
}
// Add optional extensions
if (showLineNumbers) {
baseExtensions.push(lineNumbers());
}
if (lineWrapping) {
baseExtensions.push(EditorView.lineWrapping);
}
if (autocompletionExtension) {
baseExtensions.push(autocompletionCompartment.current.of(autocompletionExtension));
}
// Add custom extensions
if (extensions.length > 0) {
baseExtensions.push(...extensions);
}
const startState = EditorState.create({
doc: value,
extensions: baseExtensions,
});
const view = new EditorView({
state: startState,
parent: editorContainerRef.current,
});
editorViewRef.current = view;
return () => {
view.destroy();
editorViewRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Update editor value when prop changes
useEffect(() => {
if (editorViewRef.current) {
const currentValue = editorViewRef.current.state.doc.toString();
if (currentValue !== value) {
editorViewRef.current.dispatch({
changes: { from: 0, to: currentValue.length, insert: value },
});
}
}
}, [value]);
// Update theme when it changes
useEffect(() => {
if (editorViewRef.current) {
editorViewRef.current.dispatch({
effects: themeCompartment.current.reconfigure(getThemeExtensions()),
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [theme, themeFactory, highlighterFactory, highlightConfig]);
// Update autocompletion when it changes
useEffect(() => {
if (editorViewRef.current && autocompletionExtension) {
editorViewRef.current.dispatch({
effects: autocompletionCompartment.current.reconfigure(autocompletionExtension),
});
}
}, [autocompletionExtension]);
return (
<div className={cx(styles.container, className)}>
<div className={styles.input} ref={editorContainerRef} />
</div>
);
});
CodeMirrorEditor.displayName = 'CodeMirrorEditor';
const getStyles = (theme: GrafanaTheme2, useInputStyles: boolean) => {
const baseInputStyles = useInputStyles ? getInputStyles({ theme, invalid: false }).input : {};
return {
container: css({
position: 'relative',
width: '100%',
}),
input: css(baseInputStyles),
};
};

View File

@@ -0,0 +1,246 @@
# CodeMirror Editor Component
A reusable CodeMirror editor component for Grafana that provides a flexible and themeable code editing experience.
## Overview
The `CodeMirrorEditor` component is a generic, theme-aware editor built on CodeMirror 6. Use it anywhere you need code editing functionality with syntax highlighting, autocompletion, and Grafana theme integration.
## Basic usage
```typescript
import { CodeMirrorEditor } from '@grafana/ui';
function MyComponent() {
const [value, setValue] = useState('');
return (
<CodeMirrorEditor
value={value}
onChange={setValue}
placeholder="Enter your code here"
/>
);
}
```
## Advanced usage
### Custom syntax highlighting
Create a custom highlighter for your specific syntax:
```typescript
import { CodeMirrorEditor, SyntaxHighlightConfig } from '@grafana/ui';
function MyComponent() {
const [value, setValue] = useState('');
const highlightConfig: SyntaxHighlightConfig = {
pattern: /\b(SELECT|FROM|WHERE)\b/gi, // Highlight SQL keywords
className: 'cm-keyword',
};
return (
<CodeMirrorEditor
value={value}
onChange={setValue}
highlightConfig={highlightConfig}
/>
);
}
```
### Custom theme
Extend the default theme with your own styling:
```typescript
import { CodeMirrorEditor, ThemeFactory } from '@grafana/ui';
import { EditorView } from '@codemirror/view';
import { createGenericTheme } from '@grafana/ui';
const myCustomTheme: ThemeFactory = (theme) => {
const baseTheme = createGenericTheme(theme);
const customStyles = EditorView.theme({
'.cm-keyword': {
color: theme.colors.primary.text,
fontWeight: theme.typography.fontWeightBold,
},
'.cm-string': {
color: theme.colors.success.text,
},
});
return [baseTheme, customStyles];
};
function MyComponent() {
return (
<CodeMirrorEditor
value={value}
onChange={setValue}
themeFactory={myCustomTheme}
/>
);
}
```
### Custom autocompletion
Add autocompletion for your specific use case:
```typescript
import { CodeMirrorEditor } from '@grafana/ui';
import { autocompletion, CompletionContext } from '@codemirror/autocomplete';
function MyComponent() {
const [value, setValue] = useState('');
const autocompletionExtension = useMemo(() => {
return autocompletion({
override: [(context: CompletionContext) => {
const word = context.matchBefore(/\w*/);
if (!word || word.from === word.to) {
return null;
}
return {
from: word.from,
options: [
{ label: 'hello', type: 'keyword' },
{ label: 'world', type: 'keyword' },
],
};
}],
activateOnTyping: true,
});
}, []);
return (
<CodeMirrorEditor
value={value}
onChange={setValue}
autocompletion={autocompletionExtension}
/>
);
}
```
### Additional extensions
Add custom CodeMirror extensions:
```typescript
import { CodeMirrorEditor } from '@grafana/ui';
import { javascript } from '@codemirror/lang-javascript';
import { linter } from '@codemirror/lint';
function MyComponent() {
const extensions = useMemo(() => [
javascript(),
linter(/* your linting logic */),
], []);
return (
<CodeMirrorEditor
value={value}
onChange={setValue}
extensions={extensions}
/>
);
}
```
## Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `value` | `string` | required | The current value of the editor |
| `onChange` | `(value: string, callback?: () => void) => void` | required | Callback when the editor value changes |
| `placeholder` | `string` | `''` | Placeholder text when editor is empty |
| `themeFactory` | `ThemeFactory` | `createGenericTheme` | Custom theme factory function |
| `highlighterFactory` | `HighlighterFactory` | `createGenericHighlighter` | Custom syntax highlighter factory |
| `highlightConfig` | `SyntaxHighlightConfig` | `undefined` | Configuration for syntax highlighting |
| `autocompletion` | `Extension` | `undefined` | Custom autocompletion extension |
| `extensions` | `Extension[]` | `[]` | Additional CodeMirror extensions |
| `showLineNumbers` | `boolean` | `false` | Whether to show line numbers |
| `lineWrapping` | `boolean` | `true` | Whether to enable line wrapping |
| `ariaLabel` | `string` | `placeholder` | Aria label for accessibility |
| `className` | `string` | `undefined` | Custom CSS class for the container |
| `useInputStyles` | `boolean` | `true` | Whether to apply Grafana input styles |
## Example: DataLink editor
Here's how the DataLink component uses the CodeMirror editor:
```typescript
import { CodeMirrorEditor } from '@grafana/ui';
import { createDataLinkAutocompletion, createDataLinkHighlighter, createDataLinkTheme } from './codemirrorUtils';
export const DataLinkInput = memo(({ value, onChange, suggestions, placeholder }) => {
const autocompletionExtension = useMemo(
() => createDataLinkAutocompletion(suggestions),
[suggestions]
);
return (
<CodeMirrorEditor
value={value}
onChange={onChange}
placeholder={placeholder}
themeFactory={createDataLinkTheme}
highlighterFactory={createDataLinkHighlighter}
autocompletion={autocompletionExtension}
ariaLabel={placeholder}
/>
);
});
```
## Utilities
### `createGenericTheme(theme: GrafanaTheme2): Extension`
Creates a generic CodeMirror theme based on Grafana's theme.
### `createGenericHighlighter(theme: GrafanaTheme2, config: SyntaxHighlightConfig): Extension`
Creates a generic syntax highlighter based on a regex pattern and CSS class name.
## Types
```typescript
interface SyntaxHighlightConfig {
pattern: RegExp;
className: string;
}
type ThemeFactory = (theme: GrafanaTheme2) => Extension;
type HighlighterFactory = (theme: GrafanaTheme2, config?: SyntaxHighlightConfig) => Extension;
type AutocompletionFactory<T = unknown> = (data: T) => Extension;
```
## Features
- **Theme-aware**: Automatically adapts to Grafana's light and dark themes
- **Syntax highlighting**: Configurable pattern-based syntax highlighting
- **Autocompletion**: Customizable autocompletion with keyboard shortcuts
- **Accessibility**: Built-in ARIA support
- **Line numbers**: Optional line number display
- **Line wrapping**: Configurable line wrapping
- **Modal-friendly**: Tooltips render at body level to prevent clipping
- **Extensible**: Support for custom CodeMirror extensions
## Best practices
1. **Memoize extensions**: Use `useMemo` to create autocompletion and other extensions to avoid recreating them on every render.
2. **Custom themes**: Extend the generic theme rather than replacing it to maintain consistency with Grafana's design system.
3. **Pattern efficiency**: Use efficient regex patterns for syntax highlighting to avoid performance issues with large documents.
4. **Accessibility**: Always provide meaningful `ariaLabel` or `placeholder` text for screen readers.
5. **Type safety**: Use the provided TypeScript types for better type safety and IDE support.

View File

@@ -0,0 +1,246 @@
import { EditorState } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { createGenericHighlighter } from './highlight';
import { SyntaxHighlightConfig } from './types';
// Mock DOM elements required by CodeMirror
beforeAll(() => {
Range.prototype.getClientRects = jest.fn(() => ({
item: () => null,
length: 0,
[Symbol.iterator]: jest.fn(),
}));
Range.prototype.getBoundingClientRect = jest.fn(() => ({
x: 0,
y: 0,
bottom: 0,
height: 0,
left: 0,
right: 0,
top: 0,
width: 0,
toJSON: () => {},
}));
});
describe('createGenericHighlighter', () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
});
afterEach(() => {
document.body.removeChild(container);
});
/**
* Helper to create editor with highlighter
*/
function createEditorWithHighlighter(config: SyntaxHighlightConfig, text: string) {
const highlighter = createGenericHighlighter(config);
const state = EditorState.create({
doc: text,
extensions: [highlighter],
});
return new EditorView({ state, parent: container });
}
describe('basic highlighting', () => {
it('highlights text matching the pattern', () => {
const config: SyntaxHighlightConfig = {
pattern: /\$\{[^}]+\}/g,
className: 'test-highlight',
};
const view = createEditorWithHighlighter(config, 'Hello ${world}!');
const content = view.dom.textContent;
expect(content).toBe('Hello ${world}!');
view.destroy();
});
it('highlights multiple matches', () => {
const config: SyntaxHighlightConfig = {
pattern: /\$\{[^}]+\}/g,
className: 'variable',
};
const view = createEditorWithHighlighter(config, '${first} and ${second} and ${third}');
const content = view.dom.textContent;
expect(content).toBe('${first} and ${second} and ${third}');
view.destroy();
});
it('handles text with no matches', () => {
const config: SyntaxHighlightConfig = {
pattern: /\$\{[^}]+\}/g,
className: 'variable',
};
const view = createEditorWithHighlighter(config, 'No variables here');
const content = view.dom.textContent;
expect(content).toBe('No variables here');
view.destroy();
});
it('handles empty text', () => {
const config: SyntaxHighlightConfig = {
pattern: /\$\{[^}]+\}/g,
className: 'variable',
};
const view = createEditorWithHighlighter(config, '');
const content = view.dom.textContent;
expect(content).toBe('');
view.destroy();
});
});
describe('pattern variations', () => {
it('highlights with simple word pattern', () => {
const config: SyntaxHighlightConfig = {
pattern: /\btest\b/g,
className: 'keyword',
};
const view = createEditorWithHighlighter(config, 'This is a test of the test word');
const content = view.dom.textContent;
expect(content).toBe('This is a test of the test word');
view.destroy();
});
it('highlights with number pattern', () => {
const config: SyntaxHighlightConfig = {
pattern: /\d+/g,
className: 'number',
};
const view = createEditorWithHighlighter(config, 'Numbers: 123, 456, 789');
const content = view.dom.textContent;
expect(content).toBe('Numbers: 123, 456, 789');
view.destroy();
});
it('highlights with URL pattern', () => {
const config: SyntaxHighlightConfig = {
pattern: /https?:\/\/[^\s]+/g,
className: 'url',
};
const view = createEditorWithHighlighter(config, 'Visit https://grafana.com and http://example.com');
const content = view.dom.textContent;
expect(content).toBe('Visit https://grafana.com and http://example.com');
view.destroy();
});
});
describe('dynamic updates', () => {
it('updates highlights when document changes', () => {
const config: SyntaxHighlightConfig = {
pattern: /\$\{[^}]+\}/g,
className: 'variable',
};
const view = createEditorWithHighlighter(config, 'Initial text');
// Update document
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: 'New ${variable} text' },
});
const content = view.dom.textContent;
expect(content).toBe('New ${variable} text');
view.destroy();
});
it('updates highlights when adding to document', () => {
const config: SyntaxHighlightConfig = {
pattern: /\$\{[^}]+\}/g,
className: 'variable',
};
const view = createEditorWithHighlighter(config, 'Start ');
// Insert text
view.dispatch({
changes: { from: view.state.doc.length, insert: '${var}' },
});
const content = view.dom.textContent;
expect(content).toBe('Start ${var}');
view.destroy();
});
it('removes highlights when pattern no longer matches', () => {
const config: SyntaxHighlightConfig = {
pattern: /\$\{[^}]+\}/g,
className: 'variable',
};
const view = createEditorWithHighlighter(config, '${variable}');
// Replace with non-matching text
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: 'plain text' },
});
const content = view.dom.textContent;
expect(content).toBe('plain text');
view.destroy();
});
});
describe('complex patterns', () => {
it('highlights nested brackets', () => {
const config: SyntaxHighlightConfig = {
pattern: /\$\{[^}]+\}/g,
className: 'variable',
};
const view = createEditorWithHighlighter(config, 'Text with ${var1} and ${var2} variables');
const content = view.dom.textContent;
expect(content).toBe('Text with ${var1} and ${var2} variables');
view.destroy();
});
it('highlights overlapping patterns correctly', () => {
const config: SyntaxHighlightConfig = {
pattern: /test/g,
className: 'keyword',
};
const view = createEditorWithHighlighter(config, 'testtesttest');
const content = view.dom.textContent;
expect(content).toBe('testtesttest');
view.destroy();
});
});
describe('multiline text', () => {
it('highlights patterns across multiple lines', () => {
const config: SyntaxHighlightConfig = {
pattern: /\$\{[^}]+\}/g,
className: 'variable',
};
const text = 'Line 1 ${var1}\nLine 2 ${var2}\nLine 3';
const view = createEditorWithHighlighter(config, text);
// Check the document state instead of textContent (which doesn't preserve newlines in DOM)
const docContent = view.state.doc.toString();
expect(docContent).toBe(text);
view.destroy();
});
});
});

View File

@@ -0,0 +1,54 @@
import { Extension } from '@codemirror/state';
import { Decoration, DecorationSet, EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view';
import { SyntaxHighlightConfig } from './types';
/**
* Creates a generic syntax highlighter based on a pattern and class name
*/
export function createGenericHighlighter(config: SyntaxHighlightConfig): Extension {
const { pattern, className } = config;
const decoration = Decoration.mark({
class: className,
});
const viewPlugin = ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = this.buildDecorations(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = this.buildDecorations(update.view);
}
}
buildDecorations(view: EditorView): DecorationSet {
const decorations: Array<{ from: number; to: number }> = [];
const text = view.state.doc.toString();
let match;
// Reset regex state
pattern.lastIndex = 0;
while ((match = pattern.exec(text)) !== null) {
decorations.push({
from: match.index,
to: match.index + match[0].length,
});
}
return Decoration.set(decorations.map((range) => decoration.range(range.from, range.to)));
}
},
{
decorations: (v) => v.decorations,
}
);
return viewPlugin;
}

View File

@@ -0,0 +1,189 @@
import { Compartment, EditorState } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { createTheme } from '@grafana/data';
import { createGenericTheme } from './styles';
// Mock DOM elements required by CodeMirror
beforeAll(() => {
Range.prototype.getClientRects = jest.fn(() => ({
item: () => null,
length: 0,
[Symbol.iterator]: jest.fn(),
}));
Range.prototype.getBoundingClientRect = jest.fn(() => ({
x: 0,
y: 0,
bottom: 0,
height: 0,
left: 0,
right: 0,
top: 0,
width: 0,
toJSON: () => {},
}));
});
describe('createGenericTheme', () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
});
afterEach(() => {
document.body.removeChild(container);
});
/**
* Helper to create editor with theme
*/
function createEditorWithTheme(themeMode: 'light' | 'dark', text = 'test') {
const theme = createTheme({ colors: { mode: themeMode } });
const themeExtension = createGenericTheme(theme);
const state = EditorState.create({
doc: text,
extensions: [themeExtension],
});
return new EditorView({ state, parent: container });
}
describe('theme creation', () => {
it('creates theme for light mode', () => {
const theme = createTheme({ colors: { mode: 'light' } });
const themeExtension = createGenericTheme(theme);
expect(themeExtension).toBeDefined();
});
it('creates theme for dark mode', () => {
const theme = createTheme({ colors: { mode: 'dark' } });
const themeExtension = createGenericTheme(theme);
expect(themeExtension).toBeDefined();
});
it('applies theme to editor in light mode', () => {
const view = createEditorWithTheme('light');
expect(view).toBeDefined();
expect(view.dom).toBeInstanceOf(HTMLElement);
view.destroy();
});
it('applies theme to editor in dark mode', () => {
const view = createEditorWithTheme('dark');
expect(view).toBeDefined();
expect(view.dom).toBeInstanceOf(HTMLElement);
view.destroy();
});
});
describe('theme properties', () => {
it('applies typography settings from theme', () => {
const theme = createTheme({ colors: { mode: 'light' } });
const themeExtension = createGenericTheme(theme);
const state = EditorState.create({
doc: 'test',
extensions: [themeExtension],
});
const view = new EditorView({ state, parent: container });
// Check that editor is created successfully
expect(view.dom).toBeInstanceOf(HTMLElement);
view.destroy();
});
it('applies color settings from theme', () => {
const theme = createTheme({ colors: { mode: 'dark' } });
const themeExtension = createGenericTheme(theme);
const state = EditorState.create({
doc: 'test',
extensions: [themeExtension],
});
const view = new EditorView({ state, parent: container });
expect(view.dom).toBeInstanceOf(HTMLElement);
view.destroy();
});
});
describe('theme updates', () => {
it('switches from light to dark theme', () => {
const themeCompartment = new Compartment();
const lightTheme = createTheme({ colors: { mode: 'light' } });
const lightThemeExtension = createGenericTheme(lightTheme);
const state = EditorState.create({
doc: 'test',
extensions: [themeCompartment.of(lightThemeExtension)],
});
const view = new EditorView({ state, parent: container });
// Update to dark theme
const darkTheme = createTheme({ colors: { mode: 'dark' } });
const darkThemeExtension = createGenericTheme(darkTheme);
view.dispatch({
effects: themeCompartment.reconfigure(darkThemeExtension),
});
expect(view.dom).toBeInstanceOf(HTMLElement);
view.destroy();
});
it('switches from dark to light theme', () => {
const themeCompartment = new Compartment();
const darkTheme = createTheme({ colors: { mode: 'dark' } });
const darkThemeExtension = createGenericTheme(darkTheme);
const state = EditorState.create({
doc: 'test',
extensions: [themeCompartment.of(darkThemeExtension)],
});
const view = new EditorView({ state, parent: container });
// Update to light theme
const lightTheme = createTheme({ colors: { mode: 'light' } });
const lightThemeExtension = createGenericTheme(lightTheme);
view.dispatch({
effects: themeCompartment.reconfigure(lightThemeExtension),
});
expect(view.dom).toBeInstanceOf(HTMLElement);
view.destroy();
});
});
describe('editor rendering', () => {
it('renders editor with light theme and content', () => {
const view = createEditorWithTheme('light', 'Hello world!');
expect(view.dom).toHaveTextContent('Hello world!');
view.destroy();
});
it('renders editor with dark theme and content', () => {
const view = createEditorWithTheme('dark', 'Hello world!');
expect(view.dom).toHaveTextContent('Hello world!');
view.destroy();
});
it('renders multiline content with theme', () => {
const text = 'Line 1\nLine 2\nLine 3';
const view = createEditorWithTheme('light', text);
// Check the document state instead of textContent (which doesn't preserve newlines in DOM)
const docContent = view.state.doc.toString();
expect(docContent).toBe(text);
view.destroy();
});
});
});

View File

@@ -0,0 +1,92 @@
import { Extension } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { GrafanaTheme2 } from '@grafana/data';
/**
* Creates a generic CodeMirror theme based on Grafana's theme
*/
export function createGenericTheme(theme: GrafanaTheme2): Extension {
const isDark = theme.colors.mode === 'dark';
return EditorView.theme(
{
'&': {
fontSize: theme.typography.body.fontSize,
fontFamily: theme.typography.fontFamilyMonospace,
backgroundColor: 'transparent',
border: 'none',
outline: 'none',
},
'.cm-placeholder': {
color: theme.colors.text.disabled,
fontStyle: 'normal',
},
'.cm-scroller': {
overflow: 'auto',
fontFamily: theme.typography.fontFamilyMonospace,
},
'.cm-content': {
padding: '3px 0',
color: theme.colors.text.primary,
caretColor: theme.colors.text.primary,
},
'.cm-line': {
padding: '0 2px',
},
'.cm-cursor': {
borderLeftColor: theme.colors.text.primary,
},
'.cm-selectionBackground': {
backgroundColor: `${theme.colors.action.selected} !important`,
},
'&.cm-focused .cm-selectionBackground': {
backgroundColor: `${theme.colors.action.focus} !important`,
},
'.cm-activeLine': {
backgroundColor: 'transparent',
},
'.cm-gutters': {
display: 'none',
},
'.cm-tooltip.cm-tooltip-autocomplete': {
backgroundColor: theme.colors.background.primary,
border: `1px solid ${theme.colors.border.weak}`,
boxShadow: theme.shadows.z3,
pointerEvents: 'auto',
},
'.cm-tooltip.cm-tooltip-autocomplete > ul': {
fontFamily: theme.typography.fontFamily,
maxHeight: '300px',
},
'.cm-tooltip.cm-tooltip-autocomplete > ul > li': {
padding: '2px 8px',
color: theme.colors.text.primary,
cursor: 'pointer',
},
'.cm-tooltip.cm-tooltip-autocomplete > ul > li:hover': {
backgroundColor: theme.colors.background.secondary,
},
'.cm-tooltip-autocomplete ul li[aria-selected]': {
backgroundColor: theme.colors.background.secondary,
color: theme.colors.text.primary,
},
'.cm-completionLabel': {
fontFamily: theme.typography.fontFamilyMonospace,
fontSize: theme.typography.size.sm,
},
'.cm-completionDetail': {
color: theme.colors.text.secondary,
fontStyle: 'normal',
marginLeft: theme.spacing(1),
},
'.cm-completionInfo': {
backgroundColor: theme.colors.background.primary,
border: `1px solid ${theme.colors.border.weak}`,
color: theme.colors.text.primary,
padding: theme.spacing(1),
},
},
{ dark: isDark }
);
}

View File

@@ -0,0 +1,107 @@
import { Extension } from '@codemirror/state';
import { GrafanaTheme2 } from '@grafana/data';
/**
* Configuration options for syntax highlighting
*/
export interface SyntaxHighlightConfig {
/**
* Pattern to match for highlighting
*/
pattern: RegExp;
/**
* CSS class to apply to matched text
*/
className: string;
}
/**
* Function to create a theme extension
*/
export type ThemeFactory = (theme: GrafanaTheme2) => Extension;
/**
* Function to create a syntax highlighter extension
*/
export type HighlighterFactory = (config?: SyntaxHighlightConfig) => Extension;
/**
* Function to create an autocompletion extension
*/
export type AutocompletionFactory<T = unknown> = (data: T) => Extension;
/**
* Props for the CodeMirrorEditor component
*/
export interface CodeMirrorEditorProps {
/**
* The current value of the editor
*/
value: string;
/**
* Callback when the editor value changes
*/
onChange: (value: string, callback?: () => void) => void;
/**
* Placeholder text to display when editor is empty
*/
placeholder?: string;
/**
* Custom theme factory function
*/
themeFactory?: ThemeFactory;
/**
* Custom syntax highlighter factory function
*/
highlighterFactory?: HighlighterFactory;
/**
* Configuration for syntax highlighting
*/
highlightConfig?: SyntaxHighlightConfig;
/**
* Custom autocompletion extension
*/
autocompletion?: Extension;
/**
* Additional CodeMirror extensions to apply
*/
extensions?: Extension[];
/**
* Whether to show line numbers (default: false)
*/
showLineNumbers?: boolean;
/**
* Whether to enable line wrapping (default: true)
*/
lineWrapping?: boolean;
/**
* Aria label for accessibility
*/
ariaLabel?: string;
/**
* Custom CSS class for the container
*/
className?: string;
/**
* Whether to apply input styles (default: true)
*/
useInputStyles?: boolean;
/**
* Whether to enable automatic closing of brackets and braces (default: true)
*/
closeBrackets?: boolean;
}

View File

@@ -51,7 +51,7 @@ export const DataLinkEditor = memo(
/>
</Field>
<Field label={t('grafana-ui.data-link-editor.url-label', 'URL')}>
<Field label={t('grafana-ui.data-link-editor.url-label', 'URL')} className={styles.urlField}>
<DataLinkInput value={value.url} onChange={onUrlChange} suggestions={suggestions} />
</Field>
@@ -88,6 +88,10 @@ const getStyles = (theme: GrafanaTheme2) => ({
listItem: css({
marginBottom: theme.spacing(),
}),
urlField: css({
position: 'relative',
zIndex: theme.zIndex.typeahead,
}),
infoText: css({
paddingBottom: theme.spacing(2),
marginLeft: '66px',

View File

@@ -0,0 +1,249 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useEffect } from 'react';
import * as React from 'react';
import { DataLinkBuiltInVars, VariableOrigin, VariableSuggestion } from '@grafana/data';
import { DataLinkInput } from './DataLinkInput';
// Mock getClientRects for CodeMirror in JSDOM
beforeAll(() => {
Range.prototype.getClientRects = jest.fn(() => ({
item: () => null,
length: 0,
[Symbol.iterator]: jest.fn(),
}));
Range.prototype.getBoundingClientRect = jest.fn(() => ({
x: 0,
y: 0,
bottom: 0,
height: 0,
left: 0,
right: 0,
top: 0,
width: 0,
toJSON: () => {},
}));
});
const mockSuggestions: VariableSuggestion[] = [
{
value: DataLinkBuiltInVars.seriesName,
label: '__series.name',
documentation: 'Series name',
origin: VariableOrigin.Series,
},
{
value: DataLinkBuiltInVars.fieldName,
label: '__field.name',
documentation: 'Field name',
origin: VariableOrigin.Field,
},
{
value: 'myVar',
label: 'myVar',
documentation: 'Custom variable',
origin: VariableOrigin.Template,
},
];
describe('DataLinkInput', () => {
it('renders with initial value', async () => {
const onChange = jest.fn();
render(
<DataLinkInput value="https://grafana.com" onChange={onChange} suggestions={mockSuggestions} />
);
await waitFor(() => {
const editor = screen.getByRole('textbox');
expect(editor).toBeInTheDocument();
});
});
it('renders with placeholder when value is empty', async () => {
const onChange = jest.fn();
const placeholder = 'Enter URL here';
render(<DataLinkInput value="" onChange={onChange} suggestions={mockSuggestions} placeholder={placeholder} />);
await waitFor(() => {
const editor = screen.getByRole('textbox');
expect(editor).toHaveAttribute('aria-placeholder', placeholder);
});
});
it('calls onChange when value changes', async () => {
const onChange = jest.fn();
const user = userEvent.setup();
render(<DataLinkInput value="" onChange={onChange} suggestions={mockSuggestions} />);
await waitFor(() => {
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
const editor = screen.getByRole('textbox');
await user.click(editor);
await user.keyboard('test');
await waitFor(() => {
expect(onChange).toHaveBeenCalled();
});
});
it('shows suggestions menu when $ is typed', async () => {
const onChange = jest.fn();
const user = userEvent.setup();
render(<DataLinkInput value="" onChange={onChange} suggestions={mockSuggestions} />);
await waitFor(() => {
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
const editor = screen.getByRole('textbox');
await user.click(editor);
await user.keyboard('$');
await waitFor(() => {
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
});
it('shows suggestions menu when = is typed', async () => {
const onChange = jest.fn();
const user = userEvent.setup();
render(<DataLinkInput value="" onChange={onChange} suggestions={mockSuggestions} />);
await waitFor(() => {
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
const editor = screen.getByRole('textbox');
await user.click(editor);
await user.keyboard('=');
await waitFor(() => {
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
});
it('closes suggestions on Escape key', async () => {
const onChange = jest.fn();
const user = userEvent.setup();
render(<DataLinkInput value="" onChange={onChange} suggestions={mockSuggestions} />);
await waitFor(() => {
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
const editor = screen.getByRole('textbox');
await user.click(editor);
await user.keyboard('$');
await waitFor(() => {
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
await user.keyboard('{Escape}');
await waitFor(() => {
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
});
});
it('navigates suggestions with arrow keys', async () => {
const onChange = jest.fn();
const user = userEvent.setup();
render(<DataLinkInput value="" onChange={onChange} suggestions={mockSuggestions} />);
await waitFor(() => {
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
const editor = screen.getByRole('textbox');
await user.click(editor);
await user.keyboard('$');
await waitFor(() => {
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
// Navigate with arrow keys
await user.keyboard('{ArrowDown}');
await user.keyboard('{ArrowUp}');
// Menu should still be visible
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
it('inserts variable on Enter key', async () => {
const onChange = jest.fn();
const user = userEvent.setup();
render(<DataLinkInput value="" onChange={onChange} suggestions={mockSuggestions} />);
await waitFor(() => {
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
const editor = screen.getByRole('textbox');
await user.click(editor);
await user.keyboard('$');
await waitFor(() => {
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
await user.keyboard('{Enter}');
await waitFor(() => {
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
});
// Should have called onChange with the inserted variable
expect(onChange).toHaveBeenCalled();
});
it('updates when external value prop changes', async () => {
const onChange = jest.fn();
function TestWrapper({ initialValue }: { initialValue: string }) {
const [value, setValue] = React.useState(initialValue);
useEffect(() => {
setValue(initialValue);
}, [initialValue]);
return <DataLinkInput value={value} onChange={onChange} suggestions={mockSuggestions} />;
}
const { rerender } = render(<TestWrapper initialValue="first" />);
await waitFor(() => {
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
rerender(<TestWrapper initialValue="second" />);
await waitFor(() => {
const editor = screen.getByRole('textbox');
expect(editor).toBeInTheDocument();
});
});
it('displays component with default placeholder', async () => {
const onChange = jest.fn();
render(<DataLinkInput value="" onChange={onChange} suggestions={mockSuggestions} />);
await waitFor(() => {
const editor = screen.getByRole('textbox');
expect(editor).toHaveAttribute('aria-placeholder', 'http://your-grafana.com/d/000000010/annotations');
});
});
});

View File

@@ -1,27 +1,10 @@
import { css, cx } from '@emotion/css';
import { autoUpdate, offset, useFloating } from '@floating-ui/react';
import Prism, { Grammar, LanguageMap } from 'prismjs';
import { memo, useEffect, useRef, useState } from 'react';
import * as React from 'react';
import { usePrevious } from 'react-use';
import { Value } from 'slate';
import Plain from 'slate-plain-serializer';
import { Editor } from 'slate-react';
import { memo, useMemo } from 'react';
import { DataLinkBuiltInVars, GrafanaTheme2, VariableOrigin, VariableSuggestion } from '@grafana/data';
import { VariableSuggestion } from '@grafana/data';
import { SlatePrism } from '../../slate-plugins/slate-prism';
import { useStyles2 } from '../../themes/ThemeContext';
import { getPositioningMiddleware } from '../../utils/floating';
import { SCHEMA, makeValue } from '../../utils/slate';
import { getInputStyles } from '../Input/Input';
import { Portal } from '../Portal/Portal';
import { ScrollContainer } from '../ScrollContainer/ScrollContainer';
import { CodeMirrorEditor } from '../CodeMirror/CodeMirrorEditor';
import { DataLinkSuggestions } from './DataLinkSuggestions';
import { SelectionReference } from './SelectionReference';
const modulo = (a: number, n: number) => a - n * Math.floor(a / n);
import { createDataLinkAutocompletion, createDataLinkHighlighter, createDataLinkTheme } from './codemirrorUtils';
interface DataLinkInputProps {
value: string;
@@ -30,49 +13,6 @@ interface DataLinkInputProps {
placeholder?: string;
}
const datalinksSyntax: Grammar = {
builtInVariable: {
pattern: /(\${\S+?})/,
},
};
const plugins = [
SlatePrism(
{
onlyIn: (node) => 'type' in node && node.type === 'code_block',
getSyntax: () => 'links',
},
{ ...(Prism.languages as LanguageMap), links: datalinksSyntax }
),
];
const getStyles = (theme: GrafanaTheme2) => ({
input: getInputStyles({ theme, invalid: false }).input,
editor: css({
'.token.builtInVariable': {
color: theme.colors.success.text,
},
'.token.variable': {
color: theme.colors.primary.text,
},
}),
suggestionsWrapper: css({
boxShadow: theme.shadows.z2,
}),
// Wrapper with child selector needed.
// When classnames are applied to the same element as the wrapper, it causes the suggestions to stop working
wrapperOverrides: css({
width: '100%',
'> .slate-query-field__wrapper': {
padding: 0,
backgroundColor: 'transparent',
border: 'none',
},
}),
});
// This memoised also because rerendering the slate editor grabs focus which created problem in some cases this
// was used and changes to different state were propagated here.
export const DataLinkInput = memo(
({
value,
@@ -80,175 +20,22 @@ export const DataLinkInput = memo(
suggestions,
placeholder = 'http://your-grafana.com/d/000000010/annotations',
}: DataLinkInputProps) => {
const editorRef = useRef<Editor>(null);
const styles = useStyles2(getStyles);
const [showingSuggestions, setShowingSuggestions] = useState(false);
const [suggestionsIndex, setSuggestionsIndex] = useState(0);
const [linkUrl, setLinkUrl] = useState<Value>(makeValue(value));
const prevLinkUrl = usePrevious<Value>(linkUrl);
const [scrollTop, setScrollTop] = useState(0);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
scrollRef.current?.scrollTo(0, scrollTop);
}, [scrollTop]);
// the order of middleware is important!
const middleware = [
offset(({ rects }) => ({
alignmentAxis: rects.reference.width,
})),
...getPositioningMiddleware(),
];
const { refs, floatingStyles } = useFloating({
open: showingSuggestions,
placement: 'bottom-start',
onOpenChange: setShowingSuggestions,
middleware,
whileElementsMounted: autoUpdate,
strategy: 'fixed',
});
// Workaround for https://github.com/ianstormtaylor/slate/issues/2927
const stateRef = useRef({ showingSuggestions, suggestions, suggestionsIndex, linkUrl, onChange });
stateRef.current = { showingSuggestions, suggestions, suggestionsIndex, linkUrl, onChange };
// Used to get the height of the suggestion elements in order to scroll to them.
const activeRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setScrollTop(getElementPosition(activeRef.current, suggestionsIndex));
}, [suggestionsIndex]);
const onKeyDown = React.useCallback((event: React.KeyboardEvent, next: () => void) => {
if (!stateRef.current.showingSuggestions) {
if (event.key === '=' || event.key === '$' || (event.keyCode === 32 && event.ctrlKey)) {
const selectionRef = new SelectionReference();
refs.setReference(selectionRef);
return setShowingSuggestions(true);
}
return next();
}
switch (event.key) {
case 'Backspace':
if (stateRef.current.linkUrl.focusText.getText().length === 1) {
next();
}
case 'Escape':
setShowingSuggestions(false);
return setSuggestionsIndex(0);
case 'Enter':
event.preventDefault();
return onVariableSelect(stateRef.current.suggestions[stateRef.current.suggestionsIndex]);
case 'ArrowDown':
case 'ArrowUp':
event.preventDefault();
const direction = event.key === 'ArrowDown' ? 1 : -1;
return setSuggestionsIndex((index) => modulo(index + direction, stateRef.current.suggestions.length));
default:
return next();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
// Update the state of the link in the parent. This is basically done on blur but we need to do it after
// our state have been updated. The duplicity of state is done for perf reasons and also because local
// state also contains things like selection and formating.
if (prevLinkUrl && prevLinkUrl.selection.isFocused && !linkUrl.selection.isFocused) {
stateRef.current.onChange(Plain.serialize(linkUrl));
}
}, [linkUrl, prevLinkUrl]);
const onUrlChange = React.useCallback(({ value }: { value: Value }) => {
setLinkUrl(value);
}, []);
const onVariableSelect = (item: VariableSuggestion, editor = editorRef.current!) => {
const precedingChar: string = getCharactersAroundCaret();
const precedingDollar = precedingChar === '$';
if (item.origin !== VariableOrigin.Template || item.value === DataLinkBuiltInVars.includeVars) {
editor.insertText(`${precedingDollar ? '' : '$'}\{${item.value}}`);
} else {
editor.insertText(`${precedingDollar ? '' : '$'}\{${item.value}:queryparam}`);
}
setLinkUrl(editor.value);
setShowingSuggestions(false);
setSuggestionsIndex(0);
stateRef.current.onChange(Plain.serialize(editor.value));
};
const getCharactersAroundCaret = () => {
const input: HTMLSpanElement | null = document.getElementById('data-link-input')!;
let precedingChar = '',
sel: Selection | null,
range: Range;
if (window.getSelection) {
sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
range = sel.getRangeAt(0).cloneRange();
// Collapse to the start of the range
range.collapse(true);
range.setStart(input, 0);
precedingChar = range.toString().slice(-1);
}
}
return precedingChar;
};
// Memoize autocompletion extension to avoid recreating on every render
const autocompletionExtension = useMemo(() => createDataLinkAutocompletion(suggestions), [suggestions]);
return (
<div className={styles.wrapperOverrides}>
<div className="slate-query-field__wrapper">
<div id="data-link-input" className="slate-query-field">
{showingSuggestions && (
<Portal>
<div ref={refs.setFloating} style={floatingStyles}>
<ScrollContainer
maxHeight="300px"
ref={scrollRef}
onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}
>
<DataLinkSuggestions
activeRef={activeRef}
suggestions={stateRef.current.suggestions}
onSuggestionSelect={onVariableSelect}
onClose={() => setShowingSuggestions(false)}
activeIndex={suggestionsIndex}
/>
</ScrollContainer>
</div>
</Portal>
)}
<Editor
schema={SCHEMA}
ref={editorRef}
placeholder={placeholder}
value={stateRef.current.linkUrl}
onChange={onUrlChange}
onKeyDown={(event, _editor, next) => onKeyDown(event, next)}
plugins={plugins}
className={cx(
styles.editor,
styles.input,
css({
padding: '3px 8px',
})
)}
/>
</div>
</div>
</div>
<CodeMirrorEditor
value={value}
onChange={onChange}
placeholder={placeholder}
themeFactory={createDataLinkTheme}
highlighterFactory={createDataLinkHighlighter}
autocompletion={autocompletionExtension}
ariaLabel={placeholder}
closeBrackets={false}
/>
);
}
);
DataLinkInput.displayName = 'DataLinkInput';
function getElementPosition(suggestionElement: HTMLElement | null, activeIndex: number) {
return (suggestionElement?.clientHeight ?? 0) * activeIndex;
}

View File

@@ -0,0 +1,480 @@
import { CompletionContext } from '@codemirror/autocomplete';
import { EditorState, Extension } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { createTheme, DataLinkBuiltInVars, VariableOrigin, VariableSuggestion } from '@grafana/data';
import {
createDataLinkAutocompletion,
createDataLinkHighlighter,
createDataLinkTheme,
dataLinkAutocompletion,
} from './codemirrorUtils';
// Mock DOM elements required by CodeMirror
beforeAll(() => {
Range.prototype.getClientRects = jest.fn(() => ({
item: () => null,
length: 0,
[Symbol.iterator]: jest.fn(),
}));
Range.prototype.getBoundingClientRect = jest.fn(() => ({
x: 0,
y: 0,
bottom: 0,
height: 0,
left: 0,
right: 0,
top: 0,
width: 0,
toJSON: () => {},
}));
});
const mockSuggestions: VariableSuggestion[] = [
{
value: DataLinkBuiltInVars.seriesName,
label: '__series.name',
documentation: 'Series name',
origin: VariableOrigin.Series,
},
{
value: DataLinkBuiltInVars.fieldName,
label: '__field.name',
documentation: 'Field name',
origin: VariableOrigin.Field,
},
{
value: 'myVar',
label: 'myVar',
documentation: 'Custom variable',
origin: VariableOrigin.Template,
},
{
value: DataLinkBuiltInVars.includeVars,
label: '__all_variables',
documentation: 'Include all variables',
origin: VariableOrigin.Template,
},
];
describe('codemirrorUtils', () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
});
afterEach(() => {
document.body.removeChild(container);
});
/**
* Helper to create editor with extensions
*/
function createEditor(text: string, extensions: Extension | Extension[]) {
const state = EditorState.create({
doc: text,
extensions,
});
return new EditorView({ state, parent: container });
}
describe('createDataLinkTheme', () => {
it('creates theme for light mode', () => {
const theme = createTheme({ colors: { mode: 'light' } });
const themeExtension = createDataLinkTheme(theme);
expect(themeExtension).toBeDefined();
expect(Array.isArray(themeExtension)).toBe(true);
});
it('creates theme for dark mode', () => {
const theme = createTheme({ colors: { mode: 'dark' } });
const themeExtension = createDataLinkTheme(theme);
expect(themeExtension).toBeDefined();
expect(Array.isArray(themeExtension)).toBe(true);
});
it('applies theme to editor', () => {
const theme = createTheme({ colors: { mode: 'light' } });
const themeExtension = createDataLinkTheme(theme);
const view = createEditor('${test}', themeExtension);
expect(view.dom).toBeInstanceOf(HTMLElement);
view.destroy();
});
it('applies theme with variable highlighting', () => {
const theme = createTheme({ colors: { mode: 'dark' } });
const themeExtension = createDataLinkTheme(theme);
const highlighter = createDataLinkHighlighter();
const view = createEditor('${variable}', [themeExtension, highlighter]);
expect(view.dom).toBeInstanceOf(HTMLElement);
const content = view.dom.textContent;
expect(content).toBe('${variable}');
view.destroy();
});
});
describe('createDataLinkHighlighter', () => {
it('creates highlighter extension', () => {
const highlighter = createDataLinkHighlighter();
expect(highlighter).toBeDefined();
});
it('highlights single variable', () => {
const highlighter = createDataLinkHighlighter();
const view = createEditor('${variable}', [highlighter]);
const content = view.dom.textContent;
expect(content).toBe('${variable}');
view.destroy();
});
it('highlights multiple variables', () => {
const highlighter = createDataLinkHighlighter();
const view = createEditor('${var1} and ${var2}', [highlighter]);
const content = view.dom.textContent;
expect(content).toBe('${var1} and ${var2}');
view.destroy();
});
it('highlights variables in URLs', () => {
const highlighter = createDataLinkHighlighter();
const view = createEditor('https://example.com?id=${id}&name=${name}', [highlighter]);
const content = view.dom.textContent;
expect(content).toBe('https://example.com?id=${id}&name=${name}');
view.destroy();
});
it('does not highlight incomplete variables', () => {
const highlighter = createDataLinkHighlighter();
const view = createEditor('${incomplete', [highlighter]);
const content = view.dom.textContent;
expect(content).toBe('${incomplete');
view.destroy();
});
it('highlights variables with dots', () => {
const highlighter = createDataLinkHighlighter();
const view = createEditor('${__series.name}', [highlighter]);
const content = view.dom.textContent;
expect(content).toBe('${__series.name}');
view.destroy();
});
it('highlights variables with underscores', () => {
const highlighter = createDataLinkHighlighter();
const view = createEditor('${__field_name}', [highlighter]);
const content = view.dom.textContent;
expect(content).toBe('${__field_name}');
view.destroy();
});
it('updates highlights when document changes', () => {
const highlighter = createDataLinkHighlighter();
const view = createEditor('initial', [highlighter]);
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: '${newVar}' },
});
const content = view.dom.textContent;
expect(content).toBe('${newVar}');
view.destroy();
});
});
describe('dataLinkAutocompletion', () => {
/**
* Helper to create a mock completion context
*/
function createMockContext(
text: string,
pos: number,
explicit = false
): CompletionContext {
const state = EditorState.create({ doc: text });
return {
state,
pos,
explicit,
matchBefore: (regex: RegExp) => {
const before = text.slice(0, pos);
const match = before.match(regex);
if (!match) {
return null;
}
const from = pos - match[0].length;
return {
from,
to: pos,
text: match[0],
};
},
aborted: false,
addEventListener: jest.fn(),
} as unknown as CompletionContext;
}
describe('explicit completion', () => {
it('shows all suggestions on explicit trigger', () => {
const autocomplete = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('', 0, true);
const result = autocomplete(context);
expect(result).not.toBeNull();
expect(result?.options).toHaveLength(4);
expect(result?.from).toBe(0);
});
it('formats series variable correctly', () => {
const autocomplete = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('', 0, true);
const result = autocomplete(context);
const seriesOption = result?.options.find((opt) => opt.label === '__series.name');
expect(seriesOption).toBeDefined();
expect(seriesOption?.apply).toBe('${__series.name}');
});
it('formats field variable correctly', () => {
const autocomplete = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('', 0, true);
const result = autocomplete(context);
const fieldOption = result?.options.find((opt) => opt.label === '__field.name');
expect(fieldOption).toBeDefined();
expect(fieldOption?.apply).toBe('${__field.name}');
});
it('formats template variable with queryparam', () => {
const autocomplete = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('', 0, true);
const result = autocomplete(context);
const templateOption = result?.options.find((opt) => opt.label === 'myVar');
expect(templateOption).toBeDefined();
expect(templateOption?.apply).toBe('${myVar:queryparam}');
});
it('formats includeVars without queryparam', () => {
const autocomplete = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('', 0, true);
const result = autocomplete(context);
const includeVarsOption = result?.options.find((opt) => opt.label === '__all_variables');
expect(includeVarsOption).toBeDefined();
expect(includeVarsOption?.apply).toBe('${__all_variables}');
});
it('returns null when no suggestions available', () => {
const autocomplete = dataLinkAutocompletion([]);
const context = createMockContext('', 0, true);
const result = autocomplete(context);
expect(result).toBeNull();
});
});
describe('trigger on $ character', () => {
it('shows completions after typing $', () => {
const autocomplete = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('$', 1, false);
const result = autocomplete(context);
expect(result).not.toBeNull();
expect(result?.options).toHaveLength(4);
});
it('shows completions after typing ${', () => {
const autocomplete = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('${', 2, false);
const result = autocomplete(context);
expect(result).not.toBeNull();
expect(result?.options).toHaveLength(4);
});
it('shows completions while typing variable name', () => {
const autocomplete = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('${ser', 5, false);
const result = autocomplete(context);
expect(result).not.toBeNull();
expect(result?.options).toHaveLength(4);
});
it('does not show completions without trigger', () => {
const autocomplete = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('test', 4, false);
const result = autocomplete(context);
expect(result).toBeNull();
});
});
describe('trigger on = character', () => {
it('shows completions after typing =', () => {
const autocomplete = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('url?param=', 10, false);
const result = autocomplete(context);
expect(result).not.toBeNull();
expect(result?.options).toHaveLength(4);
});
it('shows completions after typing =${', () => {
const autocomplete = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('url?param=${', 12, false);
const result = autocomplete(context);
expect(result).not.toBeNull();
expect(result?.options).toHaveLength(4);
});
});
describe('option metadata', () => {
it('includes label for all options', () => {
const autocomplete = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('$', 1, false);
const result = autocomplete(context);
result?.options.forEach((option) => {
expect(option.label).toBeDefined();
expect(typeof option.label).toBe('string');
});
});
it('includes detail (origin) for all options', () => {
const autocomplete = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('$', 1, false);
const result = autocomplete(context);
result?.options.forEach((option) => {
expect(option.detail).toBeDefined();
});
});
it('includes documentation info for all options', () => {
const autocomplete = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('$', 1, false);
const result = autocomplete(context);
result?.options.forEach((option) => {
expect(option.info).toBeDefined();
expect(typeof option.info).toBe('string');
});
});
it('sets type to variable for all options', () => {
const autocomplete = dataLinkAutocompletion(mockSuggestions);
const context = createMockContext('$', 1, false);
const result = autocomplete(context);
result?.options.forEach((option) => {
expect(option.type).toBe('variable');
});
});
});
});
describe('createDataLinkAutocompletion', () => {
it('creates autocompletion extension', () => {
const extension = createDataLinkAutocompletion(mockSuggestions);
expect(extension).toBeDefined();
});
it('applies autocompletion to editor', () => {
const extension = createDataLinkAutocompletion(mockSuggestions);
const view = createEditor('', [extension]);
expect(view.dom).toBeInstanceOf(HTMLElement);
view.destroy();
});
it('works with empty suggestions', () => {
const extension = createDataLinkAutocompletion([]);
const view = createEditor('', [extension]);
expect(view.dom).toBeInstanceOf(HTMLElement);
view.destroy();
});
it('integrates with theme and highlighter', () => {
const theme = createTheme({ colors: { mode: 'light' } });
const themeExtension = createDataLinkTheme(theme);
const highlighter = createDataLinkHighlighter();
const autocompletion = createDataLinkAutocompletion(mockSuggestions);
const view = createEditor('${test}', [themeExtension, highlighter, autocompletion]);
expect(view.dom).toBeInstanceOf(HTMLElement);
const content = view.dom.textContent;
expect(content).toBe('${test}');
view.destroy();
});
});
describe('integration tests', () => {
it('combines all utilities together', () => {
const theme = createTheme({ colors: { mode: 'dark' } });
const themeExtension = createDataLinkTheme(theme);
const highlighter = createDataLinkHighlighter();
const autocompletion = createDataLinkAutocompletion(mockSuggestions);
const view = createEditor(
'https://example.com?id=${id}&name=${name}',
[themeExtension, highlighter, autocompletion]
);
expect(view.dom).toBeInstanceOf(HTMLElement);
const content = view.dom.textContent;
expect(content).toBe('https://example.com?id=${id}&name=${name}');
view.destroy();
});
it('handles dynamic content updates', () => {
const theme = createTheme({ colors: { mode: 'light' } });
const themeExtension = createDataLinkTheme(theme);
const highlighter = createDataLinkHighlighter();
const view = createEditor('initial', [themeExtension, highlighter]);
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: '${variable} updated' },
});
const content = view.dom.textContent;
expect(content).toBe('${variable} updated');
view.destroy();
});
});
});

View File

@@ -0,0 +1,145 @@
import { autocompletion, Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import { Extension } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { DataLinkBuiltInVars, GrafanaTheme2, VariableOrigin, VariableSuggestion } from '@grafana/data';
import { createGenericHighlighter } from '../CodeMirror/highlight';
import { createGenericTheme } from '../CodeMirror/styles';
/**
* Creates a CodeMirror theme for data link input with custom variable styling
* This extends the generic theme with data link-specific styles
*/
export function createDataLinkTheme(theme: GrafanaTheme2): Extension {
const genericTheme = createGenericTheme(theme);
// Add data link-specific variable styling
const dataLinkStyles = EditorView.theme({
'.cm-variable': {
color: theme.colors.success.text,
fontWeight: theme.typography.fontWeightMedium,
},
});
return [genericTheme, dataLinkStyles];
}
/**
* Creates a syntax highlighter for data link variables (${...})
* Matches the pattern from the old Prism implementation: (\${\S+?})
*/
export function createDataLinkHighlighter(): Extension {
// Regular expression matching ${...} patterns (same as old implementation)
const variablePattern = /\$\{[^}]+\}/g;
return createGenericHighlighter({
pattern: variablePattern,
className: 'cm-variable',
});
}
/**
* Helper function to generate the apply text for a variable suggestion
*/
function getApplyText(suggestion: VariableSuggestion): string {
if (suggestion.origin !== VariableOrigin.Template || suggestion.value === DataLinkBuiltInVars.includeVars) {
return `\${${suggestion.value}}`;
}
return `\${${suggestion.value}:queryparam}`;
}
/**
* Helper function to create a completion option from a suggestion
*/
function createCompletionOption(
suggestion: VariableSuggestion,
customApply?: (view: EditorView, completion: Completion, from: number, to: number) => void
): Completion {
const applyText = getApplyText(suggestion);
return {
label: suggestion.label,
apply: customApply ?? applyText,
type: 'variable',
};
}
/**
* Creates autocomplete source function for data link variables
* Triggers on $ and = characters
*/
export function dataLinkAutocompletion(
suggestions: VariableSuggestion[]
): (context: CompletionContext) => CompletionResult | null {
return (context: CompletionContext): CompletionResult | null => {
// Don't show completions if there are no suggestions
if (suggestions.length === 0) {
return null;
}
// For explicit completion (Ctrl+Space), show at cursor position
if (context.explicit) {
const options = suggestions.map((suggestion) => createCompletionOption(suggestion));
return {
from: context.pos,
options,
};
}
// Match $ or = followed by optional { and word characters
// This will match: $, ${, ${word, =, etc.
const word = context.matchBefore(/[$=]\{?[\w.]*$/);
// If no match on typing, don't show completions
if (!word) {
return null;
}
// Check if the match starts with a trigger character
const triggerChar = word.text.charAt(0);
if (triggerChar !== '$' && triggerChar !== '=') {
return null;
}
// For single trigger character ($ or =), use custom apply function to handle replacement
const isSingleChar = word.text.length === 1;
const options = suggestions.map((suggestion) => {
if (!isSingleChar) {
return createCompletionOption(suggestion);
}
const applyText = getApplyText(suggestion);
const customApply = (view: EditorView, completion: Completion, from: number, to: number) => {
// Replace from the trigger character position
const wordFrom = triggerChar === '=' ? context.pos : word.from;
view.dispatch({
changes: { from: wordFrom, to, insert: applyText },
selection: { anchor: wordFrom + applyText.length },
});
};
return createCompletionOption(suggestion, customApply);
});
return {
from: isSingleChar ? context.pos : word.from,
options,
};
};
}
/**
* Creates a data link autocompletion extension with configured suggestions
*/
export function createDataLinkAutocompletion(suggestions: VariableSuggestion[]): Extension {
return autocompletion({
override: [dataLinkAutocompletion(suggestions)],
activateOnTyping: true,
closeOnBlur: true,
maxRenderedOptions: 100,
defaultKeymap: true,
interactionDelay: 0,
});
}

View File

@@ -92,6 +92,18 @@ export {
} from './components/Monaco/types';
export { variableSuggestionToCodeEditorSuggestion } from './components/Monaco/utils';
// CodeMirror
export { CodeMirrorEditor } from './components/CodeMirror/CodeMirrorEditor';
export { createGenericTheme } from './components/CodeMirror/styles';
export { createGenericHighlighter } from './components/CodeMirror/highlight';
export type {
CodeMirrorEditorProps,
ThemeFactory,
HighlighterFactory,
AutocompletionFactory,
SyntaxHighlightConfig,
} from './components/CodeMirror/types';
// TODO: namespace
export { Modal, type Props as ModalProps } from './components/Modal/Modal';
export { ModalHeader } from './components/Modal/ModalHeader';

View File

@@ -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,
},
}
}

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,

View File

@@ -872,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",

View File

@@ -120,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
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
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

View File

@@ -2246,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",

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.

View File

@@ -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"
}

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",

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

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;

View File

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

View File

@@ -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',

View File

@@ -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 v2→Scene→v1 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;
}
/**

View File

@@ -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]

View File

@@ -1,3 +1,4 @@
import { reportInteraction } from '@grafana/runtime';
import { Box, Card, Icon, Link, Stack, Text, useStyles2 } from '@grafana/ui';
import { LocationInfo } from 'app/features/search/service/types';
import { StarToolbarButton } from 'app/features/stars/StarToolbarButton';
@@ -11,11 +12,26 @@ interface Props {
showFolderNames: boolean;
locationInfo?: LocationInfo;
layoutMode: 'list' | 'card';
order?: number; // for rudderstack analytics to track position in card list
onStarChange?: (id: string, isStarred: boolean) => void;
}
export function DashListItem({ dashboard, url, showFolderNames, locationInfo, layoutMode, onStarChange }: Props) {
export function DashListItem({
dashboard,
url,
showFolderNames,
locationInfo,
layoutMode,
order,
onStarChange,
}: Props) {
const css = useStyles2(getStyles);
const onCardLinkClick = () => {
reportInteraction('grafana_recently_viewed_dashboards_click_card', {
cardOrder: order,
});
};
return (
<>
{layoutMode === 'list' ? (
@@ -39,7 +55,9 @@ export function DashListItem({ dashboard, url, showFolderNames, locationInfo, la
) : (
<Card className={css.dashlistCard} noMargin>
<Stack justifyContent="space-between" alignItems="center">
<Link href={url}>{dashboard.name}</Link>
<Link href={url} onClick={onCardLinkClick}>
{dashboard.name}
</Link>
<StarToolbarButton
title={dashboard.name}
group="dashboard.grafana.app"

View File

@@ -11,13 +11,7 @@ failed_checks=()
for file in "$ARTIFACTS_DIR"/*.tgz; do
echo "🔍 Checking NPM package: $file"
# TODO: Fix the error with @grafana/i18n/eslint-resolution
if [[ "$file" == *"@grafana-i18n"* ]]; then
ATTW_FLAGS="--profile node16"
fi
# shellcheck disable=SC2086
if ! NODE_OPTIONS="-C @grafana-app/source" yarn attw "$file" --ignore-rules "false-cjs" $ATTW_FLAGS; then
if ! NODE_OPTIONS="-C @grafana-app/source" yarn attw "$file" --ignore-rules "false-cjs" --profile "node16"; then
echo "attw check failed for $file"
echo ""
failed_checks+=("$file - yarn attw")

111
yarn.lock
View File

@@ -1572,6 +1572,65 @@ __metadata:
languageName: node
linkType: hard
"@codemirror/autocomplete@npm:^6.12.0":
version: 6.20.0
resolution: "@codemirror/autocomplete@npm:6.20.0"
dependencies:
"@codemirror/language": "npm:^6.0.0"
"@codemirror/state": "npm:^6.0.0"
"@codemirror/view": "npm:^6.17.0"
"@lezer/common": "npm:^1.0.0"
checksum: 10/ba3603b860c30dd4f8b7c20085680d2f491022db95fe1f3aa6a58363c64678efb3ba795d715755c8a02121631317cf7fbe44cfa3b4cdb01ebca2b4ed36ea5d8a
languageName: node
linkType: hard
"@codemirror/commands@npm:^6.3.3":
version: 6.10.1
resolution: "@codemirror/commands@npm:6.10.1"
dependencies:
"@codemirror/language": "npm:^6.0.0"
"@codemirror/state": "npm:^6.4.0"
"@codemirror/view": "npm:^6.27.0"
"@lezer/common": "npm:^1.1.0"
checksum: 10/9e305263dc457635fa1c7e5b47756958be5367e38f5bb07a3abfd5966591e2eafd57ea0c5c738b28bb3ab5de64c07a5302ebd49b129ff7e48b225841f66e647f
languageName: node
linkType: hard
"@codemirror/language@npm:^6.0.0, @codemirror/language@npm:^6.10.0":
version: 6.12.1
resolution: "@codemirror/language@npm:6.12.1"
dependencies:
"@codemirror/state": "npm:^6.0.0"
"@codemirror/view": "npm:^6.23.0"
"@lezer/common": "npm:^1.5.0"
"@lezer/highlight": "npm:^1.0.0"
"@lezer/lr": "npm:^1.0.0"
style-mod: "npm:^4.0.0"
checksum: 10/a24c3512d38cbb2a20cc3128da0eea074b4a6102b6a5a041b3dfd5e67638fb61dcdf4743ed87708db882df5d72a84d9f891aac6fa68447830989c8e2d9ffa2ba
languageName: node
linkType: hard
"@codemirror/state@npm:^6.0.0, @codemirror/state@npm:^6.4.0, @codemirror/state@npm:^6.5.0":
version: 6.5.3
resolution: "@codemirror/state@npm:6.5.3"
dependencies:
"@marijn/find-cluster-break": "npm:^1.0.0"
checksum: 10/07dc8e06aa3c78bde36fd584d1e1131a529d244474dd36bffc6ad1033701d6628a02259711692d099b2a482ede015930f20106aa8ebc7b251db6f303bc72caa2
languageName: node
linkType: hard
"@codemirror/view@npm:^6.17.0, @codemirror/view@npm:^6.23.0, @codemirror/view@npm:^6.27.0":
version: 6.39.9
resolution: "@codemirror/view@npm:6.39.9"
dependencies:
"@codemirror/state": "npm:^6.5.0"
crelt: "npm:^1.0.6"
style-mod: "npm:^4.1.0"
w3c-keyname: "npm:^2.2.4"
checksum: 10/9e86b35f31fd4f8b4c2fe608fa6116ddc71261acd842c405de41de1f752268c47ea8e0c400818b4d0481a629e1f773dda9e6f0d24d38ed6a9f6b3d58b2dff669
languageName: node
linkType: hard
"@colors/colors@npm:1.5.0":
version: 1.5.0
resolution: "@colors/colors@npm:1.5.0"
@@ -3770,6 +3829,11 @@ __metadata:
resolution: "@grafana/ui@workspace:packages/grafana-ui"
dependencies:
"@babel/core": "npm:7.28.0"
"@codemirror/autocomplete": "npm:^6.12.0"
"@codemirror/commands": "npm:^6.3.3"
"@codemirror/language": "npm:^6.10.0"
"@codemirror/state": "npm:^6.4.0"
"@codemirror/view": "npm:^6.23.0"
"@emotion/css": "npm:11.13.5"
"@emotion/react": "npm:11.14.0"
"@emotion/serialize": "npm:1.3.3"
@@ -3781,6 +3845,7 @@ __metadata:
"@grafana/i18n": "npm:12.4.0-pre"
"@grafana/schema": "npm:12.4.0-pre"
"@hello-pangea/dnd": "npm:18.0.1"
"@lezer/highlight": "npm:^1.2.0"
"@monaco-editor/react": "npm:4.7.0"
"@popperjs/core": "npm:2.11.8"
"@rc-component/drawer": "npm:1.3.0"
@@ -5192,7 +5257,14 @@ __metadata:
languageName: node
linkType: hard
"@lezer/highlight@npm:1.2.3":
"@lezer/common@npm:^1.1.0, @lezer/common@npm:^1.5.0":
version: 1.5.0
resolution: "@lezer/common@npm:1.5.0"
checksum: 10/d99a45947c5033476f7c16f475b364e5b276e89a351641d8d785ceac88e8175f7b7b7d43dda80c3d9097f5e3379f018404bbe59a41d15992df23a03bbef3519b
languageName: node
linkType: hard
"@lezer/highlight@npm:1.2.3, @lezer/highlight@npm:^1.0.0, @lezer/highlight@npm:^1.2.0":
version: 1.2.3
resolution: "@lezer/highlight@npm:1.2.3"
dependencies:
@@ -5210,6 +5282,15 @@ __metadata:
languageName: node
linkType: hard
"@lezer/lr@npm:^1.0.0":
version: 1.4.7
resolution: "@lezer/lr@npm:1.4.7"
dependencies:
"@lezer/common": "npm:^1.0.0"
checksum: 10/5407e10c8f983eedd8eaace9f2582aac39f7b280cdcf4e396d53ca6c1e654ce1bb2fdbddfbf9a63c8462046be37c8c4da180be7ffaf2d2aa24eb71622f624d85
languageName: node
linkType: hard
"@linaria/core@npm:^4.5.4":
version: 4.5.4
resolution: "@linaria/core@npm:4.5.4"
@@ -5387,6 +5468,13 @@ __metadata:
languageName: node
linkType: hard
"@marijn/find-cluster-break@npm:^1.0.0":
version: 1.0.2
resolution: "@marijn/find-cluster-break@npm:1.0.2"
checksum: 10/92fe7ba43ce3d3314f593e4c2fd822d7089649baff47a474fe04b83e3119931d7cf58388747d429ff65fa2db14f5ca57e787268c482e868fc67759511f61f09b
languageName: node
linkType: hard
"@mdx-js/react@npm:^3.0.0":
version: 3.0.1
resolution: "@mdx-js/react@npm:3.0.1"
@@ -14930,6 +15018,13 @@ __metadata:
languageName: node
linkType: hard
"crelt@npm:^1.0.6":
version: 1.0.6
resolution: "crelt@npm:1.0.6"
checksum: 10/5ed326ca6bd243b1dba6b943f665b21c2c04be03271824bc48f20dba324b0f8233e221f8c67312526d24af2b1243c023dc05a41bd8bd05d1a479fd2c72fb39c3
languageName: node
linkType: hard
"croact-css-styled@npm:^1.1.9":
version: 1.1.9
resolution: "croact-css-styled@npm:1.1.9"
@@ -31798,6 +31893,13 @@ __metadata:
languageName: node
linkType: hard
"style-mod@npm:^4.0.0, style-mod@npm:^4.1.0":
version: 4.1.3
resolution: "style-mod@npm:4.1.3"
checksum: 10/b47465ea953c42e62682a2a366a0946a4aa973cbabb000619acbf5d1c162c94aa019caeb13804e38bed71c2b19b8c778f847542d7e82e9309154ccbb5ef9ca98
languageName: node
linkType: hard
"style-search@npm:^0.1.0":
version: 0.1.0
resolution: "style-search@npm:0.1.0"
@@ -33839,6 +33941,13 @@ __metadata:
languageName: node
linkType: hard
"w3c-keyname@npm:^2.2.4":
version: 2.2.8
resolution: "w3c-keyname@npm:2.2.8"
checksum: 10/95bafa4c04fa2f685a86ca1000069c1ec43ace1f8776c10f226a73296caeddd83f893db885c2c220ebeb6c52d424e3b54d7c0c1e963bbf204038ff1a944fbb07
languageName: node
linkType: hard
"w3c-xmlserializer@npm:^3.0.0":
version: 3.0.0
resolution: "w3c-xmlserializer@npm:3.0.0"