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