Compare commits
12 Commits
feature/ex
...
ismail/imp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3448f34f9 | ||
|
|
a231cf3318 | ||
|
|
c923b58ef7 | ||
|
|
d84652d8aa | ||
|
|
317bc94634 | ||
|
|
0d1ec94548 | ||
|
|
23a51ec9c5 | ||
|
|
51dcdd3499 | ||
|
|
880bc23c85 | ||
|
|
6dc604c2ea | ||
|
|
77c500dc01 | ||
|
|
bec4d225b3 |
11
.github/actions/change-detection/action.yml
vendored
11
.github/actions/change-detection/action.yml
vendored
@@ -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 }}"
|
||||
|
||||
4
.github/actions/setup-node/action.yml
vendored
4
.github/actions/setup-node/action.yml
vendored
@@ -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'
|
||||
|
||||
66
.github/workflows/frontend-lint.yml
vendored
66
.github/workflows/frontend-lint.yml
vendored
@@ -17,6 +17,7 @@ jobs:
|
||||
outputs:
|
||||
changed: ${{ steps.detect-changes.outputs.frontend }}
|
||||
prettier: ${{ steps.detect-changes.outputs.frontend == 'true' || steps.detect-changes.outputs.docs == 'true' }}
|
||||
changed-frontend-packages: ${{ steps.detect-changes.outputs.frontend-packages }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
@@ -42,11 +43,8 @@ jobs:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: 'yarn.lock'
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node
|
||||
- run: yarn install --immutable --check-cache
|
||||
- run: yarn run prettier:check
|
||||
- run: yarn run lint
|
||||
@@ -63,11 +61,8 @@ jobs:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: 'yarn.lock'
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node
|
||||
- name: Setup Enterprise
|
||||
uses: ./.github/actions/setup-enterprise
|
||||
with:
|
||||
@@ -89,11 +84,8 @@ jobs:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: 'yarn.lock'
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node
|
||||
- run: yarn install --immutable --check-cache
|
||||
- run: yarn run typecheck
|
||||
lint-frontend-typecheck-enterprise:
|
||||
@@ -109,11 +101,8 @@ jobs:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: 'yarn.lock'
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node
|
||||
- name: Setup Enterprise
|
||||
uses: ./.github/actions/setup-enterprise
|
||||
with:
|
||||
@@ -133,11 +122,8 @@ jobs:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: 'yarn.lock'
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node
|
||||
- run: yarn install --immutable --check-cache
|
||||
- name: Generate API clients
|
||||
run: |
|
||||
@@ -164,11 +150,8 @@ jobs:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: 'yarn.lock'
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node
|
||||
- name: Setup Enterprise
|
||||
uses: ./.github/actions/setup-enterprise
|
||||
with:
|
||||
@@ -187,3 +170,26 @@ jobs:
|
||||
echo "${uncommited_error_message}"
|
||||
exit 1
|
||||
fi
|
||||
lint-frontend-packed-packages:
|
||||
needs: detect-changes
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
if: github.event_name == 'pull_request' && needs.detect-changes.outputs.changed-frontend-packages == 'true'
|
||||
name: Verify packed frontend packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout build commit
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
- name: Build and pack packages
|
||||
run: |
|
||||
yarn run packages:build
|
||||
yarn run packages:pack
|
||||
- name: Validate packages
|
||||
run: ./scripts/validate-npm-packages.sh
|
||||
|
||||
@@ -852,6 +852,194 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel-7": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"id": 7,
|
||||
"title": "Single Dashboard DS Query",
|
||||
"description": "Panel with a single -- Dashboard -- datasource query",
|
||||
"links": [],
|
||||
"data": {
|
||||
"kind": "QueryGroup",
|
||||
"spec": {
|
||||
"queries": [
|
||||
{
|
||||
"kind": "PanelQuery",
|
||||
"spec": {
|
||||
"query": {
|
||||
"kind": "datasource",
|
||||
"spec": {
|
||||
"panelId": 1,
|
||||
"withTransforms": true
|
||||
}
|
||||
},
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "-- Dashboard --"
|
||||
},
|
||||
"refId": "A",
|
||||
"hidden": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"transformations": [],
|
||||
"queryOptions": {}
|
||||
}
|
||||
},
|
||||
"vizConfig": {
|
||||
"kind": "stat",
|
||||
"spec": {
|
||||
"pluginVersion": "12.1.0-pre",
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"percentChangeColorMode": "standard",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showPercentChange": false,
|
||||
"textMode": "auto",
|
||||
"wideLayout": true
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"value": 0,
|
||||
"color": "green"
|
||||
}
|
||||
]
|
||||
},
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel-8": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"id": 8,
|
||||
"title": "Multiple Dashboard DS Queries",
|
||||
"description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)",
|
||||
"links": [],
|
||||
"data": {
|
||||
"kind": "QueryGroup",
|
||||
"spec": {
|
||||
"queries": [
|
||||
{
|
||||
"kind": "PanelQuery",
|
||||
"spec": {
|
||||
"query": {
|
||||
"kind": "datasource",
|
||||
"spec": {
|
||||
"panelId": 1,
|
||||
"withTransforms": true
|
||||
}
|
||||
},
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "-- Dashboard --"
|
||||
},
|
||||
"refId": "A",
|
||||
"hidden": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "PanelQuery",
|
||||
"spec": {
|
||||
"query": {
|
||||
"kind": "datasource",
|
||||
"spec": {
|
||||
"panelId": 2,
|
||||
"withTransforms": true
|
||||
}
|
||||
},
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "-- Dashboard --"
|
||||
},
|
||||
"refId": "B",
|
||||
"hidden": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "PanelQuery",
|
||||
"spec": {
|
||||
"query": {
|
||||
"kind": "datasource",
|
||||
"spec": {
|
||||
"panelId": 3,
|
||||
"withTransforms": true
|
||||
}
|
||||
},
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "-- Dashboard --"
|
||||
},
|
||||
"refId": "C",
|
||||
"hidden": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"transformations": [],
|
||||
"queryOptions": {}
|
||||
}
|
||||
},
|
||||
"vizConfig": {
|
||||
"kind": "stat",
|
||||
"spec": {
|
||||
"pluginVersion": "12.1.0-pre",
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"percentChangeColorMode": "standard",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showPercentChange": false,
|
||||
"textMode": "auto",
|
||||
"wideLayout": true
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"value": 0,
|
||||
"color": "green"
|
||||
}
|
||||
]
|
||||
},
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"layout": {
|
||||
@@ -914,6 +1102,24 @@
|
||||
"name": "panel-6"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "AutoGridLayoutItem",
|
||||
"spec": {
|
||||
"element": {
|
||||
"kind": "ElementReference",
|
||||
"name": "panel-7"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "AutoGridLayoutItem",
|
||||
"spec": {
|
||||
"element": {
|
||||
"kind": "ElementReference",
|
||||
"name": "panel-8"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -879,6 +879,200 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel-7": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"id": 7,
|
||||
"title": "Single Dashboard DS Query",
|
||||
"description": "Panel with a single -- Dashboard -- datasource query",
|
||||
"links": [],
|
||||
"data": {
|
||||
"kind": "QueryGroup",
|
||||
"spec": {
|
||||
"queries": [
|
||||
{
|
||||
"kind": "PanelQuery",
|
||||
"spec": {
|
||||
"query": {
|
||||
"kind": "DataQuery",
|
||||
"group": "datasource",
|
||||
"version": "v0",
|
||||
"datasource": {
|
||||
"name": "-- Dashboard --"
|
||||
},
|
||||
"spec": {
|
||||
"panelId": 1,
|
||||
"withTransforms": true
|
||||
}
|
||||
},
|
||||
"refId": "A",
|
||||
"hidden": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"transformations": [],
|
||||
"queryOptions": {}
|
||||
}
|
||||
},
|
||||
"vizConfig": {
|
||||
"kind": "VizConfig",
|
||||
"group": "stat",
|
||||
"version": "12.1.0-pre",
|
||||
"spec": {
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"percentChangeColorMode": "standard",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showPercentChange": false,
|
||||
"textMode": "auto",
|
||||
"wideLayout": true
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"value": 0,
|
||||
"color": "green"
|
||||
}
|
||||
]
|
||||
},
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel-8": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"id": 8,
|
||||
"title": "Multiple Dashboard DS Queries",
|
||||
"description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)",
|
||||
"links": [],
|
||||
"data": {
|
||||
"kind": "QueryGroup",
|
||||
"spec": {
|
||||
"queries": [
|
||||
{
|
||||
"kind": "PanelQuery",
|
||||
"spec": {
|
||||
"query": {
|
||||
"kind": "DataQuery",
|
||||
"group": "datasource",
|
||||
"version": "v0",
|
||||
"datasource": {
|
||||
"name": "-- Dashboard --"
|
||||
},
|
||||
"spec": {
|
||||
"panelId": 1,
|
||||
"withTransforms": true
|
||||
}
|
||||
},
|
||||
"refId": "A",
|
||||
"hidden": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "PanelQuery",
|
||||
"spec": {
|
||||
"query": {
|
||||
"kind": "DataQuery",
|
||||
"group": "datasource",
|
||||
"version": "v0",
|
||||
"datasource": {
|
||||
"name": "-- Dashboard --"
|
||||
},
|
||||
"spec": {
|
||||
"panelId": 2,
|
||||
"withTransforms": true
|
||||
}
|
||||
},
|
||||
"refId": "B",
|
||||
"hidden": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "PanelQuery",
|
||||
"spec": {
|
||||
"query": {
|
||||
"kind": "DataQuery",
|
||||
"group": "datasource",
|
||||
"version": "v0",
|
||||
"datasource": {
|
||||
"name": "-- Dashboard --"
|
||||
},
|
||||
"spec": {
|
||||
"panelId": 3,
|
||||
"withTransforms": true
|
||||
}
|
||||
},
|
||||
"refId": "C",
|
||||
"hidden": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"transformations": [],
|
||||
"queryOptions": {}
|
||||
}
|
||||
},
|
||||
"vizConfig": {
|
||||
"kind": "VizConfig",
|
||||
"group": "stat",
|
||||
"version": "12.1.0-pre",
|
||||
"spec": {
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"percentChangeColorMode": "standard",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showPercentChange": false,
|
||||
"textMode": "auto",
|
||||
"wideLayout": true
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"value": 0,
|
||||
"color": "green"
|
||||
}
|
||||
]
|
||||
},
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"layout": {
|
||||
@@ -973,6 +1167,32 @@
|
||||
"name": "panel-6"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "GridLayoutItem",
|
||||
"spec": {
|
||||
"x": 0,
|
||||
"y": 6,
|
||||
"width": 8,
|
||||
"height": 3,
|
||||
"element": {
|
||||
"kind": "ElementReference",
|
||||
"name": "panel-7"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "GridLayoutItem",
|
||||
"spec": {
|
||||
"x": 8,
|
||||
"y": 6,
|
||||
"width": 8,
|
||||
"height": 3,
|
||||
"element": {
|
||||
"kind": "ElementReference",
|
||||
"name": "panel-8"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -711,6 +711,146 @@
|
||||
],
|
||||
"title": "Mixed DS WITHOUT REFS",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "-- Dashboard --"
|
||||
},
|
||||
"description": "Panel with a single -- Dashboard -- datasource query",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 9,
|
||||
"w": 8,
|
||||
"x": 0,
|
||||
"y": 18
|
||||
},
|
||||
"id": 7,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"percentChangeColorMode": "standard",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showPercentChange": false,
|
||||
"textMode": "auto",
|
||||
"wideLayout": true
|
||||
},
|
||||
"pluginVersion": "12.1.0-pre",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "-- Dashboard --"
|
||||
},
|
||||
"panelId": 1,
|
||||
"refId": "A",
|
||||
"withTransforms": true
|
||||
}
|
||||
],
|
||||
"title": "Single Dashboard DS Query",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "mixed",
|
||||
"uid": "-- Mixed --"
|
||||
},
|
||||
"description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 9,
|
||||
"w": 8,
|
||||
"x": 8,
|
||||
"y": 18
|
||||
},
|
||||
"id": 8,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"percentChangeColorMode": "standard",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showPercentChange": false,
|
||||
"textMode": "auto",
|
||||
"wideLayout": true
|
||||
},
|
||||
"pluginVersion": "12.1.0-pre",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "-- Dashboard --"
|
||||
},
|
||||
"panelId": 1,
|
||||
"refId": "A",
|
||||
"withTransforms": true
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "-- Dashboard --"
|
||||
},
|
||||
"panelId": 2,
|
||||
"refId": "B",
|
||||
"withTransforms": true
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "-- Dashboard --"
|
||||
},
|
||||
"panelId": 3,
|
||||
"refId": "C",
|
||||
"withTransforms": true
|
||||
}
|
||||
],
|
||||
"title": "Multiple Dashboard DS Queries",
|
||||
"type": "stat"
|
||||
}
|
||||
],
|
||||
"preload": false,
|
||||
|
||||
@@ -711,6 +711,146 @@
|
||||
],
|
||||
"title": "Mixed DS WITHOUT REFS",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "-- Dashboard --"
|
||||
},
|
||||
"description": "Panel with a single -- Dashboard -- datasource query",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 9,
|
||||
"w": 8,
|
||||
"x": 0,
|
||||
"y": 18
|
||||
},
|
||||
"id": 7,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"percentChangeColorMode": "standard",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showPercentChange": false,
|
||||
"textMode": "auto",
|
||||
"wideLayout": true
|
||||
},
|
||||
"pluginVersion": "12.1.0-pre",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "-- Dashboard --"
|
||||
},
|
||||
"panelId": 1,
|
||||
"refId": "A",
|
||||
"withTransforms": true
|
||||
}
|
||||
],
|
||||
"title": "Single Dashboard DS Query",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "mixed",
|
||||
"uid": "-- Mixed --"
|
||||
},
|
||||
"description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 9,
|
||||
"w": 8,
|
||||
"x": 8,
|
||||
"y": 18
|
||||
},
|
||||
"id": 8,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"percentChangeColorMode": "standard",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showPercentChange": false,
|
||||
"textMode": "auto",
|
||||
"wideLayout": true
|
||||
},
|
||||
"pluginVersion": "12.1.0-pre",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "-- Dashboard --"
|
||||
},
|
||||
"panelId": 1,
|
||||
"refId": "A",
|
||||
"withTransforms": true
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "-- Dashboard --"
|
||||
},
|
||||
"panelId": 2,
|
||||
"refId": "B",
|
||||
"withTransforms": true
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "-- Dashboard --"
|
||||
},
|
||||
"panelId": 3,
|
||||
"refId": "C",
|
||||
"withTransforms": true
|
||||
}
|
||||
],
|
||||
"title": "Multiple Dashboard DS Queries",
|
||||
"type": "stat"
|
||||
}
|
||||
],
|
||||
"preload": false,
|
||||
|
||||
@@ -879,6 +879,200 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel-7": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"id": 7,
|
||||
"title": "Single Dashboard DS Query",
|
||||
"description": "Panel with a single -- Dashboard -- datasource query",
|
||||
"links": [],
|
||||
"data": {
|
||||
"kind": "QueryGroup",
|
||||
"spec": {
|
||||
"queries": [
|
||||
{
|
||||
"kind": "PanelQuery",
|
||||
"spec": {
|
||||
"query": {
|
||||
"kind": "DataQuery",
|
||||
"group": "datasource",
|
||||
"version": "v0",
|
||||
"datasource": {
|
||||
"name": "-- Dashboard --"
|
||||
},
|
||||
"spec": {
|
||||
"panelId": 1,
|
||||
"withTransforms": true
|
||||
}
|
||||
},
|
||||
"refId": "A",
|
||||
"hidden": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"transformations": [],
|
||||
"queryOptions": {}
|
||||
}
|
||||
},
|
||||
"vizConfig": {
|
||||
"kind": "VizConfig",
|
||||
"group": "stat",
|
||||
"version": "12.1.0-pre",
|
||||
"spec": {
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"percentChangeColorMode": "standard",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showPercentChange": false,
|
||||
"textMode": "auto",
|
||||
"wideLayout": true
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"value": 0,
|
||||
"color": "green"
|
||||
}
|
||||
]
|
||||
},
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel-8": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"id": 8,
|
||||
"title": "Multiple Dashboard DS Queries",
|
||||
"description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)",
|
||||
"links": [],
|
||||
"data": {
|
||||
"kind": "QueryGroup",
|
||||
"spec": {
|
||||
"queries": [
|
||||
{
|
||||
"kind": "PanelQuery",
|
||||
"spec": {
|
||||
"query": {
|
||||
"kind": "DataQuery",
|
||||
"group": "datasource",
|
||||
"version": "v0",
|
||||
"datasource": {
|
||||
"name": "-- Dashboard --"
|
||||
},
|
||||
"spec": {
|
||||
"panelId": 1,
|
||||
"withTransforms": true
|
||||
}
|
||||
},
|
||||
"refId": "A",
|
||||
"hidden": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "PanelQuery",
|
||||
"spec": {
|
||||
"query": {
|
||||
"kind": "DataQuery",
|
||||
"group": "datasource",
|
||||
"version": "v0",
|
||||
"datasource": {
|
||||
"name": "-- Dashboard --"
|
||||
},
|
||||
"spec": {
|
||||
"panelId": 2,
|
||||
"withTransforms": true
|
||||
}
|
||||
},
|
||||
"refId": "B",
|
||||
"hidden": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "PanelQuery",
|
||||
"spec": {
|
||||
"query": {
|
||||
"kind": "DataQuery",
|
||||
"group": "datasource",
|
||||
"version": "v0",
|
||||
"datasource": {
|
||||
"name": "-- Dashboard --"
|
||||
},
|
||||
"spec": {
|
||||
"panelId": 3,
|
||||
"withTransforms": true
|
||||
}
|
||||
},
|
||||
"refId": "C",
|
||||
"hidden": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"transformations": [],
|
||||
"queryOptions": {}
|
||||
}
|
||||
},
|
||||
"vizConfig": {
|
||||
"kind": "VizConfig",
|
||||
"group": "stat",
|
||||
"version": "12.1.0-pre",
|
||||
"spec": {
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"percentChangeColorMode": "standard",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showPercentChange": false,
|
||||
"textMode": "auto",
|
||||
"wideLayout": true
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"value": 0,
|
||||
"color": "green"
|
||||
}
|
||||
]
|
||||
},
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"layout": {
|
||||
@@ -941,6 +1135,24 @@
|
||||
"name": "panel-6"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "AutoGridLayoutItem",
|
||||
"spec": {
|
||||
"element": {
|
||||
"kind": "ElementReference",
|
||||
"name": "panel-7"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "AutoGridLayoutItem",
|
||||
"spec": {
|
||||
"element": {
|
||||
"kind": "ElementReference",
|
||||
"name": "panel-8"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -711,6 +711,146 @@
|
||||
],
|
||||
"title": "Mixed DS WITHOUT REFS",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "-- Dashboard --"
|
||||
},
|
||||
"description": "Panel with a single -- Dashboard -- datasource query",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 3,
|
||||
"w": 8,
|
||||
"x": 0,
|
||||
"y": 6
|
||||
},
|
||||
"id": 7,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"percentChangeColorMode": "standard",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showPercentChange": false,
|
||||
"textMode": "auto",
|
||||
"wideLayout": true
|
||||
},
|
||||
"pluginVersion": "12.1.0-pre",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "-- Dashboard --"
|
||||
},
|
||||
"panelId": 1,
|
||||
"refId": "A",
|
||||
"withTransforms": true
|
||||
}
|
||||
],
|
||||
"title": "Single Dashboard DS Query",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "mixed",
|
||||
"uid": "-- Mixed --"
|
||||
},
|
||||
"description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 3,
|
||||
"w": 8,
|
||||
"x": 8,
|
||||
"y": 6
|
||||
},
|
||||
"id": 8,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"percentChangeColorMode": "standard",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showPercentChange": false,
|
||||
"textMode": "auto",
|
||||
"wideLayout": true
|
||||
},
|
||||
"pluginVersion": "12.1.0-pre",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "-- Dashboard --"
|
||||
},
|
||||
"panelId": 1,
|
||||
"refId": "A",
|
||||
"withTransforms": true
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "-- Dashboard --"
|
||||
},
|
||||
"panelId": 2,
|
||||
"refId": "B",
|
||||
"withTransforms": true
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "-- Dashboard --"
|
||||
},
|
||||
"panelId": 3,
|
||||
"refId": "C",
|
||||
"withTransforms": true
|
||||
}
|
||||
],
|
||||
"title": "Multiple Dashboard DS Queries",
|
||||
"type": "stat"
|
||||
}
|
||||
],
|
||||
"preload": false,
|
||||
|
||||
@@ -711,6 +711,146 @@
|
||||
],
|
||||
"title": "Mixed DS WITHOUT REFS",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "-- Dashboard --"
|
||||
},
|
||||
"description": "Panel with a single -- Dashboard -- datasource query",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 3,
|
||||
"w": 8,
|
||||
"x": 0,
|
||||
"y": 6
|
||||
},
|
||||
"id": 7,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"percentChangeColorMode": "standard",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showPercentChange": false,
|
||||
"textMode": "auto",
|
||||
"wideLayout": true
|
||||
},
|
||||
"pluginVersion": "12.1.0-pre",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "-- Dashboard --"
|
||||
},
|
||||
"panelId": 1,
|
||||
"refId": "A",
|
||||
"withTransforms": true
|
||||
}
|
||||
],
|
||||
"title": "Single Dashboard DS Query",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "mixed",
|
||||
"uid": "-- Mixed --"
|
||||
},
|
||||
"description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 3,
|
||||
"w": 8,
|
||||
"x": 8,
|
||||
"y": 6
|
||||
},
|
||||
"id": 8,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"percentChangeColorMode": "standard",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showPercentChange": false,
|
||||
"textMode": "auto",
|
||||
"wideLayout": true
|
||||
},
|
||||
"pluginVersion": "12.1.0-pre",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "-- Dashboard --"
|
||||
},
|
||||
"panelId": 1,
|
||||
"refId": "A",
|
||||
"withTransforms": true
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "-- Dashboard --"
|
||||
},
|
||||
"panelId": 2,
|
||||
"refId": "B",
|
||||
"withTransforms": true
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "-- Dashboard --"
|
||||
},
|
||||
"panelId": 3,
|
||||
"refId": "C",
|
||||
"withTransforms": true
|
||||
}
|
||||
],
|
||||
"title": "Multiple Dashboard DS Queries",
|
||||
"type": "stat"
|
||||
}
|
||||
],
|
||||
"preload": false,
|
||||
|
||||
@@ -852,6 +852,194 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel-7": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"id": 7,
|
||||
"title": "Single Dashboard DS Query",
|
||||
"description": "Panel with a single -- Dashboard -- datasource query",
|
||||
"links": [],
|
||||
"data": {
|
||||
"kind": "QueryGroup",
|
||||
"spec": {
|
||||
"queries": [
|
||||
{
|
||||
"kind": "PanelQuery",
|
||||
"spec": {
|
||||
"query": {
|
||||
"kind": "datasource",
|
||||
"spec": {
|
||||
"panelId": 1,
|
||||
"withTransforms": true
|
||||
}
|
||||
},
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "-- Dashboard --"
|
||||
},
|
||||
"refId": "A",
|
||||
"hidden": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"transformations": [],
|
||||
"queryOptions": {}
|
||||
}
|
||||
},
|
||||
"vizConfig": {
|
||||
"kind": "stat",
|
||||
"spec": {
|
||||
"pluginVersion": "12.1.0-pre",
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"percentChangeColorMode": "standard",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showPercentChange": false,
|
||||
"textMode": "auto",
|
||||
"wideLayout": true
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"value": 0,
|
||||
"color": "green"
|
||||
}
|
||||
]
|
||||
},
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel-8": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"id": 8,
|
||||
"title": "Multiple Dashboard DS Queries",
|
||||
"description": "Panel with multiple -- Dashboard -- datasource queries (should be mixed)",
|
||||
"links": [],
|
||||
"data": {
|
||||
"kind": "QueryGroup",
|
||||
"spec": {
|
||||
"queries": [
|
||||
{
|
||||
"kind": "PanelQuery",
|
||||
"spec": {
|
||||
"query": {
|
||||
"kind": "datasource",
|
||||
"spec": {
|
||||
"panelId": 1,
|
||||
"withTransforms": true
|
||||
}
|
||||
},
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "-- Dashboard --"
|
||||
},
|
||||
"refId": "A",
|
||||
"hidden": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "PanelQuery",
|
||||
"spec": {
|
||||
"query": {
|
||||
"kind": "datasource",
|
||||
"spec": {
|
||||
"panelId": 2,
|
||||
"withTransforms": true
|
||||
}
|
||||
},
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "-- Dashboard --"
|
||||
},
|
||||
"refId": "B",
|
||||
"hidden": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "PanelQuery",
|
||||
"spec": {
|
||||
"query": {
|
||||
"kind": "datasource",
|
||||
"spec": {
|
||||
"panelId": 3,
|
||||
"withTransforms": true
|
||||
}
|
||||
},
|
||||
"datasource": {
|
||||
"type": "datasource",
|
||||
"uid": "-- Dashboard --"
|
||||
},
|
||||
"refId": "C",
|
||||
"hidden": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"transformations": [],
|
||||
"queryOptions": {}
|
||||
}
|
||||
},
|
||||
"vizConfig": {
|
||||
"kind": "stat",
|
||||
"spec": {
|
||||
"pluginVersion": "12.1.0-pre",
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"percentChangeColorMode": "standard",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showPercentChange": false,
|
||||
"textMode": "auto",
|
||||
"wideLayout": true
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"value": 0,
|
||||
"color": "green"
|
||||
}
|
||||
]
|
||||
},
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"layout": {
|
||||
@@ -946,6 +1134,32 @@
|
||||
"name": "panel-6"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "GridLayoutItem",
|
||||
"spec": {
|
||||
"x": 0,
|
||||
"y": 6,
|
||||
"width": 8,
|
||||
"height": 3,
|
||||
"element": {
|
||||
"kind": "ElementReference",
|
||||
"name": "panel-7"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "GridLayoutItem",
|
||||
"spec": {
|
||||
"x": 8,
|
||||
"y": 6,
|
||||
"width": 8,
|
||||
"height": 3,
|
||||
"element": {
|
||||
"kind": "ElementReference",
|
||||
"name": "panel-8"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1195,16 +1195,36 @@ func getDataSourceForQuery(explicitDS *dashv2alpha1.DashboardDataSourceRef, quer
|
||||
// getPanelDatasource determines the panel-level datasource for V1.
|
||||
// Returns:
|
||||
// - Mixed datasource reference if queries use different datasources
|
||||
// - Mixed datasource reference if multiple queries use Dashboard datasource (they fetch from different panels)
|
||||
// - Dashboard datasource reference if a single query uses Dashboard datasource
|
||||
// - First query's datasource if all queries use the same datasource
|
||||
// - nil if no queries exist
|
||||
// Compares based on V2 input without runtime resolution:
|
||||
// - If query has explicit datasource.uid → use that UID and type
|
||||
// - Else → use query.Kind as type (empty UID)
|
||||
func getPanelDatasource(queries []dashv2alpha1.DashboardPanelQueryKind) map[string]interface{} {
|
||||
const sharedDashboardQuery = "-- Dashboard --"
|
||||
|
||||
if len(queries) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Count how many queries use Dashboard datasource
|
||||
// Multiple dashboard queries need mixed mode because they fetch from different panels
|
||||
// which may have different underlying datasources
|
||||
dashboardDsQueryCount := 0
|
||||
for _, query := range queries {
|
||||
if query.Spec.Datasource != nil && query.Spec.Datasource.Uid != nil && *query.Spec.Datasource.Uid == sharedDashboardQuery {
|
||||
dashboardDsQueryCount++
|
||||
}
|
||||
}
|
||||
if dashboardDsQueryCount > 1 {
|
||||
return map[string]interface{}{
|
||||
"type": "mixed",
|
||||
"uid": "-- Mixed --",
|
||||
}
|
||||
}
|
||||
|
||||
var firstUID, firstType string
|
||||
var hasFirst bool
|
||||
|
||||
@@ -1239,6 +1259,16 @@ func getPanelDatasource(queries []dashv2alpha1.DashboardPanelQueryKind) map[stri
|
||||
}
|
||||
}
|
||||
|
||||
// Handle case when a single query uses Dashboard datasource.
|
||||
// This is needed for the frontend to properly activate and fetch data from source panels.
|
||||
// See DashboardDatasourceBehaviour.tsx for more details.
|
||||
if firstUID == sharedDashboardQuery {
|
||||
return map[string]interface{}{
|
||||
"type": "datasource",
|
||||
"uid": sharedDashboardQuery,
|
||||
}
|
||||
}
|
||||
|
||||
// Not mixed - return the first query's datasource so the panel has a datasource set.
|
||||
// This is required because the frontend's legacy PanelModel.PanelQueryRunner.run uses panel.datasource
|
||||
// to resolve the datasource, and if undefined, it falls back to the default datasource
|
||||
|
||||
@@ -99,12 +99,27 @@ refs:
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/data-source-management/#query-and-resource-caching
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/data-source-management/#query-and-resource-caching
|
||||
mssql-troubleshoot:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/mssql/troubleshooting/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/mssql/troubleshooting/
|
||||
postgres:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/postgres/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/postgres/
|
||||
mysql:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/mysql/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/mysql/
|
||||
---
|
||||
|
||||
# Microsoft SQL Server (MSSQL) data source
|
||||
|
||||
Grafana ships with built-in support for Microsoft SQL Server (MSSQL).
|
||||
You can query and visualize data from any Microsoft SQL Server 2005 or newer, including the Microsoft Azure SQL Database.
|
||||
You can query and visualize data from any Microsoft SQL Server 2005 or newer, including Microsoft Azure SQL Database.
|
||||
|
||||
Use this data source to create dashboards, explore SQL data, and monitor MSSQL-based workloads in real time.
|
||||
|
||||
@@ -113,10 +128,33 @@ The following documentation helps you get started working with the Microsoft SQL
|
||||
- [Configure the Microsoft SQL Server data source](ref:configure-mssql-data-source)
|
||||
- [Microsoft SQL Server query editor](ref:mssql-query-editor)
|
||||
- [Microsoft SQL Server template variables](ref:mssql-template-variables)
|
||||
- [Troubleshoot Microsoft SQL Server data source issues](ref:mssql-troubleshoot)
|
||||
|
||||
## Get the most out of the data source
|
||||
## Supported versions
|
||||
|
||||
After installing and configuring the Microsoft SQL Server data source, you can:
|
||||
This data source supports the following Microsoft SQL Server versions:
|
||||
|
||||
- Microsoft SQL Server 2005 and newer
|
||||
- Microsoft Azure SQL Database
|
||||
- Azure SQL Managed Instance
|
||||
|
||||
Grafana recommends using the latest available service pack for your SQL Server version for optimal compatibility.
|
||||
|
||||
## Key capabilities
|
||||
|
||||
The Microsoft SQL Server data source supports:
|
||||
|
||||
- **Time series queries:** Visualize metrics over time using the built-in time grouping macros.
|
||||
- **Table queries:** Display query results in table format for any valid SQL query.
|
||||
- **Template variables:** Create dynamic dashboards with variable-driven queries.
|
||||
- **Annotations:** Overlay events from SQL Server on your dashboard graphs.
|
||||
- **Alerting:** Create alerts based on SQL Server query results.
|
||||
- **Stored procedures:** Execute stored procedures and visualize results.
|
||||
- **Macros:** Simplify queries with built-in macros for time filtering and grouping.
|
||||
|
||||
## Additional resources
|
||||
|
||||
After configuring the Microsoft SQL Server data source, you can:
|
||||
|
||||
- Create a wide variety of [visualizations](ref:visualizations)
|
||||
- Configure and use [templates and variables](ref:variables)
|
||||
@@ -124,3 +162,8 @@ After installing and configuring the Microsoft SQL Server data source, you can:
|
||||
- Add [annotations](ref:annotate-visualizations)
|
||||
- Set up [alerting](ref:alerting)
|
||||
- Optimize performance with [query caching](ref:query-caching)
|
||||
|
||||
## Related data sources
|
||||
|
||||
- [PostgreSQL](ref:postgres) - For PostgreSQL databases.
|
||||
- [MySQL](ref:mysql) - For MySQL and MariaDB databases.
|
||||
|
||||
@@ -89,6 +89,26 @@ refs:
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-access/configure-authentication/azuread/#enable-azure-ad-oauth-in-grafana
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-access/configure-authentication/azuread/#enable-azure-ad-oauth-in-grafana
|
||||
mssql-query-editor:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/mssql/query-editor/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/mssql/query-editor/
|
||||
mssql-template-variables:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/mssql/template-variables/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/mssql/template-variables/
|
||||
alerting:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana-cloud/alerting-and-irm/alerting/
|
||||
mssql-troubleshoot:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/mssql/troubleshooting/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/mssql/troubleshooting/
|
||||
---
|
||||
|
||||
# Configure the Microsoft SQL Server data source
|
||||
@@ -97,13 +117,28 @@ This document provides instructions for configuring the Microsoft SQL Server dat
|
||||
|
||||
## Before you begin
|
||||
|
||||
- Grafana comes with a built-in MSSQL data source plugin, eliminating the need to install a plugin.
|
||||
Before configuring the Microsoft SQL Server data source, ensure you have the following:
|
||||
|
||||
- You must have the `Organization administrator` role to configure the MSSQL data source. Organization administrators can also [configure the data source via YAML](#provision-the-data-source) with the Grafana provisioning system.
|
||||
- **Grafana permissions:** You must have the `Organization administrator` role to configure data sources. Organization administrators can also [configure the data source via YAML](#provision-the-data-source) with the Grafana provisioning system.
|
||||
|
||||
- Familiarize yourself with your MSSQL security configuration and gather any necessary security certificates and client keys.
|
||||
- **A running SQL Server instance:** Microsoft SQL Server 2005 or newer, Azure SQL Database, or Azure SQL Managed Instance.
|
||||
|
||||
- Verify that data from MSSQL is being written to your Grafana instance.
|
||||
- **Network access:** Grafana must be able to reach your SQL Server. The default port is `1433`.
|
||||
|
||||
- **Authentication credentials:** Depending on your authentication method, you need one of:
|
||||
- SQL Server login credentials (username and password).
|
||||
- Windows/Kerberos credentials and configuration (not supported in Grafana Cloud).
|
||||
- Azure Entra ID app registration or managed identity.
|
||||
|
||||
- **Security certificates:** If using encrypted connections, gather any necessary TLS/SSL certificates.
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
Grafana ships with a built-in Microsoft SQL Server data source plugin. No additional installation is required.
|
||||
{{< /admonition >}}
|
||||
|
||||
{{< admonition type="tip" >}}
|
||||
**Grafana Cloud users:** If your SQL Server is in a private network, you can configure [Private data source connect](ref:private-data-source-connect) to establish connectivity.
|
||||
{{< /admonition >}}
|
||||
|
||||
## Add the MSSQL data source
|
||||
|
||||
@@ -382,3 +417,48 @@ datasources:
|
||||
secureJsonData:
|
||||
password: 'Password!'
|
||||
```
|
||||
|
||||
### Configure with Terraform
|
||||
|
||||
You can configure the Microsoft SQL Server data source using [Terraform](https://www.terraform.io/) with the [Grafana Terraform provider](https://registry.terraform.io/providers/grafana/grafana/latest/docs).
|
||||
|
||||
For more information about provisioning resources with Terraform, refer to the [Grafana as code using Terraform](https://grafana.com/docs/grafana-cloud/developer-resources/infrastructure-as-code/terraform/) documentation.
|
||||
|
||||
#### Terraform example
|
||||
|
||||
The following example creates a basic Microsoft SQL Server data source:
|
||||
|
||||
```hcl
|
||||
resource "grafana_data_source" "mssql" {
|
||||
name = "MSSQL"
|
||||
type = "mssql"
|
||||
url = "localhost:1433"
|
||||
user = "grafana"
|
||||
|
||||
json_data_encoded = jsonencode({
|
||||
database = "grafana"
|
||||
maxOpenConns = 100
|
||||
maxIdleConns = 100
|
||||
maxIdleConnsAuto = true
|
||||
connMaxLifetime = 14400
|
||||
connectionTimeout = 0
|
||||
encrypt = "false"
|
||||
})
|
||||
|
||||
secure_json_data_encoded = jsonencode({
|
||||
password = "Password!"
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
For all available configuration options, refer to the [Grafana provider data source resource documentation](https://registry.terraform.io/providers/grafana/grafana/latest/docs/resources/data_source).
|
||||
|
||||
## Next steps
|
||||
|
||||
After configuring your Microsoft SQL Server data source, you can:
|
||||
|
||||
- [Write queries](ref:mssql-query-editor) using the query editor to explore and visualize your data
|
||||
- [Create template variables](ref:mssql-template-variables) to build dynamic, reusable dashboards
|
||||
- [Add annotations](ref:annotate-visualizations) to overlay SQL Server events on your graphs
|
||||
- [Set up alerting](ref:alerting) to create alert rules based on your SQL Server data
|
||||
- [Troubleshoot issues](ref:mssql-troubleshoot) if you encounter problems with your data source
|
||||
|
||||
333
docs/sources/datasources/mssql/troubleshooting/index.md
Normal file
333
docs/sources/datasources/mssql/troubleshooting/index.md
Normal 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)
|
||||
@@ -9,6 +9,7 @@ import { FieldColorModeId } from '../types/fieldColor';
|
||||
import { FieldConfigPropertyItem, FieldConfigSource } from '../types/fieldOverrides';
|
||||
import { InterpolateFunction } from '../types/panel';
|
||||
import { ThresholdsMode } from '../types/thresholds';
|
||||
import { MappingType } from '../types/valueMapping';
|
||||
import { Registry } from '../utils/Registry';
|
||||
import { locationUtil } from '../utils/location';
|
||||
import { mockStandardProperties } from '../utils/tests/mockStandardProperties';
|
||||
@@ -999,6 +1000,45 @@ describe('setDynamicConfigValue', () => {
|
||||
expect(config.custom.property3).toEqual({});
|
||||
expect(config.displayName).toBeUndefined();
|
||||
});
|
||||
|
||||
it('works correctly with multiple value mappings in the same override', () => {
|
||||
const config: FieldConfig = {
|
||||
mappings: [{ type: MappingType.ValueToText, options: { existing: { text: 'existing' } } }],
|
||||
};
|
||||
|
||||
setDynamicConfigValue(
|
||||
config,
|
||||
{
|
||||
id: 'mappings',
|
||||
value: [{ type: MappingType.ValueToText, options: { first: { text: 'first' } } }],
|
||||
},
|
||||
{
|
||||
fieldConfigRegistry: customFieldRegistry,
|
||||
data: [],
|
||||
field: { type: FieldType.number } as Field,
|
||||
dataFrameIndex: 0,
|
||||
}
|
||||
);
|
||||
|
||||
setDynamicConfigValue(
|
||||
config,
|
||||
{
|
||||
id: 'mappings',
|
||||
value: [{ type: MappingType.ValueToText, options: { second: { text: 'second' } } }],
|
||||
},
|
||||
{
|
||||
fieldConfigRegistry: customFieldRegistry,
|
||||
data: [],
|
||||
field: { type: FieldType.number } as Field,
|
||||
dataFrameIndex: 0,
|
||||
}
|
||||
);
|
||||
|
||||
expect(config.mappings).toHaveLength(3);
|
||||
expect(config.mappings![0]).toEqual({ type: MappingType.ValueToText, options: { existing: { text: 'existing' } } });
|
||||
expect(config.mappings![1]).toEqual({ type: MappingType.ValueToText, options: { first: { text: 'first' } } });
|
||||
expect(config.mappings![2]).toEqual({ type: MappingType.ValueToText, options: { second: { text: 'second' } } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLinksSupplier', () => {
|
||||
|
||||
@@ -341,7 +341,7 @@ export function setDynamicConfigValue(config: FieldConfig, value: DynamicConfigV
|
||||
return;
|
||||
}
|
||||
|
||||
const val = item.process(value.value, context, item.settings);
|
||||
let val = item.process(value.value, context, item.settings);
|
||||
|
||||
const remove = val === undefined || val === null;
|
||||
|
||||
@@ -352,6 +352,15 @@ export function setDynamicConfigValue(config: FieldConfig, value: DynamicConfigV
|
||||
unset(config, item.path);
|
||||
}
|
||||
} else {
|
||||
// Merge arrays (e.g. mappings) when multiple overrides target the same field
|
||||
if (Array.isArray(val)) {
|
||||
const existingValue = item.isCustom ? get(config.custom, item.path) : get(config, item.path);
|
||||
|
||||
if (Array.isArray(existingValue)) {
|
||||
val = [...existingValue, ...val];
|
||||
}
|
||||
}
|
||||
|
||||
if (item.isCustom) {
|
||||
if (!config.custom) {
|
||||
config.custom = {};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
246
packages/grafana-ui/src/components/CodeMirror/README.md
Normal file
246
packages/grafana-ui/src/components/CodeMirror/README.md
Normal 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.
|
||||
246
packages/grafana-ui/src/components/CodeMirror/highlight.test.ts
Normal file
246
packages/grafana-ui/src/components/CodeMirror/highlight.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
54
packages/grafana-ui/src/components/CodeMirror/highlight.ts
Normal file
54
packages/grafana-ui/src/components/CodeMirror/highlight.ts
Normal 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;
|
||||
}
|
||||
189
packages/grafana-ui/src/components/CodeMirror/styles.test.ts
Normal file
189
packages/grafana-ui/src/components/CodeMirror/styles.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
92
packages/grafana-ui/src/components/CodeMirror/styles.ts
Normal file
92
packages/grafana-ui/src/components/CodeMirror/styles.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
107
packages/grafana-ui/src/components/CodeMirror/types.ts
Normal file
107
packages/grafana-ui/src/components/CodeMirror/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
145
packages/grafana-ui/src/components/DataLinks/codemirrorUtils.ts
Normal file
145
packages/grafana-ui/src/components/DataLinks/codemirrorUtils.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
1
pkg/services/featuremgmt/toggles_gen.csv
generated
1
pkg/services/featuremgmt/toggles_gen.csv
generated
@@ -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
|
||||
|
||||
|
3
pkg/services/featuremgmt/toggles_gen.json
generated
3
pkg/services/featuremgmt/toggles_gen.json
generated
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -162,17 +162,6 @@ export class DatasourceAPIVersions {
|
||||
if (group.name.includes('datasource.grafana.app')) {
|
||||
const id = group.name.split('.')[0];
|
||||
apiVersions[id] = group.preferredVersion.version;
|
||||
// workaround for plugins that don't append '-datasource' for the group name
|
||||
// e.g. org-plugin-datasource uses org-plugin.datasource.grafana.app
|
||||
if (!id.endsWith('-datasource')) {
|
||||
if (!id.includes('-')) {
|
||||
// workaroud for Grafana plugins that don't include the org either
|
||||
// e.g. testdata uses testdata.datasource.grafana.app
|
||||
apiVersions[`grafana-${id}-datasource`] = group.preferredVersion.version;
|
||||
} else {
|
||||
apiVersions[`${id}-datasource`] = group.preferredVersion.version;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
this.apiVersions = apiVersions;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useAsyncRetry } from 'react-use';
|
||||
|
||||
import { GrafanaTheme2, store } from '@grafana/data';
|
||||
import { t, Trans } from '@grafana/i18n';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { evaluateBooleanFlag } from '@grafana/runtime/internal';
|
||||
import { Button, CollapsableSection, Spinner, Stack, Text, useStyles2, Grid } from '@grafana/ui';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
@@ -35,10 +36,18 @@ export function RecentlyViewedDashboards() {
|
||||
const { foldersByUid } = useDashboardLocationInfo(recentDashboards.length > 0);
|
||||
|
||||
const handleClearHistory = () => {
|
||||
reportInteraction('grafana_recently_viewed_dashboards_clear_history');
|
||||
store.set(recentDashboardsKey, JSON.stringify([]));
|
||||
retry();
|
||||
};
|
||||
|
||||
const handleSectionToggle = () => {
|
||||
reportInteraction('grafana_recently_viewed_dashboards_toggle_section', {
|
||||
expanded: !isOpen,
|
||||
});
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
if (!evaluateBooleanFlag('recentlyViewedDashboards', false) || recentDashboards.length === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -48,7 +57,7 @@ export function RecentlyViewedDashboards() {
|
||||
headerDataTestId="browseDashboardsRecentlyViewedTitle"
|
||||
label={
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="baseline" width="100%">
|
||||
<Text variant="h5" element="h3" onClick={() => setIsOpen(!isOpen)}>
|
||||
<Text variant="h5" element="h3" onClick={handleSectionToggle}>
|
||||
<Trans i18nKey="browse-dashboards.recently-viewed.title">Recently viewed</Trans>
|
||||
</Text>
|
||||
<Button icon="times" size="xs" variant="secondary" fill="text" onClick={handleClearHistory}>
|
||||
@@ -80,9 +89,10 @@ export function RecentlyViewedDashboards() {
|
||||
{!loading && recentDashboards.length > 0 && (
|
||||
<ul className={styles.list}>
|
||||
<Grid columns={{ xs: 1, sm: 2, md: 3, lg: 5 }} gap={2}>
|
||||
{recentDashboards.map((dash) => (
|
||||
{recentDashboards.map((dash, idx) => (
|
||||
<li key={dash.uid} className={styles.listItem}>
|
||||
<DashListItem
|
||||
order={idx + 1}
|
||||
key={dash.uid}
|
||||
dashboard={dash}
|
||||
url={dash.url}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { defaultDataQueryKind, PanelQueryKind } from '@grafana/schema/dist/esm/schema/dashboard/v2';
|
||||
import {
|
||||
defaultDataQueryKind,
|
||||
defaultPanelSpec,
|
||||
PanelKind,
|
||||
PanelQueryKind,
|
||||
} from '@grafana/schema/dist/esm/schema/dashboard/v2';
|
||||
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/constants';
|
||||
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
|
||||
|
||||
import { ensureUniqueRefIds, getRuntimePanelDataSource } from './utils';
|
||||
import { ensureUniqueRefIds, getPanelDataSource, getRuntimePanelDataSource } from './utils';
|
||||
|
||||
describe('getRuntimePanelDataSource', () => {
|
||||
it('should return uid and type when explicit datasource UID is provided', () => {
|
||||
@@ -141,6 +148,159 @@ describe('getRuntimePanelDataSource', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPanelDataSource', () => {
|
||||
const createPanelWithQueries = (queries: PanelQueryKind[]): PanelKind => ({
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
...defaultPanelSpec(),
|
||||
id: 1,
|
||||
title: 'Test Panel',
|
||||
data: {
|
||||
kind: 'QueryGroup',
|
||||
spec: {
|
||||
queries,
|
||||
queryOptions: {},
|
||||
transformations: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createQuery = (datasourceName: string, group: string, refId = 'A'): PanelQueryKind => ({
|
||||
kind: 'PanelQuery',
|
||||
spec: {
|
||||
refId,
|
||||
hidden: false,
|
||||
query: {
|
||||
kind: 'DataQuery',
|
||||
version: defaultDataQueryKind().version,
|
||||
group,
|
||||
datasource: {
|
||||
name: datasourceName,
|
||||
},
|
||||
spec: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createQueryWithoutDatasourceName = (group: string, refId = 'A'): PanelQueryKind => ({
|
||||
kind: 'PanelQuery',
|
||||
spec: {
|
||||
refId,
|
||||
hidden: false,
|
||||
query: {
|
||||
kind: 'DataQuery',
|
||||
version: defaultDataQueryKind().version,
|
||||
group,
|
||||
spec: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
it('should return undefined when panel has no queries', () => {
|
||||
const panel = createPanelWithQueries([]);
|
||||
|
||||
const result = getPanelDataSource(panel);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for a single query with specific datasource (not mixed)', () => {
|
||||
const panel = createPanelWithQueries([createQuery('prometheus-uid', 'prometheus')]);
|
||||
|
||||
const result = getPanelDataSource(panel);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for multiple queries with the same datasource', () => {
|
||||
const panel = createPanelWithQueries([
|
||||
createQuery('prometheus-uid', 'prometheus', 'A'),
|
||||
createQuery('prometheus-uid', 'prometheus', 'B'),
|
||||
createQuery('prometheus-uid', 'prometheus', 'C'),
|
||||
]);
|
||||
|
||||
const result = getPanelDataSource(panel);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return mixed datasource when queries use different datasource UIDs', () => {
|
||||
const panel = createPanelWithQueries([
|
||||
createQuery('prometheus-uid', 'prometheus', 'A'),
|
||||
createQuery('loki-uid', 'loki', 'B'),
|
||||
]);
|
||||
|
||||
const result = getPanelDataSource(panel);
|
||||
|
||||
expect(result).toEqual({ type: 'mixed', uid: MIXED_DATASOURCE_NAME });
|
||||
});
|
||||
|
||||
it('should return mixed datasource when queries use different datasource types', () => {
|
||||
const panel = createPanelWithQueries([
|
||||
createQuery('ds-uid', 'prometheus', 'A'),
|
||||
createQuery('ds-uid', 'loki', 'B'),
|
||||
]);
|
||||
|
||||
const result = getPanelDataSource(panel);
|
||||
|
||||
expect(result).toEqual({ type: 'mixed', uid: MIXED_DATASOURCE_NAME });
|
||||
});
|
||||
|
||||
it('should return mixed datasource when multiple queries use Dashboard datasource', () => {
|
||||
const panel = createPanelWithQueries([
|
||||
createQuery(SHARED_DASHBOARD_QUERY, 'datasource', 'A'),
|
||||
createQuery(SHARED_DASHBOARD_QUERY, 'datasource', 'B'),
|
||||
createQuery(SHARED_DASHBOARD_QUERY, 'datasource', 'C'),
|
||||
]);
|
||||
|
||||
const result = getPanelDataSource(panel);
|
||||
|
||||
expect(result).toEqual({ type: 'mixed', uid: MIXED_DATASOURCE_NAME });
|
||||
});
|
||||
|
||||
it('should return Dashboard datasource when single query uses Dashboard datasource', () => {
|
||||
const panel = createPanelWithQueries([createQuery(SHARED_DASHBOARD_QUERY, 'datasource')]);
|
||||
|
||||
const result = getPanelDataSource(panel);
|
||||
|
||||
expect(result).toEqual({ type: 'datasource', uid: SHARED_DASHBOARD_QUERY });
|
||||
});
|
||||
|
||||
it('should return mixed when Dashboard datasource is mixed with other datasources', () => {
|
||||
const panel = createPanelWithQueries([
|
||||
createQuery(SHARED_DASHBOARD_QUERY, 'datasource', 'A'),
|
||||
createQuery('prometheus-uid', 'prometheus', 'B'),
|
||||
]);
|
||||
|
||||
const result = getPanelDataSource(panel);
|
||||
|
||||
expect(result).toEqual({ type: 'mixed', uid: MIXED_DATASOURCE_NAME });
|
||||
});
|
||||
|
||||
it('should return undefined when queries have no explicit datasource name but same type', () => {
|
||||
const panel = createPanelWithQueries([
|
||||
createQueryWithoutDatasourceName('prometheus', 'A'),
|
||||
createQueryWithoutDatasourceName('prometheus', 'B'),
|
||||
]);
|
||||
|
||||
const result = getPanelDataSource(panel);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return mixed when queries have no explicit datasource name but different types', () => {
|
||||
const panel = createPanelWithQueries([
|
||||
createQueryWithoutDatasourceName('prometheus', 'A'),
|
||||
createQueryWithoutDatasourceName('loki', 'B'),
|
||||
]);
|
||||
|
||||
const result = getPanelDataSource(panel);
|
||||
|
||||
expect(result).toEqual({ type: 'mixed', uid: MIXED_DATASOURCE_NAME });
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureUniqueRefIds', () => {
|
||||
const createQuery = (refId: string): PanelQueryKind => ({
|
||||
kind: 'PanelQuery',
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
DataQueryKind,
|
||||
defaultPanelQueryKind,
|
||||
} from '@grafana/schema/dist/esm/schema/dashboard/v2';
|
||||
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/constants';
|
||||
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
|
||||
|
||||
import { ConditionalRenderingGroup } from '../../conditional-rendering/group/ConditionalRenderingGroup';
|
||||
@@ -228,29 +229,45 @@ export function createPanelDataProvider(panelKind: PanelKind): SceneDataProvider
|
||||
* This ensures 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
111
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user