Compare commits
2 Commits
ensure-fol
...
mckn/tmpl-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e0bd2527c | ||
|
|
5e3baf593d |
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@@ -425,7 +425,6 @@ i18next.config.ts @grafana/grafana-frontend-platform
|
||||
/public/locales/enterprise/i18next.config.ts @grafana/grafana-frontend-platform
|
||||
/public/app/core/internationalization/ @grafana/grafana-frontend-platform
|
||||
/e2e/ @grafana/grafana-frontend-platform
|
||||
/e2e-playwright/alerting-suite/ @grafana/alerting-frontend
|
||||
/e2e-playwright/cloud-plugins-suite/ @grafana/partner-datasources
|
||||
/e2e-playwright/dashboard-new-layouts/ @grafana/dashboards-squad
|
||||
/e2e-playwright/dashboard-cujs/ @grafana/dashboards-squad
|
||||
|
||||
1
.github/actions/change-detection/action.yml
vendored
1
.github/actions/change-detection/action.yml
vendored
@@ -99,7 +99,6 @@ runs:
|
||||
- '${{ inputs.self }}'
|
||||
e2e:
|
||||
- 'e2e/**'
|
||||
- 'e2e-playwright/**'
|
||||
- '.github/actions/setup-enterprise/**'
|
||||
- '.github/actions/checkout/**'
|
||||
- 'emails/**'
|
||||
|
||||
@@ -71,11 +71,12 @@
|
||||
"id": 1,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidthFactor": 0.4,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"rounded": true,
|
||||
"spotlight": false,
|
||||
"gradient": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
@@ -149,11 +150,12 @@
|
||||
"id": 4,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidthFactor": 0.4,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": true,
|
||||
"rounded": true,
|
||||
"spotlight": false,
|
||||
"gradient": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
@@ -227,11 +229,12 @@
|
||||
"id": 3,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidthFactor": 0.4,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"rounded": true,
|
||||
"spotlight": false,
|
||||
"gradient": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
@@ -268,6 +271,85 @@
|
||||
"title": "Center and bar glow",
|
||||
"type": "radialbar"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "grafana-testdata-datasource"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"mappings": [],
|
||||
"max": 100,
|
||||
"min": 0,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": 0
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 6,
|
||||
"w": 4,
|
||||
"x": 12,
|
||||
"y": 1
|
||||
},
|
||||
"id": 5,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barWidthFactor": 0.4,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"rounded": true,
|
||||
"spotlight": true,
|
||||
"gradient": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"segmentCount": 1,
|
||||
"segmentSpacing": 0.3,
|
||||
"shape": "circle",
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": false,
|
||||
"sparkline": false
|
||||
},
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"targets": [
|
||||
{
|
||||
"alias": "1",
|
||||
"datasource": {
|
||||
"type": "grafana-testdata-datasource"
|
||||
},
|
||||
"max": 100,
|
||||
"min": 1,
|
||||
"noise": 22,
|
||||
"refId": "A",
|
||||
"scenarioId": "random_walk",
|
||||
"spread": 22,
|
||||
"startValue": 1
|
||||
}
|
||||
],
|
||||
"title": "Spotlight",
|
||||
"type": "radialbar"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "grafana-testdata-datasource"
|
||||
@@ -309,9 +391,10 @@
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"rounded": true,
|
||||
"spotlight": true,
|
||||
"gradient": false
|
||||
},
|
||||
"barShape": "rounded",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
@@ -387,9 +470,10 @@
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"rounded": false,
|
||||
"spotlight": true,
|
||||
"gradient": false
|
||||
},
|
||||
"barShape": "flat",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
@@ -465,9 +549,10 @@
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"rounded": false,
|
||||
"spotlight": true,
|
||||
"gradient": false
|
||||
},
|
||||
"barShape": "flat",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
@@ -556,9 +641,10 @@
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"rounded": true,
|
||||
"spotlight": true,
|
||||
"gradient": false
|
||||
},
|
||||
"barShape": "rounded",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
@@ -634,9 +720,10 @@
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"rounded": true,
|
||||
"spotlight": true,
|
||||
"gradient": false
|
||||
},
|
||||
"barShape": "rounded",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
@@ -712,9 +799,10 @@
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"rounded": true,
|
||||
"spotlight": true,
|
||||
"gradient": false
|
||||
},
|
||||
"barShape": "rounded",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
@@ -790,9 +878,10 @@
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"rounded": true,
|
||||
"spotlight": true,
|
||||
"gradient": false
|
||||
},
|
||||
"barShape": "rounded",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
@@ -885,9 +974,10 @@
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"rounded": false,
|
||||
"spotlight": false,
|
||||
"gradient": false
|
||||
},
|
||||
"barShape": "flat",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
@@ -963,9 +1053,10 @@
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"rounded": false,
|
||||
"spotlight": false,
|
||||
"gradient": false
|
||||
},
|
||||
"barShape": "flat",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
@@ -1041,9 +1132,10 @@
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"rounded": false,
|
||||
"spotlight": false,
|
||||
"gradient": true
|
||||
},
|
||||
"barShape": "flat",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
@@ -1119,9 +1211,10 @@
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"rounded": false,
|
||||
"spotlight": false,
|
||||
"gradient": false
|
||||
},
|
||||
"barShape": "flat",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
@@ -1197,9 +1290,10 @@
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"rounded": false,
|
||||
"spotlight": false,
|
||||
"gradient": false
|
||||
},
|
||||
"barShape": "flat",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
@@ -1292,9 +1386,10 @@
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"rounded": false,
|
||||
"spotlight": false,
|
||||
"gradient": true
|
||||
},
|
||||
"barShape": "flat",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
@@ -1374,9 +1469,10 @@
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"rounded": false,
|
||||
"spotlight": false,
|
||||
"gradient": true
|
||||
},
|
||||
"barShape": "flat",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
@@ -1456,9 +1552,10 @@
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"rounded": false,
|
||||
"spotlight": false,
|
||||
"gradient": true
|
||||
},
|
||||
"barShape": "flat",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
@@ -1544,13 +1641,13 @@
|
||||
"options": {
|
||||
"barWidth": 12,
|
||||
"barWidthFactor": 0.4,
|
||||
"barShape": "rounded",
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"rounded": true,
|
||||
"spotlight": true,
|
||||
"gradient": true
|
||||
},
|
||||
"endpointMarker": "glow",
|
||||
"glow": "both",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1565,7 +1662,8 @@
|
||||
"shape": "circle",
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": false,
|
||||
"sparkline": false
|
||||
"sparkline": false,
|
||||
"spotlight": true
|
||||
},
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"targets": [
|
||||
@@ -1632,9 +1730,10 @@
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"rounded": true,
|
||||
"spotlight": true,
|
||||
"gradient": true
|
||||
},
|
||||
"barShape": "rounded",
|
||||
"glow": "both",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1649,7 +1748,8 @@
|
||||
"shape": "gauge",
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": false,
|
||||
"sparkline": true
|
||||
"sparkline": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"targets": [
|
||||
@@ -1730,9 +1830,10 @@
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"rounded": true,
|
||||
"spotlight": true,
|
||||
"gradient": true
|
||||
},
|
||||
"barShape": "rounded",
|
||||
"glow": "both",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1747,7 +1848,8 @@
|
||||
"shape": "circle",
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": false,
|
||||
"sparkline": false
|
||||
"sparkline": false,
|
||||
"spotlight": true
|
||||
},
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"targets": [
|
||||
@@ -1815,6 +1917,9 @@
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"rounded": true,
|
||||
"sparkline": false,
|
||||
"spotlight": true,
|
||||
"gradient": true
|
||||
},
|
||||
"glow": "both",
|
||||
@@ -1829,10 +1934,10 @@
|
||||
"segmentCount": 12,
|
||||
"segmentSpacing": 0.3,
|
||||
"shape": "circle",
|
||||
"barShape": "rounded",
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": false,
|
||||
"sparkline": false
|
||||
"sparkline": false,
|
||||
"spotlight": true
|
||||
},
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"targets": [
|
||||
@@ -1899,9 +2004,10 @@
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"rounded": true,
|
||||
"spotlight": true,
|
||||
"gradient": true
|
||||
},
|
||||
"barShape": "rounded",
|
||||
"glow": "both",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1916,7 +2022,8 @@
|
||||
"shape": "circle",
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": false,
|
||||
"sparkline": false
|
||||
"sparkline": false,
|
||||
"spotlight": true
|
||||
},
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"targets": [
|
||||
@@ -1983,9 +2090,10 @@
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"rounded": true,
|
||||
"spotlight": true,
|
||||
"gradient": true
|
||||
},
|
||||
"barShape": "rounded",
|
||||
"glow": "both",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -2000,7 +2108,8 @@
|
||||
"shape": "circle",
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": false,
|
||||
"sparkline": false
|
||||
"sparkline": false,
|
||||
"spotlight": true
|
||||
},
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"targets": [
|
||||
|
||||
@@ -955,6 +955,8 @@
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"rounded": false,
|
||||
"spotlight": false,
|
||||
"gradient": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
|
||||
@@ -77,12 +77,13 @@
|
||||
"id": 1,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidthFactor": 0.4,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -155,12 +156,13 @@
|
||||
"id": 4,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidthFactor": 0.4,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -233,12 +235,13 @@
|
||||
"id": 3,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidthFactor": 0.4,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -274,6 +277,85 @@
|
||||
"title": "Center and bar glow",
|
||||
"type": "radialbar"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "grafana-testdata-datasource"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"mappings": [],
|
||||
"max": 100,
|
||||
"min": 0,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": 0
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 6,
|
||||
"w": 4,
|
||||
"x": 12,
|
||||
"y": 1
|
||||
},
|
||||
"id": 5,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barWidthFactor": 0.4,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"segmentCount": 1,
|
||||
"segmentSpacing": 0.3,
|
||||
"shape": "circle",
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": false,
|
||||
"sparkline": false
|
||||
},
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"targets": [
|
||||
{
|
||||
"alias": "1",
|
||||
"datasource": {
|
||||
"type": "grafana-testdata-datasource"
|
||||
},
|
||||
"max": 100,
|
||||
"min": 1,
|
||||
"noise": 22,
|
||||
"refId": "A",
|
||||
"scenarioId": "random_walk",
|
||||
"spread": 22,
|
||||
"startValue": 1
|
||||
}
|
||||
],
|
||||
"title": "Spotlight",
|
||||
"type": "radialbar"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "grafana-testdata-datasource"
|
||||
@@ -311,12 +393,13 @@
|
||||
"id": 8,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidthFactor": 0.4,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -389,12 +472,13 @@
|
||||
"id": 22,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "flat",
|
||||
"barWidthFactor": 0.72,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": false,
|
||||
"spotlight": true
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -467,12 +551,13 @@
|
||||
"id": 23,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "flat",
|
||||
"barWidthFactor": 0.72,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": false,
|
||||
"spotlight": true
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -558,12 +643,13 @@
|
||||
"id": 18,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidthFactor": 0.1,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -636,12 +722,13 @@
|
||||
"id": 19,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidthFactor": 0.32,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -714,12 +801,13 @@
|
||||
"id": 20,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidthFactor": 0.57,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -792,12 +880,13 @@
|
||||
"id": 21,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidthFactor": 0.8,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -887,12 +976,13 @@
|
||||
"id": 25,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "flat",
|
||||
"barWidthFactor": 0.9,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -965,12 +1055,13 @@
|
||||
"id": 26,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "flat",
|
||||
"barWidthFactor": 0.72,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1043,12 +1134,13 @@
|
||||
"id": 29,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "flat",
|
||||
"barWidthFactor": 0.72,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1121,12 +1213,13 @@
|
||||
"id": 30,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "flat",
|
||||
"barWidthFactor": 0.9,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1199,12 +1292,13 @@
|
||||
"id": 28,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "flat",
|
||||
"barWidthFactor": 0.72,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1294,12 +1388,13 @@
|
||||
"id": 32,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "flat",
|
||||
"barWidthFactor": 0.9,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1376,12 +1471,13 @@
|
||||
"id": 34,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "flat",
|
||||
"barWidthFactor": 0.9,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1458,12 +1554,13 @@
|
||||
"id": 33,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "flat",
|
||||
"barWidthFactor": 0.9,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1548,15 +1645,15 @@
|
||||
"id": 9,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidth": 12,
|
||||
"barWidthFactor": 0.4,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"endpointMarker": "glow",
|
||||
"glow": "both",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1571,7 +1668,8 @@
|
||||
"shape": "circle",
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": false,
|
||||
"sparkline": false
|
||||
"sparkline": false,
|
||||
"spotlight": true
|
||||
},
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"targets": [
|
||||
@@ -1633,13 +1731,14 @@
|
||||
"id": 11,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidth": 12,
|
||||
"barWidthFactor": 0.4,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"glow": "both",
|
||||
"orientation": "auto",
|
||||
@@ -1655,7 +1754,8 @@
|
||||
"shape": "gauge",
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": false,
|
||||
"sparkline": true
|
||||
"sparkline": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"targets": [
|
||||
@@ -1731,13 +1831,14 @@
|
||||
"id": 13,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidth": 12,
|
||||
"barWidthFactor": 0.49,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"glow": "both",
|
||||
"orientation": "auto",
|
||||
@@ -1753,7 +1854,8 @@
|
||||
"shape": "circle",
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": false,
|
||||
"sparkline": false
|
||||
"sparkline": false,
|
||||
"spotlight": true
|
||||
},
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"targets": [
|
||||
@@ -1816,13 +1918,15 @@
|
||||
"id": 14,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidth": 12,
|
||||
"barWidthFactor": 0.49,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": true,
|
||||
"sparkline": false,
|
||||
"spotlight": true
|
||||
},
|
||||
"glow": "both",
|
||||
"orientation": "auto",
|
||||
@@ -1838,7 +1942,8 @@
|
||||
"shape": "circle",
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": false,
|
||||
"sparkline": false
|
||||
"sparkline": false,
|
||||
"spotlight": true
|
||||
},
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"targets": [
|
||||
@@ -1900,13 +2005,14 @@
|
||||
"id": 15,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidth": 12,
|
||||
"barWidthFactor": 0.84,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"glow": "both",
|
||||
"orientation": "auto",
|
||||
@@ -1922,7 +2028,8 @@
|
||||
"shape": "circle",
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": false,
|
||||
"sparkline": false
|
||||
"sparkline": false,
|
||||
"spotlight": true
|
||||
},
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"targets": [
|
||||
@@ -1984,13 +2091,14 @@
|
||||
"id": 16,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidth": 12,
|
||||
"barWidthFactor": 0.66,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"glow": "both",
|
||||
"orientation": "auto",
|
||||
@@ -2006,7 +2114,8 @@
|
||||
"shape": "circle",
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": false,
|
||||
"sparkline": false
|
||||
"sparkline": false,
|
||||
"spotlight": true
|
||||
},
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"targets": [
|
||||
@@ -2051,4 +2160,4 @@
|
||||
"storedVersion": "v0alpha1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,12 +73,13 @@
|
||||
"spec": {
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidthFactor": 0.4,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -164,13 +165,14 @@
|
||||
"spec": {
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidth": 12,
|
||||
"barWidthFactor": 0.4,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"glow": "both",
|
||||
"orientation": "auto",
|
||||
@@ -186,7 +188,8 @@
|
||||
"shape": "gauge",
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": false,
|
||||
"sparkline": true
|
||||
"sparkline": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
@@ -259,13 +262,14 @@
|
||||
"spec": {
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidth": 12,
|
||||
"barWidthFactor": 0.49,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"glow": "both",
|
||||
"orientation": "auto",
|
||||
@@ -281,7 +285,8 @@
|
||||
"shape": "circle",
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": false,
|
||||
"sparkline": false
|
||||
"sparkline": false,
|
||||
"spotlight": true
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
@@ -355,13 +360,15 @@
|
||||
"spec": {
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidth": 12,
|
||||
"barWidthFactor": 0.49,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": true,
|
||||
"sparkline": false,
|
||||
"spotlight": true
|
||||
},
|
||||
"glow": "both",
|
||||
"orientation": "auto",
|
||||
@@ -377,7 +384,8 @@
|
||||
"shape": "circle",
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": false,
|
||||
"sparkline": false
|
||||
"sparkline": false,
|
||||
"spotlight": true
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
@@ -451,13 +459,14 @@
|
||||
"spec": {
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidth": 12,
|
||||
"barWidthFactor": 0.84,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"glow": "both",
|
||||
"orientation": "auto",
|
||||
@@ -473,7 +482,8 @@
|
||||
"shape": "circle",
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": false,
|
||||
"sparkline": false
|
||||
"sparkline": false,
|
||||
"spotlight": true
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
@@ -546,13 +556,14 @@
|
||||
"spec": {
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidth": 12,
|
||||
"barWidthFactor": 0.66,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"glow": "both",
|
||||
"orientation": "auto",
|
||||
@@ -568,7 +579,8 @@
|
||||
"shape": "circle",
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": false,
|
||||
"sparkline": false
|
||||
"sparkline": false,
|
||||
"spotlight": true
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
@@ -641,12 +653,13 @@
|
||||
"spec": {
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidthFactor": 0.1,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -732,12 +745,13 @@
|
||||
"spec": {
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidthFactor": 0.32,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -823,12 +837,13 @@
|
||||
"spec": {
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidthFactor": 0.57,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -914,12 +929,13 @@
|
||||
"spec": {
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidthFactor": 0.8,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1005,12 +1021,13 @@
|
||||
"spec": {
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"options": {
|
||||
"barShape": "flat",
|
||||
"barWidthFactor": 0.72,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": false,
|
||||
"spotlight": true
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1096,12 +1113,13 @@
|
||||
"spec": {
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"options": {
|
||||
"barShape": "flat",
|
||||
"barWidthFactor": 0.72,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": false,
|
||||
"spotlight": true
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1183,12 +1201,13 @@
|
||||
"spec": {
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"options": {
|
||||
"barShape": "flat",
|
||||
"barWidthFactor": 0.9,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1274,12 +1293,13 @@
|
||||
"spec": {
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"options": {
|
||||
"barShape": "flat",
|
||||
"barWidthFactor": 0.72,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1365,12 +1385,13 @@
|
||||
"spec": {
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"options": {
|
||||
"barShape": "flat",
|
||||
"barWidthFactor": 0.72,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1456,12 +1477,13 @@
|
||||
"spec": {
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"options": {
|
||||
"barShape": "flat",
|
||||
"barWidthFactor": 0.72,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1551,12 +1573,13 @@
|
||||
"spec": {
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidthFactor": 0.4,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1638,12 +1661,13 @@
|
||||
"spec": {
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"options": {
|
||||
"barShape": "flat",
|
||||
"barWidthFactor": 0.9,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1729,12 +1753,13 @@
|
||||
"spec": {
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"options": {
|
||||
"barShape": "flat",
|
||||
"barWidthFactor": 0.9,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1824,12 +1849,13 @@
|
||||
"spec": {
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"options": {
|
||||
"barShape": "flat",
|
||||
"barWidthFactor": 0.9,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1919,12 +1945,13 @@
|
||||
"spec": {
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"options": {
|
||||
"barShape": "flat",
|
||||
"barWidthFactor": 0.9,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -2018,12 +2045,105 @@
|
||||
"spec": {
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidthFactor": 0.4,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"segmentCount": 1,
|
||||
"segmentSpacing": 0.3,
|
||||
"shape": "circle",
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": false,
|
||||
"sparkline": false
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"value": 0,
|
||||
"color": "green"
|
||||
},
|
||||
{
|
||||
"value": 80,
|
||||
"color": "red"
|
||||
}
|
||||
]
|
||||
},
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel-5": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"id": 5,
|
||||
"title": "Spotlight",
|
||||
"description": "",
|
||||
"links": [],
|
||||
"data": {
|
||||
"kind": "QueryGroup",
|
||||
"spec": {
|
||||
"queries": [
|
||||
{
|
||||
"kind": "PanelQuery",
|
||||
"spec": {
|
||||
"query": {
|
||||
"kind": "grafana-testdata-datasource",
|
||||
"spec": {
|
||||
"alias": "1",
|
||||
"max": 100,
|
||||
"min": 1,
|
||||
"noise": 22,
|
||||
"scenarioId": "random_walk",
|
||||
"spread": 22,
|
||||
"startValue": 1
|
||||
}
|
||||
},
|
||||
"refId": "A",
|
||||
"hidden": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"transformations": [],
|
||||
"queryOptions": {
|
||||
"maxDataPoints": 20
|
||||
}
|
||||
}
|
||||
},
|
||||
"vizConfig": {
|
||||
"kind": "radialbar",
|
||||
"spec": {
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"options": {
|
||||
"barWidthFactor": 0.4,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -2109,12 +2229,13 @@
|
||||
"spec": {
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidthFactor": 0.4,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -2200,15 +2321,15 @@
|
||||
"spec": {
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidth": 12,
|
||||
"barWidthFactor": 0.4,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"endpointMarker": "glow",
|
||||
"glow": "both",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -2223,7 +2344,8 @@
|
||||
"shape": "circle",
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": false,
|
||||
"sparkline": false
|
||||
"sparkline": false,
|
||||
"spotlight": true
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
@@ -2307,6 +2429,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "GridLayoutItem",
|
||||
"spec": {
|
||||
"x": 12,
|
||||
"y": 0,
|
||||
"width": 4,
|
||||
"height": 6,
|
||||
"element": {
|
||||
"kind": "ElementReference",
|
||||
"name": "panel-5"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "GridLayoutItem",
|
||||
"spec": {
|
||||
@@ -2691,4 +2826,4 @@
|
||||
"storedVersion": "v0alpha1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,12 +77,13 @@
|
||||
"version": "13.0.0-pre",
|
||||
"spec": {
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidthFactor": 0.4,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -171,13 +172,14 @@
|
||||
"version": "13.0.0-pre",
|
||||
"spec": {
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidth": 12,
|
||||
"barWidthFactor": 0.4,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"glow": "both",
|
||||
"orientation": "auto",
|
||||
@@ -193,7 +195,8 @@
|
||||
"shape": "gauge",
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": false,
|
||||
"sparkline": true
|
||||
"sparkline": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
@@ -269,13 +272,14 @@
|
||||
"version": "13.0.0-pre",
|
||||
"spec": {
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidth": 12,
|
||||
"barWidthFactor": 0.49,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"glow": "both",
|
||||
"orientation": "auto",
|
||||
@@ -291,7 +295,8 @@
|
||||
"shape": "circle",
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": false,
|
||||
"sparkline": false
|
||||
"sparkline": false,
|
||||
"spotlight": true
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
@@ -368,13 +373,15 @@
|
||||
"version": "13.0.0-pre",
|
||||
"spec": {
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidth": 12,
|
||||
"barWidthFactor": 0.49,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": true,
|
||||
"sparkline": false,
|
||||
"spotlight": true
|
||||
},
|
||||
"glow": "both",
|
||||
"orientation": "auto",
|
||||
@@ -390,7 +397,8 @@
|
||||
"shape": "circle",
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": false,
|
||||
"sparkline": false
|
||||
"sparkline": false,
|
||||
"spotlight": true
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
@@ -467,13 +475,14 @@
|
||||
"version": "13.0.0-pre",
|
||||
"spec": {
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidth": 12,
|
||||
"barWidthFactor": 0.84,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"glow": "both",
|
||||
"orientation": "auto",
|
||||
@@ -489,7 +498,8 @@
|
||||
"shape": "circle",
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": false,
|
||||
"sparkline": false
|
||||
"sparkline": false,
|
||||
"spotlight": true
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
@@ -565,13 +575,14 @@
|
||||
"version": "13.0.0-pre",
|
||||
"spec": {
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidth": 12,
|
||||
"barWidthFactor": 0.66,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"glow": "both",
|
||||
"orientation": "auto",
|
||||
@@ -587,7 +598,8 @@
|
||||
"shape": "circle",
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": false,
|
||||
"sparkline": false
|
||||
"sparkline": false,
|
||||
"spotlight": true
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
@@ -663,12 +675,13 @@
|
||||
"version": "13.0.0-pre",
|
||||
"spec": {
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidthFactor": 0.1,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -757,12 +770,13 @@
|
||||
"version": "13.0.0-pre",
|
||||
"spec": {
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidthFactor": 0.32,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -851,12 +865,13 @@
|
||||
"version": "13.0.0-pre",
|
||||
"spec": {
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidthFactor": 0.57,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -945,12 +960,13 @@
|
||||
"version": "13.0.0-pre",
|
||||
"spec": {
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidthFactor": 0.8,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1039,12 +1055,13 @@
|
||||
"version": "13.0.0-pre",
|
||||
"spec": {
|
||||
"options": {
|
||||
"barShape": "flat",
|
||||
"barWidthFactor": 0.72,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": false,
|
||||
"spotlight": true
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1133,12 +1150,13 @@
|
||||
"version": "13.0.0-pre",
|
||||
"spec": {
|
||||
"options": {
|
||||
"barShape": "flat",
|
||||
"barWidthFactor": 0.72,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": false,
|
||||
"spotlight": true
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1223,12 +1241,13 @@
|
||||
"version": "13.0.0-pre",
|
||||
"spec": {
|
||||
"options": {
|
||||
"barShape": "flat",
|
||||
"barWidthFactor": 0.9,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1317,12 +1336,13 @@
|
||||
"version": "13.0.0-pre",
|
||||
"spec": {
|
||||
"options": {
|
||||
"barShape": "flat",
|
||||
"barWidthFactor": 0.72,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1411,12 +1431,13 @@
|
||||
"version": "13.0.0-pre",
|
||||
"spec": {
|
||||
"options": {
|
||||
"barShape": "flat",
|
||||
"barWidthFactor": 0.72,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1505,12 +1526,13 @@
|
||||
"version": "13.0.0-pre",
|
||||
"spec": {
|
||||
"options": {
|
||||
"barShape": "flat",
|
||||
"barWidthFactor": 0.72,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1603,12 +1625,13 @@
|
||||
"version": "13.0.0-pre",
|
||||
"spec": {
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidthFactor": 0.4,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1693,12 +1716,13 @@
|
||||
"version": "13.0.0-pre",
|
||||
"spec": {
|
||||
"options": {
|
||||
"barShape": "flat",
|
||||
"barWidthFactor": 0.9,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1787,12 +1811,13 @@
|
||||
"version": "13.0.0-pre",
|
||||
"spec": {
|
||||
"options": {
|
||||
"barShape": "flat",
|
||||
"barWidthFactor": 0.9,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1885,12 +1910,13 @@
|
||||
"version": "13.0.0-pre",
|
||||
"spec": {
|
||||
"options": {
|
||||
"barShape": "flat",
|
||||
"barWidthFactor": 0.9,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1983,12 +2009,13 @@
|
||||
"version": "13.0.0-pre",
|
||||
"spec": {
|
||||
"options": {
|
||||
"barShape": "flat",
|
||||
"barWidthFactor": 0.9,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -2085,12 +2112,108 @@
|
||||
"version": "13.0.0-pre",
|
||||
"spec": {
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidthFactor": 0.4,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"segmentCount": 1,
|
||||
"segmentSpacing": 0.3,
|
||||
"shape": "circle",
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": false,
|
||||
"sparkline": false
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"value": 0,
|
||||
"color": "green"
|
||||
},
|
||||
{
|
||||
"value": 80,
|
||||
"color": "red"
|
||||
}
|
||||
]
|
||||
},
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel-5": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"id": 5,
|
||||
"title": "Spotlight",
|
||||
"description": "",
|
||||
"links": [],
|
||||
"data": {
|
||||
"kind": "QueryGroup",
|
||||
"spec": {
|
||||
"queries": [
|
||||
{
|
||||
"kind": "PanelQuery",
|
||||
"spec": {
|
||||
"query": {
|
||||
"kind": "DataQuery",
|
||||
"group": "grafana-testdata-datasource",
|
||||
"version": "v0",
|
||||
"spec": {
|
||||
"alias": "1",
|
||||
"max": 100,
|
||||
"min": 1,
|
||||
"noise": 22,
|
||||
"scenarioId": "random_walk",
|
||||
"spread": 22,
|
||||
"startValue": 1
|
||||
}
|
||||
},
|
||||
"refId": "A",
|
||||
"hidden": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"transformations": [],
|
||||
"queryOptions": {
|
||||
"maxDataPoints": 20
|
||||
}
|
||||
}
|
||||
},
|
||||
"vizConfig": {
|
||||
"kind": "VizConfig",
|
||||
"group": "radialbar",
|
||||
"version": "13.0.0-pre",
|
||||
"spec": {
|
||||
"options": {
|
||||
"barWidthFactor": 0.4,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -2179,12 +2302,13 @@
|
||||
"version": "13.0.0-pre",
|
||||
"spec": {
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidthFactor": 0.4,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -2273,15 +2397,15 @@
|
||||
"version": "13.0.0-pre",
|
||||
"spec": {
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidth": 12,
|
||||
"barWidthFactor": 0.4,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"endpointMarker": "glow",
|
||||
"glow": "both",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -2296,7 +2420,8 @@
|
||||
"shape": "circle",
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": false,
|
||||
"sparkline": false
|
||||
"sparkline": false,
|
||||
"spotlight": true
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
@@ -2380,6 +2505,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "GridLayoutItem",
|
||||
"spec": {
|
||||
"x": 12,
|
||||
"y": 0,
|
||||
"width": 4,
|
||||
"height": 6,
|
||||
"element": {
|
||||
"kind": "ElementReference",
|
||||
"name": "panel-5"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "GridLayoutItem",
|
||||
"spec": {
|
||||
@@ -2764,4 +2902,4 @@
|
||||
"storedVersion": "v0alpha1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -961,7 +961,9 @@
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1173,4 +1175,4 @@
|
||||
"storedVersion": "v0alpha1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -864,7 +864,9 @@
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1618,4 +1620,4 @@
|
||||
"storedVersion": "v0alpha1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -901,7 +901,9 @@
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1670,4 +1672,4 @@
|
||||
"storedVersion": "v0alpha1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,9 +75,10 @@
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": false
|
||||
},
|
||||
"barShape": "rounded",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
@@ -153,9 +154,10 @@
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": false
|
||||
},
|
||||
"barShape": "rounded",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
@@ -231,9 +233,10 @@
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": false
|
||||
},
|
||||
"barShape": "rounded",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
@@ -302,6 +305,85 @@
|
||||
"x": 12,
|
||||
"y": 1
|
||||
},
|
||||
"id": 5,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barWidthFactor": 0.4,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
"lastNotNull"
|
||||
],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"segmentCount": 1,
|
||||
"segmentSpacing": 0.3,
|
||||
"shape": "circle",
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": false,
|
||||
"sparkline": false
|
||||
},
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"targets": [
|
||||
{
|
||||
"alias": "1",
|
||||
"datasource": {
|
||||
"type": "grafana-testdata-datasource"
|
||||
},
|
||||
"max": 100,
|
||||
"min": 1,
|
||||
"noise": 22,
|
||||
"refId": "A",
|
||||
"scenarioId": "random_walk",
|
||||
"spread": 22,
|
||||
"startValue": 1
|
||||
}
|
||||
],
|
||||
"title": "Spotlight",
|
||||
"type": "radialbar"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "grafana-testdata-datasource"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"mappings": [],
|
||||
"max": 100,
|
||||
"min": 0,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": 0
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 6,
|
||||
"w": 4,
|
||||
"x": 16,
|
||||
"y": 1
|
||||
},
|
||||
"id": 8,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
@@ -309,9 +391,10 @@
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"barShape": "rounded",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
@@ -377,8 +460,8 @@
|
||||
"gridPos": {
|
||||
"h": 6,
|
||||
"w": 4,
|
||||
"x": 16,
|
||||
"y": 1
|
||||
"x": 0,
|
||||
"y": 7
|
||||
},
|
||||
"id": 22,
|
||||
"maxDataPoints": 20,
|
||||
@@ -387,9 +470,10 @@
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": false,
|
||||
"spotlight": true
|
||||
},
|
||||
"barShape": "flat",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
@@ -455,8 +539,8 @@
|
||||
"gridPos": {
|
||||
"h": 6,
|
||||
"w": 4,
|
||||
"x": 20,
|
||||
"y": 1
|
||||
"x": 4,
|
||||
"y": 7
|
||||
},
|
||||
"id": 23,
|
||||
"maxDataPoints": 20,
|
||||
@@ -465,9 +549,10 @@
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": false,
|
||||
"spotlight": true
|
||||
},
|
||||
"barShape": "flat",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
@@ -508,7 +593,7 @@
|
||||
"h": 1,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 7
|
||||
"y": 13
|
||||
},
|
||||
"id": 17,
|
||||
"panels": [],
|
||||
@@ -545,9 +630,9 @@
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 6,
|
||||
"w": 4,
|
||||
"w": 5,
|
||||
"x": 0,
|
||||
"y": 8
|
||||
"y": 14
|
||||
},
|
||||
"id": 18,
|
||||
"maxDataPoints": 20,
|
||||
@@ -556,9 +641,10 @@
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"barShape": "rounded",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
@@ -623,9 +709,9 @@
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 6,
|
||||
"w": 4,
|
||||
"x": 4,
|
||||
"y": 8
|
||||
"w": 5,
|
||||
"x": 5,
|
||||
"y": 14
|
||||
},
|
||||
"id": 19,
|
||||
"maxDataPoints": 20,
|
||||
@@ -634,9 +720,10 @@
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"barShape": "rounded",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
@@ -701,9 +788,9 @@
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 6,
|
||||
"w": 4,
|
||||
"x": 8,
|
||||
"y": 8
|
||||
"w": 5,
|
||||
"x": 10,
|
||||
"y": 14
|
||||
},
|
||||
"id": 20,
|
||||
"maxDataPoints": 20,
|
||||
@@ -712,9 +799,10 @@
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"barShape": "rounded",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
@@ -779,9 +867,9 @@
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 6,
|
||||
"w": 4,
|
||||
"x": 12,
|
||||
"y": 8
|
||||
"w": 5,
|
||||
"x": 15,
|
||||
"y": 14
|
||||
},
|
||||
"id": 21,
|
||||
"maxDataPoints": 20,
|
||||
@@ -790,9 +878,10 @@
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"barShape": "rounded",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
@@ -833,7 +922,7 @@
|
||||
"h": 1,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 14
|
||||
"y": 20
|
||||
},
|
||||
"id": 24,
|
||||
"panels": [],
|
||||
@@ -874,9 +963,9 @@
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 6,
|
||||
"w": 4,
|
||||
"w": 6,
|
||||
"x": 0,
|
||||
"y": 15
|
||||
"y": 21
|
||||
},
|
||||
"id": 25,
|
||||
"maxDataPoints": 20,
|
||||
@@ -885,9 +974,10 @@
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"barShape": "flat",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
@@ -952,9 +1042,9 @@
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 6,
|
||||
"w": 4,
|
||||
"x": 4,
|
||||
"y": 15
|
||||
"w": 6,
|
||||
"x": 6,
|
||||
"y": 21
|
||||
},
|
||||
"id": 26,
|
||||
"maxDataPoints": 20,
|
||||
@@ -963,9 +1053,10 @@
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"barShape": "flat",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
@@ -1030,9 +1121,9 @@
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 6,
|
||||
"w": 4,
|
||||
"x": 8,
|
||||
"y": 15
|
||||
"w": 5,
|
||||
"x": 12,
|
||||
"y": 21
|
||||
},
|
||||
"id": 29,
|
||||
"maxDataPoints": 20,
|
||||
@@ -1041,9 +1132,10 @@
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"barShape": "flat",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
@@ -1107,10 +1199,10 @@
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 6,
|
||||
"w": 4,
|
||||
"x": 12,
|
||||
"y": 15
|
||||
"h": 7,
|
||||
"w": 6,
|
||||
"x": 0,
|
||||
"y": 27
|
||||
},
|
||||
"id": 30,
|
||||
"maxDataPoints": 20,
|
||||
@@ -1119,9 +1211,10 @@
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"barShape": "flat",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
@@ -1185,10 +1278,10 @@
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 6,
|
||||
"w": 4,
|
||||
"x": 16,
|
||||
"y": 15
|
||||
"h": 7,
|
||||
"w": 6,
|
||||
"x": 6,
|
||||
"y": 27
|
||||
},
|
||||
"id": 28,
|
||||
"maxDataPoints": 20,
|
||||
@@ -1197,9 +1290,10 @@
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"barShape": "flat",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
@@ -1236,7 +1330,7 @@
|
||||
"h": 1,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 21
|
||||
"y": 34
|
||||
},
|
||||
"id": 31,
|
||||
"panels": [],
|
||||
@@ -1283,7 +1377,7 @@
|
||||
"h": 10,
|
||||
"w": 7,
|
||||
"x": 0,
|
||||
"y": 22
|
||||
"y": 35
|
||||
},
|
||||
"id": 32,
|
||||
"maxDataPoints": 20,
|
||||
@@ -1292,9 +1386,10 @@
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"barShape": "flat",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
@@ -1365,7 +1460,7 @@
|
||||
"h": 10,
|
||||
"w": 7,
|
||||
"x": 7,
|
||||
"y": 22
|
||||
"y": 35
|
||||
},
|
||||
"id": 34,
|
||||
"maxDataPoints": 20,
|
||||
@@ -1374,9 +1469,10 @@
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"barShape": "flat",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
@@ -1447,7 +1543,7 @@
|
||||
"h": 10,
|
||||
"w": 6,
|
||||
"x": 14,
|
||||
"y": 22
|
||||
"y": 35
|
||||
},
|
||||
"id": 33,
|
||||
"maxDataPoints": 20,
|
||||
@@ -1456,9 +1552,10 @@
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"barShape": "flat",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
@@ -1495,7 +1592,7 @@
|
||||
"h": 1,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 32
|
||||
"y": 45
|
||||
},
|
||||
"id": 6,
|
||||
"panels": [],
|
||||
@@ -1536,20 +1633,20 @@
|
||||
"h": 6,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 33
|
||||
"y": 46
|
||||
},
|
||||
"id": 9,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barWidth": 12,
|
||||
"barWidthFactor": 0.4,
|
||||
"barShape": "rounded",
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"endpointMarker": "glow",
|
||||
"glow": "both",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1564,7 +1661,8 @@
|
||||
"shape": "circle",
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": false,
|
||||
"sparkline": false
|
||||
"sparkline": false,
|
||||
"spotlight": true
|
||||
},
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"targets": [
|
||||
@@ -1619,7 +1717,7 @@
|
||||
"h": 6,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 39
|
||||
"y": 52
|
||||
},
|
||||
"id": 11,
|
||||
"maxDataPoints": 20,
|
||||
@@ -1629,9 +1727,10 @@
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"barShape": "rounded",
|
||||
"glow": "both",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1646,7 +1745,8 @@
|
||||
"shape": "gauge",
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": false,
|
||||
"sparkline": true
|
||||
"sparkline": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"targets": [
|
||||
@@ -1673,7 +1773,7 @@
|
||||
"h": 1,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 45
|
||||
"y": 58
|
||||
},
|
||||
"id": 12,
|
||||
"panels": [],
|
||||
@@ -1715,7 +1815,7 @@
|
||||
"h": 7,
|
||||
"w": 4,
|
||||
"x": 0,
|
||||
"y": 46
|
||||
"y": 59
|
||||
},
|
||||
"id": 13,
|
||||
"maxDataPoints": 20,
|
||||
@@ -1725,9 +1825,10 @@
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"barShape": "rounded",
|
||||
"glow": "both",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1742,7 +1843,8 @@
|
||||
"shape": "circle",
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": false,
|
||||
"sparkline": false
|
||||
"sparkline": false,
|
||||
"spotlight": true
|
||||
},
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"targets": [
|
||||
@@ -1798,7 +1900,7 @@
|
||||
"h": 7,
|
||||
"w": 5,
|
||||
"x": 4,
|
||||
"y": 46
|
||||
"y": 59
|
||||
},
|
||||
"id": 14,
|
||||
"maxDataPoints": 20,
|
||||
@@ -1808,9 +1910,10 @@
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"barShape": "rounded",
|
||||
"glow": "both",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1825,7 +1928,8 @@
|
||||
"shape": "circle",
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": false,
|
||||
"sparkline": false
|
||||
"sparkline": false,
|
||||
"spotlight": true
|
||||
},
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"targets": [
|
||||
@@ -1880,7 +1984,7 @@
|
||||
"h": 7,
|
||||
"w": 5,
|
||||
"x": 9,
|
||||
"y": 46
|
||||
"y": 59
|
||||
},
|
||||
"id": 15,
|
||||
"maxDataPoints": 20,
|
||||
@@ -1890,9 +1994,10 @@
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"barShape": "rounded",
|
||||
"glow": "both",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1907,7 +2012,8 @@
|
||||
"shape": "circle",
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": false,
|
||||
"sparkline": false
|
||||
"sparkline": false,
|
||||
"spotlight": true
|
||||
},
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"targets": [
|
||||
@@ -1962,7 +2068,7 @@
|
||||
"h": 7,
|
||||
"w": 6,
|
||||
"x": 14,
|
||||
"y": 46
|
||||
"y": 59
|
||||
},
|
||||
"id": 16,
|
||||
"maxDataPoints": 20,
|
||||
@@ -1972,9 +2078,10 @@
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"barShape": "rounded",
|
||||
"glow": "both",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1989,7 +2096,8 @@
|
||||
"shape": "circle",
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": false,
|
||||
"sparkline": false
|
||||
"sparkline": false,
|
||||
"spotlight": true
|
||||
},
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"targets": [
|
||||
@@ -2016,7 +2124,7 @@
|
||||
"h": 1,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 53
|
||||
"y": 66
|
||||
},
|
||||
"id": 35,
|
||||
"panels": [],
|
||||
@@ -2047,10 +2155,10 @@
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 5,
|
||||
"w": 12,
|
||||
"h": 8,
|
||||
"w": 6,
|
||||
"x": 0,
|
||||
"y": 54
|
||||
"y": 67
|
||||
},
|
||||
"id": 36,
|
||||
"options": {
|
||||
@@ -2058,9 +2166,10 @@
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"barShape": "flat",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
@@ -2114,10 +2223,10 @@
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 5,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 54
|
||||
"h": 8,
|
||||
"w": 6,
|
||||
"x": 6,
|
||||
"y": 67
|
||||
},
|
||||
"id": 37,
|
||||
"options": {
|
||||
@@ -2125,9 +2234,10 @@
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"barShape": "flat",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": [
|
||||
@@ -2169,4 +2279,4 @@
|
||||
"title": "Panel tests - Gauge (new)",
|
||||
"uid": "panel-tests-gauge-new",
|
||||
"weekStart": ""
|
||||
}
|
||||
}
|
||||
@@ -955,7 +955,9 @@
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1160,4 +1162,4 @@
|
||||
"title": "Panel tests - Old gauge to new",
|
||||
"uid": "panel-tests-old-gauge-to-new",
|
||||
"weekStart": ""
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
authlib "github.com/grafana/authlib/types"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
)
|
||||
|
||||
//go:generate mockery --name AccessChecker --structname MockAccessChecker --inpackage --filename access_checker_mock.go --with-expecter
|
||||
|
||||
// AccessChecker provides access control checks with optional role-based fallback.
|
||||
type AccessChecker interface {
|
||||
// Check performs an access check and returns nil if allowed, or an appropriate
|
||||
// API error if denied. If req.Namespace is empty, it will be filled from the
|
||||
// identity's namespace.
|
||||
Check(ctx context.Context, req authlib.CheckRequest, folder string) error
|
||||
|
||||
// WithFallbackRole returns an AccessChecker configured with the specified fallback role.
|
||||
// Whether the fallback is actually applied depends on the implementation.
|
||||
WithFallbackRole(role identity.RoleType) AccessChecker
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
// Code generated by mockery v2.53.4. DO NOT EDIT.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
context "context"
|
||||
|
||||
identity "github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
mock "github.com/stretchr/testify/mock"
|
||||
|
||||
types "github.com/grafana/authlib/types"
|
||||
)
|
||||
|
||||
// MockAccessChecker is an autogenerated mock type for the AccessChecker type
|
||||
type MockAccessChecker struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
type MockAccessChecker_Expecter struct {
|
||||
mock *mock.Mock
|
||||
}
|
||||
|
||||
func (_m *MockAccessChecker) EXPECT() *MockAccessChecker_Expecter {
|
||||
return &MockAccessChecker_Expecter{mock: &_m.Mock}
|
||||
}
|
||||
|
||||
// Check provides a mock function with given fields: ctx, req, folder
|
||||
func (_m *MockAccessChecker) Check(ctx context.Context, req types.CheckRequest, folder string) error {
|
||||
ret := _m.Called(ctx, req, folder)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for Check")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, types.CheckRequest, string) error); ok {
|
||||
r0 = rf(ctx, req, folder)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// MockAccessChecker_Check_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Check'
|
||||
type MockAccessChecker_Check_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// Check is a helper method to define mock.On call
|
||||
// - ctx context.Context
|
||||
// - req types.CheckRequest
|
||||
// - folder string
|
||||
func (_e *MockAccessChecker_Expecter) Check(ctx interface{}, req interface{}, folder interface{}) *MockAccessChecker_Check_Call {
|
||||
return &MockAccessChecker_Check_Call{Call: _e.mock.On("Check", ctx, req, folder)}
|
||||
}
|
||||
|
||||
func (_c *MockAccessChecker_Check_Call) Run(run func(ctx context.Context, req types.CheckRequest, folder string)) *MockAccessChecker_Check_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(context.Context), args[1].(types.CheckRequest), args[2].(string))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockAccessChecker_Check_Call) Return(_a0 error) *MockAccessChecker_Check_Call {
|
||||
_c.Call.Return(_a0)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockAccessChecker_Check_Call) RunAndReturn(run func(context.Context, types.CheckRequest, string) error) *MockAccessChecker_Check_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// WithFallbackRole provides a mock function with given fields: role
|
||||
func (_m *MockAccessChecker) WithFallbackRole(role identity.RoleType) AccessChecker {
|
||||
ret := _m.Called(role)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for WithFallbackRole")
|
||||
}
|
||||
|
||||
var r0 AccessChecker
|
||||
if rf, ok := ret.Get(0).(func(identity.RoleType) AccessChecker); ok {
|
||||
r0 = rf(role)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(AccessChecker)
|
||||
}
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// MockAccessChecker_WithFallbackRole_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WithFallbackRole'
|
||||
type MockAccessChecker_WithFallbackRole_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// WithFallbackRole is a helper method to define mock.On call
|
||||
// - role identity.RoleType
|
||||
func (_e *MockAccessChecker_Expecter) WithFallbackRole(role interface{}) *MockAccessChecker_WithFallbackRole_Call {
|
||||
return &MockAccessChecker_WithFallbackRole_Call{Call: _e.mock.On("WithFallbackRole", role)}
|
||||
}
|
||||
|
||||
func (_c *MockAccessChecker_WithFallbackRole_Call) Run(run func(role identity.RoleType)) *MockAccessChecker_WithFallbackRole_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
run(args[0].(identity.RoleType))
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockAccessChecker_WithFallbackRole_Call) Return(_a0 AccessChecker) *MockAccessChecker_WithFallbackRole_Call {
|
||||
_c.Call.Return(_a0)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockAccessChecker_WithFallbackRole_Call) RunAndReturn(run func(identity.RoleType) AccessChecker) *MockAccessChecker_WithFallbackRole_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// NewMockAccessChecker creates a new instance of MockAccessChecker. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
|
||||
// The first argument is typically a *testing.T value.
|
||||
func NewMockAccessChecker(t interface {
|
||||
mock.TestingT
|
||||
Cleanup(func())
|
||||
}) *MockAccessChecker {
|
||||
mock := &MockAccessChecker{}
|
||||
mock.Mock.Test(t)
|
||||
|
||||
t.Cleanup(func() { mock.AssertExpectations(t) })
|
||||
|
||||
return mock
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
// Package auth provides authentication utilities for the provisioning API.
|
||||
package auth
|
||||
|
||||
import (
|
||||
@@ -7,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/grafana/authlib/authn"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
utilnet "k8s.io/apimachinery/pkg/util/net"
|
||||
)
|
||||
|
||||
@@ -15,61 +15,29 @@ type tokenExchanger interface {
|
||||
Exchange(ctx context.Context, req authn.TokenExchangeRequest) (*authn.TokenExchangeResponse, error)
|
||||
}
|
||||
|
||||
// RoundTripperOption configures optional behavior for the RoundTripper.
|
||||
type RoundTripperOption func(*RoundTripper)
|
||||
|
||||
// ExtraAudience appends an additional audience to the token exchange request.
|
||||
//
|
||||
// This is primarily used by operators connecting to the multitenant aggregator,
|
||||
// where the token must include both the target API server's audience (e.g., dashboards,
|
||||
// folders) and the provisioning group audience. The provisioning group audience is
|
||||
// required so that the token passes the enforceManagerProperties check, which prevents
|
||||
// unauthorized updates to provisioned resources.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// authrt.NewRoundTripper(client, rt, "dashboards.grafana.app", authrt.ExtraAudience("provisioning.grafana.app"))
|
||||
func ExtraAudience(audience string) RoundTripperOption {
|
||||
return func(rt *RoundTripper) {
|
||||
rt.extraAudience = audience
|
||||
}
|
||||
}
|
||||
|
||||
// RoundTripper is an http.RoundTripper that performs token exchange before each request.
|
||||
// It exchanges the service's credentials for an access token scoped to the configured
|
||||
// audience(s), then injects that token into the outgoing request's X-Access-Token header.
|
||||
// RoundTripper injects an exchanged access token for the provisioning API into outgoing requests.
|
||||
type RoundTripper struct {
|
||||
client tokenExchanger
|
||||
transport http.RoundTripper
|
||||
audience string
|
||||
extraAudience string
|
||||
client tokenExchanger
|
||||
transport http.RoundTripper
|
||||
audience string
|
||||
}
|
||||
|
||||
// NewRoundTripper creates a RoundTripper that exchanges tokens for each outgoing request.
|
||||
//
|
||||
// Parameters:
|
||||
// - tokenExchangeClient: the client used to exchange credentials for access tokens
|
||||
// - base: the underlying transport to delegate requests to after token injection
|
||||
// - audience: the primary audience for the token (typically the target API server's group)
|
||||
// - opts: optional configuration (e.g., ExtraAudience to include additional audiences)
|
||||
func NewRoundTripper(tokenExchangeClient tokenExchanger, base http.RoundTripper, audience string, opts ...RoundTripperOption) *RoundTripper {
|
||||
rt := &RoundTripper{
|
||||
// NewRoundTripper constructs a RoundTripper that exchanges the provided token per request
|
||||
// and forwards the request to the provided base transport.
|
||||
func NewRoundTripper(tokenExchangeClient tokenExchanger, base http.RoundTripper, audience string) *RoundTripper {
|
||||
return &RoundTripper{
|
||||
client: tokenExchangeClient,
|
||||
transport: base,
|
||||
audience: audience,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(rt)
|
||||
}
|
||||
return rt
|
||||
}
|
||||
|
||||
// RoundTrip exchanges credentials for an access token and injects it into the request.
|
||||
// The token is scoped to all configured audiences and the wildcard namespace ("*").
|
||||
func (t *RoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
// when we want to write resources with the provisioning API, the audience needs to include provisioning
|
||||
// so that it passes the check in enforceManagerProperties, which prevents others from updating provisioned resources
|
||||
audiences := []string{t.audience}
|
||||
if t.extraAudience != "" && t.extraAudience != t.audience {
|
||||
audiences = append(audiences, t.extraAudience)
|
||||
if t.audience != v0alpha1.GROUP {
|
||||
audiences = append(audiences, v0alpha1.GROUP)
|
||||
}
|
||||
|
||||
tokenResponse, err := t.client.Exchange(req.Context(), authn.TokenExchangeRequest{
|
||||
|
||||
@@ -71,29 +71,16 @@ func TestRoundTripper_AudiencesAndNamespace(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
audience string
|
||||
extraAudience string
|
||||
wantAudiences []string
|
||||
}{
|
||||
{
|
||||
name: "uses only provided audience by default",
|
||||
name: "adds group when custom audience",
|
||||
audience: "example-audience",
|
||||
wantAudiences: []string{"example-audience"},
|
||||
},
|
||||
{
|
||||
name: "uses only group audience by default",
|
||||
audience: v0alpha1.GROUP,
|
||||
wantAudiences: []string{v0alpha1.GROUP},
|
||||
},
|
||||
{
|
||||
name: "extra audience adds provisioning group",
|
||||
audience: "example-audience",
|
||||
extraAudience: v0alpha1.GROUP,
|
||||
wantAudiences: []string{"example-audience", v0alpha1.GROUP},
|
||||
},
|
||||
{
|
||||
name: "extra audience no duplicate when same as primary",
|
||||
name: "no duplicate when group audience",
|
||||
audience: v0alpha1.GROUP,
|
||||
extraAudience: v0alpha1.GROUP,
|
||||
wantAudiences: []string{v0alpha1.GROUP},
|
||||
},
|
||||
}
|
||||
@@ -101,15 +88,11 @@ func TestRoundTripper_AudiencesAndNamespace(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fx := &fakeExchanger{resp: &authn.TokenExchangeResponse{Token: "abc123"}}
|
||||
var opts []RoundTripperOption
|
||||
if tt.extraAudience != "" {
|
||||
opts = append(opts, ExtraAudience(tt.extraAudience))
|
||||
}
|
||||
tr := NewRoundTripper(fx, roundTripperFunc(func(_ *http.Request) (*http.Response, error) {
|
||||
rr := httptest.NewRecorder()
|
||||
rr.WriteHeader(http.StatusOK)
|
||||
return rr.Result(), nil
|
||||
}), tt.audience, opts...)
|
||||
}), tt.audience)
|
||||
|
||||
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://example", nil)
|
||||
resp, err := tr.RoundTrip(req)
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
|
||||
authlib "github.com/grafana/authlib/types"
|
||||
"github.com/grafana/grafana-app-sdk/logging"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
)
|
||||
|
||||
// sessionAccessChecker implements AccessChecker using Grafana session identity.
|
||||
type sessionAccessChecker struct {
|
||||
inner authlib.AccessChecker
|
||||
fallbackRole identity.RoleType
|
||||
}
|
||||
|
||||
// NewSessionAccessChecker creates an AccessChecker that gets identity from Grafana
|
||||
// sessions via GetRequester(ctx). Supports optional role-based fallback via
|
||||
// WithFallbackRole for backwards compatibility.
|
||||
func NewSessionAccessChecker(inner authlib.AccessChecker) AccessChecker {
|
||||
return &sessionAccessChecker{
|
||||
inner: inner,
|
||||
fallbackRole: "",
|
||||
}
|
||||
}
|
||||
|
||||
// WithFallbackRole returns a new AccessChecker with the specified fallback role.
|
||||
func (c *sessionAccessChecker) WithFallbackRole(role identity.RoleType) AccessChecker {
|
||||
return &sessionAccessChecker{
|
||||
inner: c.inner,
|
||||
fallbackRole: role,
|
||||
}
|
||||
}
|
||||
|
||||
// Check performs an access check with optional role-based fallback.
|
||||
// Returns nil if access is allowed, or an appropriate API error if denied.
|
||||
func (c *sessionAccessChecker) Check(ctx context.Context, req authlib.CheckRequest, folder string) error {
|
||||
logger := logging.FromContext(ctx).With("logger", "sessionAccessChecker")
|
||||
|
||||
// Get identity from Grafana session
|
||||
requester, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
logger.Debug("failed to get requester",
|
||||
"resource", req.Resource,
|
||||
"verb", req.Verb,
|
||||
"error", err.Error(),
|
||||
)
|
||||
return apierrors.NewUnauthorized(fmt.Sprintf("failed to get requester: %v", err))
|
||||
}
|
||||
|
||||
logger.Debug("checking access",
|
||||
"identityType", requester.GetIdentityType(),
|
||||
"orgRole", requester.GetOrgRole(),
|
||||
"namespace", requester.GetNamespace(),
|
||||
"resource", req.Resource,
|
||||
"verb", req.Verb,
|
||||
"group", req.Group,
|
||||
"name", req.Name,
|
||||
"folder", folder,
|
||||
"fallbackRole", c.fallbackRole,
|
||||
)
|
||||
|
||||
// Fill in namespace from identity if not provided
|
||||
if req.Namespace == "" {
|
||||
req.Namespace = requester.GetNamespace()
|
||||
}
|
||||
|
||||
// Perform the access check
|
||||
rsp, err := c.inner.Check(ctx, requester, req, folder)
|
||||
|
||||
// Build the GroupResource for error messages
|
||||
gr := schema.GroupResource{Group: req.Group, Resource: req.Resource}
|
||||
|
||||
// No fallback configured, return result directly
|
||||
if c.fallbackRole == "" {
|
||||
if err != nil {
|
||||
logger.Debug("access check error (no fallback)",
|
||||
"resource", req.Resource,
|
||||
"verb", req.Verb,
|
||||
"error", err.Error(),
|
||||
)
|
||||
return apierrors.NewForbidden(gr, req.Name, fmt.Errorf("%s.%s is forbidden: %w", req.Resource, req.Group, err))
|
||||
}
|
||||
if !rsp.Allowed {
|
||||
logger.Debug("access check denied (no fallback)",
|
||||
"resource", req.Resource,
|
||||
"verb", req.Verb,
|
||||
"group", req.Group,
|
||||
"allowed", rsp.Allowed,
|
||||
)
|
||||
return apierrors.NewForbidden(gr, req.Name, fmt.Errorf("permission denied"))
|
||||
}
|
||||
logger.Debug("access allowed",
|
||||
"resource", req.Resource,
|
||||
"verb", req.Verb,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fallback is configured - apply fallback logic
|
||||
if err != nil {
|
||||
if requester.GetOrgRole().Includes(c.fallbackRole) {
|
||||
logger.Debug("access allowed via role fallback (after error)",
|
||||
"resource", req.Resource,
|
||||
"verb", req.Verb,
|
||||
"fallbackRole", c.fallbackRole,
|
||||
"orgRole", requester.GetOrgRole(),
|
||||
)
|
||||
return nil // Fallback succeeded
|
||||
}
|
||||
logger.Debug("access check error (fallback failed)",
|
||||
"resource", req.Resource,
|
||||
"verb", req.Verb,
|
||||
"error", err.Error(),
|
||||
"fallbackRole", c.fallbackRole,
|
||||
"orgRole", requester.GetOrgRole(),
|
||||
)
|
||||
return apierrors.NewForbidden(gr, req.Name, fmt.Errorf("%s.%s is forbidden: %w", req.Resource, req.Group, err))
|
||||
}
|
||||
|
||||
if rsp.Allowed {
|
||||
logger.Debug("access allowed",
|
||||
"resource", req.Resource,
|
||||
"verb", req.Verb,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fall back to role for backwards compatibility
|
||||
if requester.GetOrgRole().Includes(c.fallbackRole) {
|
||||
logger.Debug("access allowed via role fallback",
|
||||
"resource", req.Resource,
|
||||
"verb", req.Verb,
|
||||
"fallbackRole", c.fallbackRole,
|
||||
"orgRole", requester.GetOrgRole(),
|
||||
)
|
||||
return nil // Fallback succeeded
|
||||
}
|
||||
|
||||
logger.Debug("access denied (fallback role not met)",
|
||||
"resource", req.Resource,
|
||||
"verb", req.Verb,
|
||||
"group", req.Group,
|
||||
"fallbackRole", c.fallbackRole,
|
||||
"orgRole", requester.GetOrgRole(),
|
||||
)
|
||||
return apierrors.NewForbidden(gr, req.Name, fmt.Errorf("%s role is required", strings.ToLower(string(c.fallbackRole))))
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
|
||||
authlib "github.com/grafana/authlib/types"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// mockRequester implements identity.Requester for testing.
|
||||
type mockRequester struct {
|
||||
identity.Requester
|
||||
orgRole identity.RoleType
|
||||
identityType authlib.IdentityType
|
||||
namespace string
|
||||
}
|
||||
|
||||
func (m *mockRequester) GetOrgRole() identity.RoleType {
|
||||
return m.orgRole
|
||||
}
|
||||
|
||||
func (m *mockRequester) GetIdentityType() authlib.IdentityType {
|
||||
return m.identityType
|
||||
}
|
||||
|
||||
func (m *mockRequester) GetNamespace() string {
|
||||
return m.namespace
|
||||
}
|
||||
|
||||
func TestSessionAccessChecker_Check(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
req := authlib.CheckRequest{
|
||||
Verb: "get",
|
||||
Group: "provisioning.grafana.app",
|
||||
Resource: "repositories",
|
||||
Name: "test-repo",
|
||||
Namespace: "default",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
fallbackRole identity.RoleType
|
||||
innerResponse authlib.CheckResponse
|
||||
innerErr error
|
||||
requester *mockRequester
|
||||
expectAllow bool
|
||||
}{
|
||||
{
|
||||
name: "allowed by checker",
|
||||
fallbackRole: identity.RoleAdmin,
|
||||
innerResponse: authlib.CheckResponse{Allowed: true},
|
||||
requester: &mockRequester{orgRole: identity.RoleViewer, identityType: authlib.TypeUser},
|
||||
expectAllow: true,
|
||||
},
|
||||
{
|
||||
name: "denied by checker, fallback to admin role succeeds",
|
||||
fallbackRole: identity.RoleAdmin,
|
||||
innerResponse: authlib.CheckResponse{Allowed: false},
|
||||
requester: &mockRequester{orgRole: identity.RoleAdmin, identityType: authlib.TypeUser},
|
||||
expectAllow: true,
|
||||
},
|
||||
{
|
||||
name: "denied by checker, fallback to admin role fails for viewer",
|
||||
fallbackRole: identity.RoleAdmin,
|
||||
innerResponse: authlib.CheckResponse{Allowed: false},
|
||||
requester: &mockRequester{orgRole: identity.RoleViewer, identityType: authlib.TypeUser},
|
||||
expectAllow: false,
|
||||
},
|
||||
{
|
||||
name: "error from checker, fallback to admin role succeeds",
|
||||
fallbackRole: identity.RoleAdmin,
|
||||
innerErr: errors.New("access check failed"),
|
||||
requester: &mockRequester{orgRole: identity.RoleAdmin, identityType: authlib.TypeUser},
|
||||
expectAllow: true,
|
||||
},
|
||||
{
|
||||
name: "error from checker, fallback fails for viewer",
|
||||
fallbackRole: identity.RoleAdmin,
|
||||
innerErr: errors.New("access check failed"),
|
||||
requester: &mockRequester{orgRole: identity.RoleViewer, identityType: authlib.TypeUser},
|
||||
expectAllow: false,
|
||||
},
|
||||
{
|
||||
name: "denied, editor fallback succeeds for editor",
|
||||
fallbackRole: identity.RoleEditor,
|
||||
innerResponse: authlib.CheckResponse{Allowed: false},
|
||||
requester: &mockRequester{orgRole: identity.RoleEditor, identityType: authlib.TypeUser},
|
||||
expectAllow: true,
|
||||
},
|
||||
{
|
||||
name: "denied, editor fallback fails for viewer",
|
||||
fallbackRole: identity.RoleEditor,
|
||||
innerResponse: authlib.CheckResponse{Allowed: false},
|
||||
requester: &mockRequester{orgRole: identity.RoleViewer, identityType: authlib.TypeUser},
|
||||
expectAllow: false,
|
||||
},
|
||||
{
|
||||
name: "no fallback configured, denied stays denied",
|
||||
fallbackRole: "", // no fallback
|
||||
innerResponse: authlib.CheckResponse{Allowed: false},
|
||||
requester: &mockRequester{orgRole: identity.RoleAdmin, identityType: authlib.TypeUser},
|
||||
expectAllow: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mock := &mockInnerAccessChecker{
|
||||
response: tt.innerResponse,
|
||||
err: tt.innerErr,
|
||||
}
|
||||
|
||||
checker := NewSessionAccessChecker(mock)
|
||||
if tt.fallbackRole != "" {
|
||||
checker = checker.WithFallbackRole(tt.fallbackRole)
|
||||
}
|
||||
|
||||
// Add requester to context
|
||||
testCtx := identity.WithRequester(ctx, tt.requester)
|
||||
|
||||
err := checker.Check(testCtx, req, "")
|
||||
|
||||
if tt.expectAllow {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
require.Error(t, err)
|
||||
assert.True(t, apierrors.IsForbidden(err), "expected Forbidden error, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionAccessChecker_NoRequester(t *testing.T) {
|
||||
mock := &mockInnerAccessChecker{
|
||||
response: authlib.CheckResponse{Allowed: true},
|
||||
}
|
||||
|
||||
checker := NewSessionAccessChecker(mock)
|
||||
err := checker.Check(context.Background(), authlib.CheckRequest{}, "")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.True(t, apierrors.IsUnauthorized(err), "expected Unauthorized error")
|
||||
}
|
||||
|
||||
func TestSessionAccessChecker_WithFallbackRole_ImmutableOriginal(t *testing.T) {
|
||||
mock := &mockInnerAccessChecker{
|
||||
response: authlib.CheckResponse{Allowed: false},
|
||||
}
|
||||
|
||||
original := NewSessionAccessChecker(mock)
|
||||
withAdmin := original.WithFallbackRole(identity.RoleAdmin)
|
||||
withEditor := original.WithFallbackRole(identity.RoleEditor)
|
||||
|
||||
ctx := identity.WithRequester(context.Background(), &mockRequester{
|
||||
orgRole: identity.RoleEditor,
|
||||
identityType: authlib.TypeUser,
|
||||
})
|
||||
|
||||
req := authlib.CheckRequest{}
|
||||
|
||||
// Original should deny (no fallback)
|
||||
err := original.Check(ctx, req, "")
|
||||
require.Error(t, err, "original should deny without fallback")
|
||||
|
||||
// WithAdmin should deny for editor
|
||||
err = withAdmin.Check(ctx, req, "")
|
||||
require.Error(t, err, "admin fallback should deny for editor")
|
||||
|
||||
// WithEditor should allow for editor
|
||||
err = withEditor.Check(ctx, req, "")
|
||||
require.NoError(t, err, "editor fallback should allow for editor")
|
||||
}
|
||||
|
||||
func TestSessionAccessChecker_WithFallbackRole_ChainedCalls(t *testing.T) {
|
||||
mock := &mockInnerAccessChecker{
|
||||
response: authlib.CheckResponse{Allowed: false},
|
||||
}
|
||||
|
||||
// Ensure chained WithFallbackRole calls work correctly
|
||||
checker := NewSessionAccessChecker(mock).
|
||||
WithFallbackRole(identity.RoleAdmin).
|
||||
WithFallbackRole(identity.RoleEditor) // This should override admin
|
||||
|
||||
ctx := identity.WithRequester(context.Background(), &mockRequester{
|
||||
orgRole: identity.RoleEditor,
|
||||
identityType: authlib.TypeUser,
|
||||
})
|
||||
|
||||
err := checker.Check(ctx, authlib.CheckRequest{}, "")
|
||||
require.NoError(t, err, "last fallback (editor) should be used")
|
||||
}
|
||||
|
||||
func TestSessionAccessChecker_RealSignedInUser(t *testing.T) {
|
||||
mock := &mockInnerAccessChecker{
|
||||
response: authlib.CheckResponse{Allowed: false},
|
||||
}
|
||||
|
||||
checker := NewSessionAccessChecker(mock).WithFallbackRole(identity.RoleAdmin)
|
||||
|
||||
// Use a real SignedInUser
|
||||
signedInUser := &user.SignedInUser{
|
||||
UserID: 1,
|
||||
OrgID: 1,
|
||||
OrgRole: identity.RoleAdmin,
|
||||
}
|
||||
|
||||
ctx := identity.WithRequester(context.Background(), signedInUser)
|
||||
|
||||
err := checker.Check(ctx, authlib.CheckRequest{}, "")
|
||||
require.NoError(t, err, "admin user should be allowed via fallback")
|
||||
}
|
||||
|
||||
func TestSessionAccessChecker_FillsNamespace(t *testing.T) {
|
||||
mock := &mockInnerAccessChecker{
|
||||
response: authlib.CheckResponse{Allowed: true},
|
||||
}
|
||||
|
||||
checker := NewSessionAccessChecker(mock)
|
||||
|
||||
ctx := identity.WithRequester(context.Background(), &mockRequester{
|
||||
orgRole: identity.RoleAdmin,
|
||||
identityType: authlib.TypeUser,
|
||||
namespace: "org-123",
|
||||
})
|
||||
|
||||
// Request without namespace
|
||||
req := authlib.CheckRequest{
|
||||
Verb: "get",
|
||||
Group: "provisioning.grafana.app",
|
||||
Resource: "repositories",
|
||||
Name: "test-repo",
|
||||
// Namespace intentionally empty
|
||||
}
|
||||
|
||||
err := checker.Check(ctx, req, "")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
|
||||
authlib "github.com/grafana/authlib/types"
|
||||
"github.com/grafana/grafana-app-sdk/logging"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
)
|
||||
|
||||
// tokenAccessChecker implements AccessChecker using access tokens from context.
|
||||
type tokenAccessChecker struct {
|
||||
inner authlib.AccessChecker
|
||||
}
|
||||
|
||||
// NewTokenAccessChecker creates an AccessChecker that gets identity from access tokens
|
||||
// via AuthInfoFrom(ctx). Role-based fallback is not supported.
|
||||
func NewTokenAccessChecker(inner authlib.AccessChecker) AccessChecker {
|
||||
return &tokenAccessChecker{inner: inner}
|
||||
}
|
||||
|
||||
// WithFallbackRole returns the same checker since fallback is not supported.
|
||||
func (c *tokenAccessChecker) WithFallbackRole(_ identity.RoleType) AccessChecker {
|
||||
return c
|
||||
}
|
||||
|
||||
// Check performs an access check using AuthInfo from context.
|
||||
// Returns nil if access is allowed, or an appropriate API error if denied.
|
||||
func (c *tokenAccessChecker) Check(ctx context.Context, req authlib.CheckRequest, folder string) error {
|
||||
logger := logging.FromContext(ctx).With("logger", "tokenAccessChecker")
|
||||
|
||||
// Get identity from access token in context
|
||||
id, ok := authlib.AuthInfoFrom(ctx)
|
||||
if !ok {
|
||||
logger.Debug("no auth info in context",
|
||||
"resource", req.Resource,
|
||||
"verb", req.Verb,
|
||||
"namespace", req.Namespace,
|
||||
)
|
||||
return apierrors.NewUnauthorized("no auth info in context")
|
||||
}
|
||||
|
||||
logger.Debug("checking access",
|
||||
"identityType", id.GetIdentityType(),
|
||||
"namespace", id.GetNamespace(),
|
||||
"resource", req.Resource,
|
||||
"verb", req.Verb,
|
||||
"group", req.Group,
|
||||
"name", req.Name,
|
||||
"folder", folder,
|
||||
)
|
||||
|
||||
// Fill in namespace from identity if not provided
|
||||
if req.Namespace == "" {
|
||||
req.Namespace = id.GetNamespace()
|
||||
}
|
||||
|
||||
// Perform the access check
|
||||
rsp, err := c.inner.Check(ctx, id, req, folder)
|
||||
|
||||
// Build the GroupResource for error messages
|
||||
gr := schema.GroupResource{Group: req.Group, Resource: req.Resource}
|
||||
|
||||
if err != nil {
|
||||
logger.Debug("access check error",
|
||||
"resource", req.Resource,
|
||||
"verb", req.Verb,
|
||||
"error", err.Error(),
|
||||
)
|
||||
return apierrors.NewForbidden(gr, req.Name, fmt.Errorf("%s.%s is forbidden: %w", req.Resource, req.Group, err))
|
||||
}
|
||||
if !rsp.Allowed {
|
||||
logger.Debug("access check denied",
|
||||
"resource", req.Resource,
|
||||
"verb", req.Verb,
|
||||
"group", req.Group,
|
||||
"identityType", id.GetIdentityType(),
|
||||
"allowed", rsp.Allowed,
|
||||
)
|
||||
return apierrors.NewForbidden(gr, req.Name, fmt.Errorf("permission denied"))
|
||||
}
|
||||
|
||||
logger.Debug("access allowed",
|
||||
"resource", req.Resource,
|
||||
"verb", req.Verb,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
|
||||
authlib "github.com/grafana/authlib/types"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTokenAccessChecker_Check(t *testing.T) {
|
||||
req := authlib.CheckRequest{
|
||||
Verb: "get",
|
||||
Group: "provisioning.grafana.app",
|
||||
Resource: "repositories",
|
||||
Name: "test-repo",
|
||||
Namespace: "default",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
innerResponse authlib.CheckResponse
|
||||
innerErr error
|
||||
authInfo *identity.StaticRequester
|
||||
expectAllow bool
|
||||
}{
|
||||
{
|
||||
name: "allowed by checker",
|
||||
innerResponse: authlib.CheckResponse{Allowed: true},
|
||||
authInfo: &identity.StaticRequester{Type: authlib.TypeUser},
|
||||
expectAllow: true,
|
||||
},
|
||||
{
|
||||
name: "denied by checker",
|
||||
innerResponse: authlib.CheckResponse{Allowed: false},
|
||||
authInfo: &identity.StaticRequester{Type: authlib.TypeUser},
|
||||
expectAllow: false,
|
||||
},
|
||||
{
|
||||
name: "error from checker",
|
||||
innerErr: errors.New("access check failed"),
|
||||
authInfo: &identity.StaticRequester{Type: authlib.TypeUser},
|
||||
expectAllow: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mock := &mockInnerAccessChecker{
|
||||
response: tt.innerResponse,
|
||||
err: tt.innerErr,
|
||||
}
|
||||
|
||||
checker := NewTokenAccessChecker(mock)
|
||||
|
||||
// Add auth info to context
|
||||
testCtx := authlib.WithAuthInfo(context.Background(), tt.authInfo)
|
||||
|
||||
err := checker.Check(testCtx, req, "")
|
||||
|
||||
if tt.expectAllow {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
require.Error(t, err)
|
||||
assert.True(t, apierrors.IsForbidden(err), "expected Forbidden error, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenAccessChecker_NoAuthInfo(t *testing.T) {
|
||||
mock := &mockInnerAccessChecker{
|
||||
response: authlib.CheckResponse{Allowed: true},
|
||||
}
|
||||
|
||||
checker := NewTokenAccessChecker(mock)
|
||||
err := checker.Check(context.Background(), authlib.CheckRequest{}, "")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.True(t, apierrors.IsUnauthorized(err), "expected Unauthorized error")
|
||||
}
|
||||
|
||||
func TestTokenAccessChecker_WithFallbackRole_IsNoOp(t *testing.T) {
|
||||
mock := &mockInnerAccessChecker{
|
||||
response: authlib.CheckResponse{Allowed: false},
|
||||
}
|
||||
|
||||
checker := NewTokenAccessChecker(mock)
|
||||
checkerWithFallback := checker.WithFallbackRole(identity.RoleAdmin)
|
||||
|
||||
// They should be the same instance
|
||||
assert.Same(t, checker, checkerWithFallback, "WithFallbackRole should return same instance")
|
||||
}
|
||||
|
||||
func TestTokenAccessChecker_FillsNamespace(t *testing.T) {
|
||||
mock := &mockInnerAccessChecker{
|
||||
response: authlib.CheckResponse{Allowed: true},
|
||||
}
|
||||
|
||||
checker := NewTokenAccessChecker(mock)
|
||||
|
||||
ctx := authlib.WithAuthInfo(context.Background(), &identity.StaticRequester{
|
||||
Type: authlib.TypeUser,
|
||||
Namespace: "org-123",
|
||||
})
|
||||
|
||||
// Request without namespace
|
||||
req := authlib.CheckRequest{
|
||||
Verb: "get",
|
||||
Group: "provisioning.grafana.app",
|
||||
Resource: "repositories",
|
||||
Name: "test-repo",
|
||||
// Namespace intentionally empty
|
||||
}
|
||||
|
||||
err := checker.Check(ctx, req, "")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// mockInnerAccessChecker implements authlib.AccessChecker for testing.
|
||||
type mockInnerAccessChecker struct {
|
||||
response authlib.CheckResponse
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockInnerAccessChecker) Check(_ context.Context, _ authlib.AuthInfo, _ authlib.CheckRequest, _ string) (authlib.CheckResponse, error) {
|
||||
return m.response, m.err
|
||||
}
|
||||
|
||||
func (m *mockInnerAccessChecker) Compile(_ context.Context, _ authlib.AuthInfo, _ authlib.ListRequest) (authlib.ItemChecker, authlib.Zookie, error) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
@@ -71,12 +71,13 @@
|
||||
"id": 1,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidthFactor": 0.4,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -147,12 +148,13 @@
|
||||
"id": 4,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidthFactor": 0.4,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -223,12 +225,13 @@
|
||||
"id": 3,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidthFactor": 0.4,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -296,15 +299,93 @@
|
||||
"x": 12,
|
||||
"y": 1
|
||||
},
|
||||
"id": 8,
|
||||
"id": 5,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidthFactor": 0.4,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"segmentCount": 1,
|
||||
"segmentSpacing": 0.3,
|
||||
"shape": "circle",
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": false,
|
||||
"sparkline": false
|
||||
},
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"targets": [
|
||||
{
|
||||
"alias": "1",
|
||||
"datasource": {
|
||||
"type": "grafana-testdata-datasource"
|
||||
},
|
||||
"max": 100,
|
||||
"min": 1,
|
||||
"noise": 22,
|
||||
"refId": "A",
|
||||
"scenarioId": "random_walk",
|
||||
"spread": 22,
|
||||
"startValue": 1
|
||||
}
|
||||
],
|
||||
"title": "Spotlight",
|
||||
"type": "radialbar"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "grafana-testdata-datasource"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "thresholds"
|
||||
},
|
||||
"mappings": [],
|
||||
"max": 100,
|
||||
"min": 0,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": 0
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 6,
|
||||
"w": 4,
|
||||
"x": 16,
|
||||
"y": 1
|
||||
},
|
||||
"id": 8,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barWidthFactor": 0.4,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -369,18 +450,19 @@
|
||||
"gridPos": {
|
||||
"h": 6,
|
||||
"w": 4,
|
||||
"x": 16,
|
||||
"y": 1
|
||||
"x": 0,
|
||||
"y": 7
|
||||
},
|
||||
"id": 22,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "flat",
|
||||
"barWidthFactor": 0.72,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": false,
|
||||
"spotlight": true
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -445,18 +527,19 @@
|
||||
"gridPos": {
|
||||
"h": 6,
|
||||
"w": 4,
|
||||
"x": 20,
|
||||
"y": 1
|
||||
"x": 4,
|
||||
"y": 7
|
||||
},
|
||||
"id": 23,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "flat",
|
||||
"barWidthFactor": 0.72,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": false,
|
||||
"spotlight": true
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -496,7 +579,7 @@
|
||||
"h": 1,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 7
|
||||
"y": 13
|
||||
},
|
||||
"id": 17,
|
||||
"panels": [],
|
||||
@@ -533,19 +616,20 @@
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 6,
|
||||
"w": 4,
|
||||
"w": 5,
|
||||
"x": 0,
|
||||
"y": 8
|
||||
"y": 14
|
||||
},
|
||||
"id": 18,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidthFactor": 0.1,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -609,19 +693,20 @@
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 6,
|
||||
"w": 4,
|
||||
"x": 4,
|
||||
"y": 8
|
||||
"w": 5,
|
||||
"x": 5,
|
||||
"y": 14
|
||||
},
|
||||
"id": 19,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidthFactor": 0.32,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -685,19 +770,20 @@
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 6,
|
||||
"w": 4,
|
||||
"x": 8,
|
||||
"y": 8
|
||||
"w": 5,
|
||||
"x": 10,
|
||||
"y": 14
|
||||
},
|
||||
"id": 20,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidthFactor": 0.57,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -761,19 +847,20 @@
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 6,
|
||||
"w": 4,
|
||||
"x": 12,
|
||||
"y": 8
|
||||
"w": 5,
|
||||
"x": 15,
|
||||
"y": 14
|
||||
},
|
||||
"id": 21,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidthFactor": 0.8,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -813,7 +900,7 @@
|
||||
"h": 1,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 14
|
||||
"y": 20
|
||||
},
|
||||
"id": 24,
|
||||
"panels": [],
|
||||
@@ -854,19 +941,20 @@
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 6,
|
||||
"w": 4,
|
||||
"w": 6,
|
||||
"x": 0,
|
||||
"y": 15
|
||||
"y": 21
|
||||
},
|
||||
"id": 25,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "flat",
|
||||
"barWidthFactor": 0.9,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -930,19 +1018,20 @@
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 6,
|
||||
"w": 4,
|
||||
"x": 4,
|
||||
"y": 15
|
||||
"w": 6,
|
||||
"x": 6,
|
||||
"y": 21
|
||||
},
|
||||
"id": 26,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "flat",
|
||||
"barWidthFactor": 0.72,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1006,19 +1095,20 @@
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 6,
|
||||
"w": 4,
|
||||
"x": 8,
|
||||
"y": 15
|
||||
"w": 5,
|
||||
"x": 12,
|
||||
"y": 21
|
||||
},
|
||||
"id": 29,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "flat",
|
||||
"barWidthFactor": 0.72,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1081,20 +1171,21 @@
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 6,
|
||||
"w": 4,
|
||||
"x": 12,
|
||||
"y": 15
|
||||
"h": 7,
|
||||
"w": 6,
|
||||
"x": 0,
|
||||
"y": 27
|
||||
},
|
||||
"id": 30,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "flat",
|
||||
"barWidthFactor": 0.9,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1157,20 +1248,21 @@
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 6,
|
||||
"w": 4,
|
||||
"x": 16,
|
||||
"y": 15
|
||||
"h": 7,
|
||||
"w": 6,
|
||||
"x": 6,
|
||||
"y": 27
|
||||
},
|
||||
"id": 28,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "flat",
|
||||
"barWidthFactor": 0.72,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": false
|
||||
"gradient": false,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1206,7 +1298,7 @@
|
||||
"h": 1,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 21
|
||||
"y": 34
|
||||
},
|
||||
"id": 31,
|
||||
"panels": [],
|
||||
@@ -1253,17 +1345,18 @@
|
||||
"h": 10,
|
||||
"w": 7,
|
||||
"x": 0,
|
||||
"y": 22
|
||||
"y": 35
|
||||
},
|
||||
"id": 32,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "flat",
|
||||
"barWidthFactor": 0.9,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1333,17 +1426,18 @@
|
||||
"h": 10,
|
||||
"w": 7,
|
||||
"x": 7,
|
||||
"y": 22
|
||||
"y": 35
|
||||
},
|
||||
"id": 34,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "flat",
|
||||
"barWidthFactor": 0.9,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1413,17 +1507,18 @@
|
||||
"h": 10,
|
||||
"w": 6,
|
||||
"x": 14,
|
||||
"y": 22
|
||||
"y": 35
|
||||
},
|
||||
"id": 33,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "flat",
|
||||
"barWidthFactor": 0.9,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1459,7 +1554,7 @@
|
||||
"h": 1,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 32
|
||||
"y": 45
|
||||
},
|
||||
"id": 6,
|
||||
"panels": [],
|
||||
@@ -1500,20 +1595,20 @@
|
||||
"h": 6,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 33
|
||||
"y": 46
|
||||
},
|
||||
"id": 9,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidth": 12,
|
||||
"barWidthFactor": 0.4,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"endpointMarker": "glow",
|
||||
"glow": "both",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -1526,7 +1621,8 @@
|
||||
"shape": "circle",
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": false,
|
||||
"sparkline": false
|
||||
"sparkline": false,
|
||||
"spotlight": true
|
||||
},
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"targets": [
|
||||
@@ -1581,18 +1677,19 @@
|
||||
"h": 6,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 39
|
||||
"y": 52
|
||||
},
|
||||
"id": 11,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidth": 12,
|
||||
"barWidthFactor": 0.4,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"glow": "both",
|
||||
"orientation": "auto",
|
||||
@@ -1606,7 +1703,8 @@
|
||||
"shape": "gauge",
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": false,
|
||||
"sparkline": true
|
||||
"sparkline": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"targets": [
|
||||
@@ -1633,7 +1731,7 @@
|
||||
"h": 1,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 45
|
||||
"y": 58
|
||||
},
|
||||
"id": 12,
|
||||
"panels": [],
|
||||
@@ -1675,18 +1773,19 @@
|
||||
"h": 7,
|
||||
"w": 4,
|
||||
"x": 0,
|
||||
"y": 46
|
||||
"y": 59
|
||||
},
|
||||
"id": 13,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidth": 12,
|
||||
"barWidthFactor": 0.49,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"glow": "both",
|
||||
"orientation": "auto",
|
||||
@@ -1700,7 +1799,8 @@
|
||||
"shape": "circle",
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": false,
|
||||
"sparkline": false
|
||||
"sparkline": false,
|
||||
"spotlight": true
|
||||
},
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"targets": [
|
||||
@@ -1756,18 +1856,19 @@
|
||||
"h": 7,
|
||||
"w": 5,
|
||||
"x": 4,
|
||||
"y": 46
|
||||
"y": 59
|
||||
},
|
||||
"id": 14,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidth": 12,
|
||||
"barWidthFactor": 0.49,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"glow": "both",
|
||||
"orientation": "auto",
|
||||
@@ -1781,7 +1882,8 @@
|
||||
"shape": "circle",
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": false,
|
||||
"sparkline": false
|
||||
"sparkline": false,
|
||||
"spotlight": true
|
||||
},
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"targets": [
|
||||
@@ -1836,18 +1938,19 @@
|
||||
"h": 7,
|
||||
"w": 5,
|
||||
"x": 9,
|
||||
"y": 46
|
||||
"y": 59
|
||||
},
|
||||
"id": 15,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidth": 12,
|
||||
"barWidthFactor": 0.84,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"glow": "both",
|
||||
"orientation": "auto",
|
||||
@@ -1861,7 +1964,8 @@
|
||||
"shape": "circle",
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": false,
|
||||
"sparkline": false
|
||||
"sparkline": false,
|
||||
"spotlight": true
|
||||
},
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"targets": [
|
||||
@@ -1916,18 +2020,19 @@
|
||||
"h": 7,
|
||||
"w": 6,
|
||||
"x": 14,
|
||||
"y": 46
|
||||
"y": 59
|
||||
},
|
||||
"id": 16,
|
||||
"maxDataPoints": 20,
|
||||
"options": {
|
||||
"barShape": "rounded",
|
||||
"barWidth": 12,
|
||||
"barWidthFactor": 0.66,
|
||||
"effects": {
|
||||
"barGlow": true,
|
||||
"centerGlow": true,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": true,
|
||||
"spotlight": true
|
||||
},
|
||||
"glow": "both",
|
||||
"orientation": "auto",
|
||||
@@ -1941,7 +2046,8 @@
|
||||
"shape": "circle",
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": false,
|
||||
"sparkline": false
|
||||
"sparkline": false,
|
||||
"spotlight": true
|
||||
},
|
||||
"pluginVersion": "13.0.0-pre",
|
||||
"targets": [
|
||||
@@ -1968,7 +2074,7 @@
|
||||
"h": 1,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 53
|
||||
"y": 66
|
||||
},
|
||||
"id": 35,
|
||||
"panels": [],
|
||||
@@ -1999,19 +2105,20 @@
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 5,
|
||||
"w": 12,
|
||||
"h": 8,
|
||||
"w": 6,
|
||||
"x": 0,
|
||||
"y": 54
|
||||
"y": 67
|
||||
},
|
||||
"id": 36,
|
||||
"options": {
|
||||
"barShape": "flat",
|
||||
"barWidthFactor": 0.5,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -2064,19 +2171,20 @@
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 5,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 54
|
||||
"h": 8,
|
||||
"w": 6,
|
||||
"x": 6,
|
||||
"y": 67
|
||||
},
|
||||
"id": 37,
|
||||
"options": {
|
||||
"barShape": "flat",
|
||||
"barWidthFactor": 0.5,
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"gradient": true
|
||||
"gradient": true,
|
||||
"rounded": false,
|
||||
"spotlight": false
|
||||
},
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
@@ -2116,6 +2224,5 @@
|
||||
"timezone": "browser",
|
||||
"title": "Panel tests - Gauge (new)",
|
||||
"uid": "panel-tests-gauge-new",
|
||||
"version": 22,
|
||||
"weekStart": ""
|
||||
"version": 9
|
||||
}
|
||||
|
||||
@@ -956,6 +956,8 @@
|
||||
"effects": {
|
||||
"barGlow": false,
|
||||
"centerGlow": false,
|
||||
"rounded": false,
|
||||
"spotlight": false,
|
||||
"gradient": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,48 +128,35 @@ The set up process verifies the path and provides an error message if a problem
|
||||
|
||||
#### Synchronization limitations
|
||||
|
||||
{{< admonition type="caution" >}}
|
||||
Full instance sync is not available in Grafana Cloud.
|
||||
|
||||
Full instance sync is not available in Grafana Cloud and is experimental and unsupported in Grafana OSS/Enterprise.
|
||||
|
||||
{{< /admonition >}}
|
||||
|
||||
To have access to full instance sync you must explicitly enable the option.
|
||||
|
||||
The following applies:
|
||||
In Grafana OSS/Enterprise:
|
||||
|
||||
- If you try to perform a full instance sync with resources that contain alerts or panels, the connection will be blocked.
|
||||
- You won't be able to create new alerts or library panels after setup is completed.
|
||||
- If you opted for full instance sync and want to use alerts and library panels, you'll have to delete the provisioned repository and connect again with folder sync.
|
||||
|
||||
#### Set up synchronization
|
||||
|
||||
You can sync external resources into a new folder without affecting the rest of your instance.
|
||||
Choose to either sync your entire organization resources with external storage, or to sync certain resources to a new Grafana folder (with up to 10 connections).
|
||||
|
||||
To set up synchronization:
|
||||
- Choose **Sync all resources with external storage** if you want to sync and manage your entire Grafana instance through external storage. With this option, all of your dashboards are synced to that one repository. You can only have one provisioned connection with this selection, and you won't have the option of setting up additional repositories to connect to.
|
||||
|
||||
1. Select which resources you want to sync.
|
||||
- Choose **Sync external storage to new Grafana folder** to sync external resources into a new folder without affecting the rest of your instance. You can repeat this process for up to 10 connections.
|
||||
|
||||
1. Enter a **Display name** for the repository connection. Resources stored in this connection appear under the chosen display name in the Grafana UI.
|
||||
Next, enter a **Display name** for the repository connection. Resources stored in this connection appear under the chosen display name in the Grafana UI.
|
||||
|
||||
1. Click **Synchronize** to continue.
|
||||
|
||||
1. You can repeat this process for up to 10 connections.
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
|
||||
Optionally, you can export any unmanaged resources into the provisioned folder. See how in [Synchronize with external storage](#synchronize-with-external-storage).
|
||||
|
||||
{{< /admonition >}}
|
||||
Click **Synchronize** to continue.
|
||||
|
||||
### Synchronize with external storage
|
||||
|
||||
In this step you proceed to synchronize the resources selected in the previous step. Optionally, you can check the **Migrate existing resources** box to migrate your unmanaged dashboards to the provisioned folder.
|
||||
After this one time step, all future updates are automatically saved to the local file path and provisioned back to the instance.
|
||||
|
||||
Select **Begin synchronization** to start the process. After this one time step, all future updates are automatically saved to the local file path and provisioned back to the instance.
|
||||
|
||||
Note that during the initial synchronization, your dashboards will be temporarily unavailable. No data or configurations will be lost.
|
||||
During the initial synchronization, your dashboards will be temporarily unavailable. No data or configurations will be lost.
|
||||
How long the process takes depends upon the number of resources involved.
|
||||
|
||||
Select **Begin synchronization** to start the process.
|
||||
|
||||
### Choose additional settings
|
||||
|
||||
If you wish, you can make any files synchronized as as **Read only** so no changes can be made to the resources through Grafana.
|
||||
|
||||
@@ -132,35 +132,17 @@ To connect your GitHub repository:
|
||||
|
||||
### Choose what to synchronize
|
||||
|
||||
You can sync external resources into a new folder without affecting the rest of your instance.
|
||||
In this step, you can decide which elements to synchronize. The available options depend on the status of your Grafana instance:
|
||||
|
||||
- If the instance contains resources in an incompatible data format, you'll have to migrate all the data using instance sync. Folder sync won't be supported.
|
||||
- If there's already another connection using folder sync, instance sync won't be offered.
|
||||
|
||||
To set up synchronization:
|
||||
|
||||
1. Select which resources you want to sync.
|
||||
- Choose **Sync all resources with external storage** if you want to sync and manage your entire Grafana instance through external storage. With this option, all of your dashboards are synced to that one repository. You can only have one provisioned connection with this selection, and you won't have the option of setting up additional repositories to connect to.
|
||||
- Choose **Sync external storage to new Grafana folder** to sync external resources into a new folder without affecting the rest of your instance. You can repeat this process for up to 10 connections.
|
||||
|
||||
1. Enter a **Display name** for the repository connection. Resources stored in this connection appear under the chosen display name in the Grafana UI.
|
||||
|
||||
1. Click **Synchronize** to continue.
|
||||
|
||||
1. You can repeat this process for up to 10 connections.
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
|
||||
Optionally, you can export any unmanaged resources into the provisioned folder. See how in [Synchronize with external storage](#synchronize-with-external-storage).
|
||||
|
||||
{{< /admonition >}}
|
||||
|
||||
#### Full instance sync
|
||||
|
||||
Full instance sync is not available in Grafana Cloud and is experimental and unsupported in Grafana OSS/Enterprise.
|
||||
|
||||
To have access to this option you must enable experimental instance sync on purpose.
|
||||
|
||||
### Synchronize with external storage
|
||||
|
||||
After this one time step, all future updates are automatically saved to the Git repository and provisioned back to the instance.
|
||||
|
||||
Check the **Migrate existing resources** box to migrate your unmanaged dashboards to the provisioned folder.
|
||||
Next, enter a **Display name** for the repository connection. Resources stored in this connection appear under the chosen display name in the Grafana UI. Click **Synchronize** to continue.
|
||||
|
||||
### Choose additional settings
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ Using Git Sync, you can:
|
||||
|
||||
{{< admonition type="caution" >}}
|
||||
|
||||
Full instance sync is not available in Grafana Cloud and is experimental and unsupported in Grafana OSS/Enterprise.
|
||||
Git Sync only works with specific folders for the moment. Full-instance sync is not currently supported.
|
||||
|
||||
{{< /admonition >}}
|
||||
|
||||
@@ -84,7 +84,7 @@ Refer to [Requirements](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/obser
|
||||
|
||||
- You can only sync dashboards and folders. Refer to [Supported resources](#supported-resources) for more information.
|
||||
- If you're using Git Sync in Grafana OSS and Grafana Enterprise, some resources might be in an incompatible data format and won't be synced.
|
||||
- Full-instance sync is not available in Grafana Cloud and is experimental in Grafana OSS and Grafana Enterprise. Refer to [Choose what to synchronize](../git-sync-setup/#choose-what-to-synchronize) for more details.
|
||||
- Full-instance sync is not available in Grafana Cloud and has limitations in Grafana OSS and Grafana Enterprise. Refer to [Choose what to synchronize](../git-sync-setup/#choose-what-to-synchronize) for more details.
|
||||
- When migrating to full instance sync, during the synchronization process your resources will be temporarily unavailable. No one will be able to create, edit, or delete resources during this process.
|
||||
- If you want to manage existing resources with Git Sync, you need to save them as JSON files and commit them to the synced repository. Open a PR to import, copy, move, or save a dashboard.
|
||||
- Restoring resources from the UI is currently not possible. As an alternative, you can restore dashboards directly in your GitHub repository by raising a PR, and they will be updated in Grafana.
|
||||
|
||||
@@ -112,12 +112,6 @@ For example, this video demonstrates the visual Prometheus query builder:
|
||||
|
||||
For general information about querying in Grafana, and common options and user interface elements across all query editors, refer to [Query and transform data](ref:query-transform-data).
|
||||
|
||||
## Build a dashboard from the data source
|
||||
|
||||
After you've configured a data source, you can start creating a dashboard directly from it, by clicking the **Build a dashboard** button.
|
||||
|
||||
For more information, refer to [Begin dashboard creation from data source configuration](https://grafana.com/docs/grafana/<GRAFANA_VERSION>/visualizations/dashboards/build-dashboards/create-dashboard/#begin-dashboard-creation-from-connections).
|
||||
|
||||
## Special data sources
|
||||
|
||||
Grafana includes three special data sources:
|
||||
|
||||
@@ -99,7 +99,7 @@ Dashboards and panels allow you to show your data in visual form. Each panel nee
|
||||
- Understand the query language of the target data source.
|
||||
- Ensure that data source for which you are writing a query has been added. For more information about adding a data source, refer to [Add a data source](ref:add-a-data-source) if you need instructions.
|
||||
|
||||
To create a dashboard, follow these steps:
|
||||
**To create a dashboard**:
|
||||
|
||||
{{< shared id="create-dashboard" >}}
|
||||
|
||||
@@ -171,28 +171,6 @@ To create a dashboard, follow these steps:
|
||||
|
||||
Now, when you want to make more changes to the saved dashboard, click **Edit** in the top-right corner.
|
||||
|
||||
### Begin dashboard creation from data source configuration
|
||||
|
||||
You can start the process of creating a dashboard directly from a data source rather than from the **Dashboards** page.
|
||||
|
||||
To begin building a dashboard directly from a data source, follow these steps:
|
||||
|
||||
1. Navigate to **Connections > Data sources**.
|
||||
1. On the row of the data source for which you want to build a dashboard, click **Build a dashboard**.
|
||||
|
||||
The empty dashboard page opens.
|
||||
|
||||
1. Do one of the following:
|
||||
- Click **+Add visualization** to configure all the elements of the new dashboard.
|
||||
- Select one of the suggested dashboards by clicking its **Use dashboard** button. This can be helpful when you're not sure how to most effectively visualize your data.
|
||||
The suggested dashboards are specific to your data source type (for example, Prometheus, Loki, or Elasticsearch). If there are more than three dashboard suggestions, you can click **View all** to see the rest of them.
|
||||
|
||||

|
||||
|
||||
{{< docs/public-preview product="Suggested dashboards" >}}
|
||||
|
||||
1. Complete the rest of the dashboard configuration. For more detailed steps, refer to [Create a dashboard](#create-a-dashboard), beginning at step five.
|
||||
|
||||
## Copy a dashboard
|
||||
|
||||
To copy a dashboard, follow these steps:
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
import { test, expect } from '@grafana/plugin-e2e';
|
||||
|
||||
/**
|
||||
* UI selectors for Saved Searches e2e tests.
|
||||
* Each selector is a function that takes the page and returns a locator.
|
||||
*/
|
||||
const ui = {
|
||||
// Main elements
|
||||
savedSearchesButton: (page: Page) => page.getByRole('button', { name: /saved searches/i }),
|
||||
dropdown: (page: Page) => page.getByRole('dialog', { name: /saved searches/i }),
|
||||
searchInput: (page: Page) => page.getByTestId('search-query-input'),
|
||||
|
||||
// Save functionality
|
||||
saveButton: (page: Page) => page.getByRole('button', { name: /save current search/i }),
|
||||
saveConfirmButton: (page: Page) => page.getByRole('button', { name: /^save$/i }),
|
||||
saveNameInput: (page: Page) => page.getByPlaceholder(/enter a name/i),
|
||||
|
||||
// Action menu
|
||||
actionsButton: (page: Page) => page.getByRole('button', { name: /actions/i }),
|
||||
renameMenuItem: (page: Page) => page.getByText(/rename/i),
|
||||
deleteMenuItem: (page: Page) => page.getByText(/^delete$/i),
|
||||
setAsDefaultMenuItem: (page: Page) => page.getByText(/set as default/i),
|
||||
deleteConfirmButton: (page: Page) => page.getByRole('button', { name: /^delete$/i }),
|
||||
|
||||
// Indicators
|
||||
emptyState: (page: Page) => page.getByText(/no saved searches/i),
|
||||
defaultIcon: (page: Page) => page.locator('[title="Default search"]'),
|
||||
duplicateError: (page: Page) => page.getByText(/already exists/i),
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to clear saved searches storage.
|
||||
* UserStorage uses localStorage as fallback, so we clear both potential keys.
|
||||
*/
|
||||
async function clearSavedSearches(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
// Clear localStorage keys that might contain saved searches
|
||||
// UserStorage stores under 'grafana.userstorage.alerting' pattern
|
||||
const keysToRemove = Object.keys(localStorage).filter(
|
||||
(key) => key.includes('alerting') && (key.includes('savedSearches') || key.includes('userstorage'))
|
||||
);
|
||||
keysToRemove.forEach((key) => localStorage.removeItem(key));
|
||||
|
||||
// Also clear session storage visited flag
|
||||
const sessionKeysToRemove = Object.keys(sessionStorage).filter((key) => key.includes('alerting'));
|
||||
sessionKeysToRemove.forEach((key) => sessionStorage.removeItem(key));
|
||||
});
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Alert Rules - Saved Searches',
|
||||
{
|
||||
tag: ['@alerting'],
|
||||
},
|
||||
() => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Clear any saved searches from previous tests before navigating
|
||||
await page.goto('/alerting/list');
|
||||
await clearSavedSearches(page);
|
||||
await page.reload();
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
// Clean up saved searches after each test
|
||||
await clearSavedSearches(page);
|
||||
});
|
||||
|
||||
test('should display Saved searches button', async ({ page }) => {
|
||||
await expect(ui.savedSearchesButton(page)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should open dropdown when clicking Saved searches button', async ({ page }) => {
|
||||
await ui.savedSearchesButton(page).click();
|
||||
|
||||
await expect(ui.dropdown(page)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show empty state when no saved searches exist', async ({ page }) => {
|
||||
// Storage is cleared in beforeEach, so we should see empty state
|
||||
await ui.savedSearchesButton(page).click();
|
||||
|
||||
await expect(ui.emptyState(page)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should enable Save current search button when search query is entered', async ({ page }) => {
|
||||
// Enter a search query
|
||||
await ui.searchInput(page).fill('state:firing');
|
||||
await ui.searchInput(page).press('Enter');
|
||||
|
||||
// Open saved searches
|
||||
await ui.savedSearchesButton(page).click();
|
||||
|
||||
await expect(ui.saveButton(page)).toBeEnabled();
|
||||
});
|
||||
|
||||
test('should disable Save current search button when search query is empty', async ({ page }) => {
|
||||
await ui.savedSearchesButton(page).click();
|
||||
|
||||
await expect(ui.saveButton(page)).toBeDisabled();
|
||||
});
|
||||
|
||||
test('should save a new search', async ({ page }) => {
|
||||
// Enter a search query
|
||||
await ui.searchInput(page).fill('state:firing');
|
||||
await ui.searchInput(page).press('Enter');
|
||||
|
||||
// Open saved searches
|
||||
await ui.savedSearchesButton(page).click();
|
||||
|
||||
// Click save button
|
||||
await ui.saveButton(page).click();
|
||||
|
||||
// Enter name and save
|
||||
await ui.saveNameInput(page).fill('My Firing Rules');
|
||||
await ui.saveConfirmButton(page).click();
|
||||
|
||||
// Verify the saved search appears in the list
|
||||
await expect(page.getByText('My Firing Rules')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show validation error for duplicate name', async ({ page }) => {
|
||||
// First save a search
|
||||
await ui.searchInput(page).fill('state:firing');
|
||||
await ui.searchInput(page).press('Enter');
|
||||
|
||||
await ui.savedSearchesButton(page).click();
|
||||
|
||||
await ui.saveButton(page).click();
|
||||
|
||||
await ui.saveNameInput(page).fill('Duplicate Test');
|
||||
await ui.saveConfirmButton(page).click();
|
||||
|
||||
// Try to save another with the same name
|
||||
await ui.saveButton(page).click();
|
||||
await ui.saveNameInput(page).fill('Duplicate Test');
|
||||
await ui.saveConfirmButton(page).click();
|
||||
|
||||
// Verify validation error
|
||||
await expect(ui.duplicateError(page)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should apply a saved search', async ({ page }) => {
|
||||
// Create a saved search first
|
||||
await ui.searchInput(page).fill('state:firing');
|
||||
await ui.searchInput(page).press('Enter');
|
||||
|
||||
await ui.savedSearchesButton(page).click();
|
||||
|
||||
await ui.saveButton(page).click();
|
||||
|
||||
await ui.saveNameInput(page).fill('Apply Test');
|
||||
await ui.saveConfirmButton(page).click();
|
||||
|
||||
// Clear the search
|
||||
await ui.searchInput(page).clear();
|
||||
await ui.searchInput(page).press('Enter');
|
||||
|
||||
// Apply the saved search
|
||||
await ui.savedSearchesButton(page).click();
|
||||
await page.getByRole('button', { name: /apply search.*apply test/i }).click();
|
||||
|
||||
// Verify the search input is updated
|
||||
await expect(ui.searchInput(page)).toHaveValue('state:firing');
|
||||
});
|
||||
|
||||
test('should rename a saved search', async ({ page }) => {
|
||||
// Create a saved search
|
||||
await ui.searchInput(page).fill('state:firing');
|
||||
await ui.searchInput(page).press('Enter');
|
||||
|
||||
await ui.savedSearchesButton(page).click();
|
||||
|
||||
await ui.saveButton(page).click();
|
||||
|
||||
await ui.saveNameInput(page).fill('Original Name');
|
||||
await ui.saveConfirmButton(page).click();
|
||||
|
||||
// Open action menu and click rename
|
||||
await ui.actionsButton(page).click();
|
||||
await ui.renameMenuItem(page).click();
|
||||
|
||||
// Enter new name
|
||||
const renameInput = page.getByDisplayValue('Original Name');
|
||||
await renameInput.clear();
|
||||
await renameInput.fill('Renamed Search');
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
// Verify the name was updated
|
||||
await expect(page.getByText('Renamed Search')).toBeVisible();
|
||||
await expect(page.getByText('Original Name')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('should delete a saved search', async ({ page }) => {
|
||||
// Create a saved search
|
||||
await ui.searchInput(page).fill('state:firing');
|
||||
await ui.searchInput(page).press('Enter');
|
||||
|
||||
await ui.savedSearchesButton(page).click();
|
||||
|
||||
await ui.saveButton(page).click();
|
||||
|
||||
await ui.saveNameInput(page).fill('To Delete');
|
||||
await ui.saveConfirmButton(page).click();
|
||||
|
||||
// Verify it was saved
|
||||
await expect(page.getByText('To Delete')).toBeVisible();
|
||||
|
||||
// Open action menu and click delete
|
||||
await ui.actionsButton(page).click();
|
||||
await ui.deleteMenuItem(page).click();
|
||||
|
||||
// Confirm delete
|
||||
await ui.deleteConfirmButton(page).click();
|
||||
|
||||
// Verify it was deleted
|
||||
await expect(page.getByText('To Delete')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('should set a search as default', async ({ page }) => {
|
||||
// Create a saved search
|
||||
await ui.searchInput(page).fill('state:firing');
|
||||
await ui.searchInput(page).press('Enter');
|
||||
|
||||
await ui.savedSearchesButton(page).click();
|
||||
|
||||
await ui.saveButton(page).click();
|
||||
|
||||
await ui.saveNameInput(page).fill('Default Test');
|
||||
await ui.saveConfirmButton(page).click();
|
||||
|
||||
// Set as default
|
||||
await ui.actionsButton(page).click();
|
||||
await ui.setAsDefaultMenuItem(page).click();
|
||||
|
||||
// Verify the star icon appears (indicating default)
|
||||
await expect(ui.defaultIcon(page)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should close dropdown when pressing Escape', async ({ page }) => {
|
||||
await ui.savedSearchesButton(page).click();
|
||||
|
||||
await expect(ui.dropdown(page)).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await expect(ui.dropdown(page)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('should cancel save mode when pressing Escape', async ({ page }) => {
|
||||
// Enter a search query
|
||||
await ui.searchInput(page).fill('state:firing');
|
||||
await ui.searchInput(page).press('Enter');
|
||||
|
||||
await ui.savedSearchesButton(page).click();
|
||||
|
||||
// Start save mode
|
||||
await ui.saveButton(page).click();
|
||||
|
||||
await expect(ui.saveNameInput(page)).toBeVisible();
|
||||
|
||||
// Press Escape to cancel
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Verify we're back to list mode
|
||||
await expect(ui.saveNameInput(page)).not.toBeVisible();
|
||||
await expect(ui.saveButton(page)).toBeVisible();
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -1911,6 +1911,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/dashboard-scene/settings/JsonModelEditView.tsx": {
|
||||
"react/no-unescaped-entities": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"public/app/features/dashboard-scene/settings/variables/VariableEditableElement.tsx": {
|
||||
"react-hooks/rules-of-hooks": {
|
||||
"count": 4
|
||||
|
||||
@@ -535,10 +535,6 @@ export interface FeatureToggles {
|
||||
*/
|
||||
alertingListViewV2?: boolean;
|
||||
/**
|
||||
* Enables saved searches for alert rules list
|
||||
*/
|
||||
alertingSavedSearches?: boolean;
|
||||
/**
|
||||
* Disables the ability to send alerts to an external Alertmanager datasource.
|
||||
*/
|
||||
alertingDisableSendAlertsExternal?: boolean;
|
||||
|
||||
@@ -16,19 +16,21 @@ export interface GaugePanelEffects {
|
||||
barGlow?: boolean;
|
||||
centerGlow?: boolean;
|
||||
gradient?: boolean;
|
||||
rounded?: boolean;
|
||||
spotlight?: boolean;
|
||||
}
|
||||
|
||||
export const defaultGaugePanelEffects: Partial<GaugePanelEffects> = {
|
||||
barGlow: false,
|
||||
centerGlow: false,
|
||||
gradient: true,
|
||||
rounded: false,
|
||||
spotlight: false,
|
||||
};
|
||||
|
||||
export interface Options extends common.SingleStatBaseOptions {
|
||||
barShape: ('flat' | 'rounded');
|
||||
barWidthFactor: number;
|
||||
effects: GaugePanelEffects;
|
||||
endpointMarker?: ('point' | 'glow' | 'none');
|
||||
segmentCount: number;
|
||||
segmentSpacing: number;
|
||||
shape: ('circle' | 'gauge');
|
||||
@@ -38,10 +40,8 @@ export interface Options extends common.SingleStatBaseOptions {
|
||||
}
|
||||
|
||||
export const defaultOptions: Partial<Options> = {
|
||||
barShape: 'flat',
|
||||
barWidthFactor: 0.5,
|
||||
effects: {},
|
||||
endpointMarker: 'point',
|
||||
segmentCount: 1,
|
||||
segmentSpacing: 0.3,
|
||||
shape: 'gauge',
|
||||
|
||||
@@ -1,149 +1,52 @@
|
||||
import { useId, memo, HTMLAttributes, ReactNode } from 'react';
|
||||
import { GaugeDimensions, toRad } from './utils';
|
||||
|
||||
import { FieldDisplay } from '@grafana/data';
|
||||
|
||||
import { getBarEndcapColors, getGradientCss, getEndpointMarkerColors } from './colors';
|
||||
import { RadialShape, RadialGaugeDimensions, GradientStop } from './types';
|
||||
import { drawRadialArcPath, toRad } from './utils';
|
||||
|
||||
export interface RadialArcPathPropsBase {
|
||||
arcLengthDeg: number;
|
||||
barEndcaps?: boolean;
|
||||
dimensions: RadialGaugeDimensions;
|
||||
fieldDisplay: FieldDisplay;
|
||||
roundedBars?: boolean;
|
||||
shape: RadialShape;
|
||||
endpointMarker?: 'point' | 'glow';
|
||||
export interface RadialArcPathProps {
|
||||
startAngle: number;
|
||||
glowFilter?: string;
|
||||
endpointMarkerGlowFilter?: string;
|
||||
}
|
||||
|
||||
interface RadialArcPathPropsWithColor extends RadialArcPathPropsBase {
|
||||
dimensions: GaugeDimensions;
|
||||
color: string;
|
||||
glowFilter?: string;
|
||||
arcLengthDeg: number;
|
||||
roundedBars?: boolean;
|
||||
}
|
||||
|
||||
interface RadialArcPathPropsWithGradient extends RadialArcPathPropsBase {
|
||||
gradient: GradientStop[];
|
||||
}
|
||||
export function RadialArcPath({
|
||||
startAngle: angle,
|
||||
dimensions,
|
||||
color,
|
||||
glowFilter,
|
||||
arcLengthDeg,
|
||||
roundedBars,
|
||||
}: RadialArcPathProps) {
|
||||
const { radius, centerX, centerY, barWidth } = dimensions;
|
||||
|
||||
type RadialArcPathProps = RadialArcPathPropsWithColor | RadialArcPathPropsWithGradient;
|
||||
|
||||
const ENDPOINT_MARKER_MIN_ANGLE = 10;
|
||||
const DOT_OPACITY = 0.5;
|
||||
const DOT_RADIUS_FACTOR = 0.4;
|
||||
const MAX_DOT_RADIUS = 8;
|
||||
|
||||
export const RadialArcPath = memo(
|
||||
({
|
||||
arcLengthDeg,
|
||||
dimensions,
|
||||
fieldDisplay,
|
||||
roundedBars,
|
||||
shape,
|
||||
endpointMarker,
|
||||
barEndcaps,
|
||||
startAngle: angle,
|
||||
glowFilter,
|
||||
endpointMarkerGlowFilter,
|
||||
...rest
|
||||
}: RadialArcPathProps) => {
|
||||
const id = useId();
|
||||
|
||||
const bgDivStyle: HTMLAttributes<HTMLDivElement>['style'] = { width: '100%', height: '100%' };
|
||||
if ('color' in rest) {
|
||||
bgDivStyle.backgroundColor = rest.color;
|
||||
} else {
|
||||
bgDivStyle.backgroundImage = getGradientCss(rest.gradient, shape);
|
||||
}
|
||||
|
||||
const { radius, centerX, centerY, barWidth } = dimensions;
|
||||
|
||||
const path = drawRadialArcPath(angle, arcLengthDeg, dimensions, roundedBars);
|
||||
|
||||
const startRadians = toRad(angle);
|
||||
const endRadians = toRad(angle + arcLengthDeg);
|
||||
|
||||
const xStart = centerX + radius * Math.cos(startRadians);
|
||||
const yStart = centerY + radius * Math.sin(startRadians);
|
||||
const xEnd = centerX + radius * Math.cos(endRadians);
|
||||
const yEnd = centerY + radius * Math.sin(endRadians);
|
||||
|
||||
const dotRadius =
|
||||
endpointMarker === 'point' ? Math.min((barWidth / 2) * DOT_RADIUS_FACTOR, MAX_DOT_RADIUS) : barWidth / 2;
|
||||
|
||||
let barEndcapColors: [string, string] | undefined;
|
||||
let endpointMarks: ReactNode = null;
|
||||
if ('gradient' in rest) {
|
||||
if (endpointMarker && (rest.gradient?.length ?? 0) > 0) {
|
||||
switch (endpointMarker) {
|
||||
case 'point':
|
||||
const [pointColorStart, pointColorEnd] = getEndpointMarkerColors(
|
||||
rest.gradient!,
|
||||
fieldDisplay.display.percent
|
||||
);
|
||||
endpointMarks = (
|
||||
<>
|
||||
{arcLengthDeg > ENDPOINT_MARKER_MIN_ANGLE && (
|
||||
<circle cx={xStart} cy={yStart} r={dotRadius} fill={pointColorStart} opacity={DOT_OPACITY} />
|
||||
)}
|
||||
<circle cx={xEnd} cy={yEnd} r={dotRadius} fill={pointColorEnd} opacity={DOT_OPACITY} />
|
||||
</>
|
||||
);
|
||||
break;
|
||||
case 'glow':
|
||||
const offsetAngle = toRad(ENDPOINT_MARKER_MIN_ANGLE);
|
||||
const xStartMark = centerX + radius * Math.cos(endRadians + offsetAngle);
|
||||
const yStartMark = centerY + radius * Math.sin(endRadians + offsetAngle);
|
||||
endpointMarks =
|
||||
arcLengthDeg > ENDPOINT_MARKER_MIN_ANGLE ? (
|
||||
<path
|
||||
d={['M', xStartMark, yStartMark, 'A', radius, radius, 0, 0, 1, xEnd, yEnd].join(' ')}
|
||||
fill="none"
|
||||
strokeWidth={barWidth}
|
||||
stroke={endpointMarkerGlowFilter}
|
||||
strokeLinecap={roundedBars ? 'round' : 'butt'}
|
||||
filter={glowFilter}
|
||||
/>
|
||||
) : null;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (barEndcaps) {
|
||||
barEndcapColors = getBarEndcapColors(rest.gradient, fieldDisplay.display.percent);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* FIXME: optimize this by only using clippath + foreign obj for gradients */}
|
||||
<clipPath id={id}>
|
||||
<path d={path} />
|
||||
</clipPath>
|
||||
|
||||
<g filter={glowFilter}>
|
||||
<foreignObject
|
||||
x={centerX - radius - barWidth}
|
||||
y={centerY - radius - barWidth}
|
||||
width={(radius + barWidth) * 2}
|
||||
height={(radius + barWidth) * 2}
|
||||
clipPath={`url(#${id})`}
|
||||
>
|
||||
<div style={bgDivStyle} />
|
||||
</foreignObject>
|
||||
{barEndcapColors?.[0] && <circle cx={xStart} cy={yStart} r={barWidth / 2} fill={barEndcapColors[0]} />}
|
||||
{barEndcapColors?.[1] && (
|
||||
<circle cx={xEnd} cy={yEnd} r={barWidth / 2} fill={barEndcapColors[1]} opacity={0.5} />
|
||||
)}
|
||||
</g>
|
||||
|
||||
{endpointMarks}
|
||||
</>
|
||||
);
|
||||
if (arcLengthDeg === 360) {
|
||||
// For some reason a 100% full arc cannot be rendered
|
||||
arcLengthDeg = 359.99;
|
||||
}
|
||||
);
|
||||
|
||||
RadialArcPath.displayName = 'RadialArcPath';
|
||||
const startRadians = toRad(angle);
|
||||
const endRadians = toRad(angle + arcLengthDeg);
|
||||
|
||||
let x1 = centerX + radius * Math.cos(startRadians);
|
||||
let y1 = centerY + radius * Math.sin(startRadians);
|
||||
let x2 = centerX + radius * Math.cos(endRadians);
|
||||
let y2 = centerY + radius * Math.sin(endRadians);
|
||||
|
||||
const largeArc = arcLengthDeg > 180 ? 1 : 0;
|
||||
|
||||
const path = ['M', x1, y1, 'A', radius, radius, 0, largeArc, 1, x2, y2].join(' ');
|
||||
|
||||
return (
|
||||
<path
|
||||
d={path}
|
||||
fill="none"
|
||||
fillOpacity="1"
|
||||
stroke={color}
|
||||
strokeOpacity="1"
|
||||
strokeWidth={barWidth}
|
||||
filter={glowFilter}
|
||||
strokeLinecap={roundedBars ? 'round' : 'butt'}
|
||||
className="radial-arc-path"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,64 +1,97 @@
|
||||
import { FALLBACK_COLOR, FieldDisplay } from '@grafana/data';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
|
||||
import { useTheme2 } from '../../themes/ThemeContext';
|
||||
|
||||
import { RadialArcPath } from './RadialArcPath';
|
||||
import { RadialShape, RadialGaugeDimensions, GradientStop } from './types';
|
||||
import { RadialColorDefs } from './RadialColorDefs';
|
||||
import { GaugeDimensions, toRad } from './utils';
|
||||
|
||||
export interface RadialBarProps {
|
||||
angle: number;
|
||||
dimensions: GaugeDimensions;
|
||||
colorDefs: RadialColorDefs;
|
||||
angleRange: number;
|
||||
dimensions: RadialGaugeDimensions;
|
||||
fieldDisplay: FieldDisplay;
|
||||
gradient?: GradientStop[];
|
||||
roundedBars?: boolean;
|
||||
endpointMarker?: 'point' | 'glow';
|
||||
shape: RadialShape;
|
||||
angle: number;
|
||||
startAngle: number;
|
||||
roundedBars?: boolean;
|
||||
spotlightStroke: string;
|
||||
glowFilter?: string;
|
||||
endpointMarkerGlowFilter?: string;
|
||||
}
|
||||
export function RadialBar({
|
||||
angle,
|
||||
angleRange,
|
||||
dimensions,
|
||||
fieldDisplay,
|
||||
gradient,
|
||||
roundedBars,
|
||||
endpointMarker,
|
||||
shape,
|
||||
colorDefs,
|
||||
angleRange,
|
||||
angle,
|
||||
startAngle,
|
||||
roundedBars,
|
||||
spotlightStroke,
|
||||
glowFilter,
|
||||
endpointMarkerGlowFilter,
|
||||
}: RadialBarProps) {
|
||||
const theme = useTheme2();
|
||||
const colorProps = gradient ? { gradient } : { color: fieldDisplay.display.color ?? FALLBACK_COLOR };
|
||||
|
||||
return (
|
||||
<>
|
||||
{/** Track */}
|
||||
<RadialArcPath
|
||||
arcLengthDeg={angleRange - angle}
|
||||
fieldDisplay={fieldDisplay}
|
||||
color={theme.colors.action.hover}
|
||||
dimensions={dimensions}
|
||||
roundedBars={roundedBars}
|
||||
shape={shape}
|
||||
startAngle={startAngle + angle}
|
||||
/>
|
||||
{/** The colored bar */}
|
||||
<RadialArcPath
|
||||
arcLengthDeg={angle}
|
||||
barEndcaps={shape === 'circle' && roundedBars}
|
||||
dimensions={dimensions}
|
||||
endpointMarker={roundedBars ? endpointMarker : undefined}
|
||||
endpointMarkerGlowFilter={endpointMarkerGlowFilter}
|
||||
fieldDisplay={fieldDisplay}
|
||||
glowFilter={glowFilter}
|
||||
roundedBars={roundedBars}
|
||||
shape={shape}
|
||||
startAngle={startAngle}
|
||||
{...colorProps}
|
||||
/>
|
||||
<g>
|
||||
{/** Track */}
|
||||
<RadialArcPath
|
||||
startAngle={startAngle + angle}
|
||||
dimensions={dimensions}
|
||||
arcLengthDeg={angleRange - angle}
|
||||
color={theme.colors.action.hover}
|
||||
roundedBars={roundedBars}
|
||||
/>
|
||||
{/** The colored bar */}
|
||||
<RadialArcPath
|
||||
dimensions={dimensions}
|
||||
startAngle={startAngle}
|
||||
arcLengthDeg={angle}
|
||||
color={colorDefs.getMainBarColor()}
|
||||
roundedBars={roundedBars}
|
||||
glowFilter={glowFilter}
|
||||
/>
|
||||
{spotlightStroke && angle > 8 && (
|
||||
<SpotlightSquareEffect
|
||||
dimensions={dimensions}
|
||||
angle={startAngle + angle}
|
||||
glowFilter={glowFilter}
|
||||
spotlightStroke={spotlightStroke}
|
||||
theme={theme}
|
||||
roundedBars={roundedBars}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
<defs>{colorDefs.getDefs()}</defs>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface SpotlightEffectProps {
|
||||
dimensions: GaugeDimensions;
|
||||
angle: number;
|
||||
glowFilter?: string;
|
||||
spotlightStroke: string;
|
||||
theme: GrafanaTheme2;
|
||||
roundedBars?: boolean;
|
||||
}
|
||||
|
||||
function SpotlightSquareEffect({ dimensions, angle, glowFilter, spotlightStroke, roundedBars }: SpotlightEffectProps) {
|
||||
const { radius, centerX, centerY, barWidth } = dimensions;
|
||||
|
||||
const angleRadian = toRad(angle);
|
||||
const x1 = centerX + radius * Math.cos(angleRadian - 0.2);
|
||||
const y1 = centerY + radius * Math.sin(angleRadian - 0.2);
|
||||
const x2 = centerX + radius * Math.cos(angleRadian);
|
||||
const y2 = centerY + radius * Math.sin(angleRadian);
|
||||
|
||||
const path = ['M', x1, y1, 'A', radius, radius, 0, 0, 1, x2, y2].join(' ');
|
||||
|
||||
return (
|
||||
<path
|
||||
d={path}
|
||||
fill="none"
|
||||
strokeWidth={barWidth}
|
||||
stroke={spotlightStroke}
|
||||
strokeLinecap={roundedBars ? 'round' : 'butt'}
|
||||
filter={glowFilter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,74 +1,126 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
import { FALLBACK_COLOR, FieldDisplay } from '@grafana/data';
|
||||
import { FieldDisplay } from '@grafana/data';
|
||||
|
||||
import { useTheme2 } from '../../themes/ThemeContext';
|
||||
|
||||
import { RadialArcPath } from './RadialArcPath';
|
||||
import { RadialShape, RadialGaugeDimensions, GradientStop } from './types';
|
||||
import {
|
||||
getAngleBetweenSegments,
|
||||
getFieldConfigMinMax,
|
||||
getFieldDisplayProcessor,
|
||||
getOptimalSegmentCount,
|
||||
} from './utils';
|
||||
import { RadialColorDefs } from './RadialColorDefs';
|
||||
import { GaugeDimensions } from './utils';
|
||||
|
||||
export interface RadialBarSegmentedProps {
|
||||
fieldDisplay: FieldDisplay;
|
||||
dimensions: RadialGaugeDimensions;
|
||||
dimensions: GaugeDimensions;
|
||||
colorDefs: RadialColorDefs;
|
||||
angleRange: number;
|
||||
startAngle: number;
|
||||
glowFilter?: string;
|
||||
segmentCount: number;
|
||||
segmentSpacing: number;
|
||||
shape: RadialShape;
|
||||
gradient?: GradientStop[];
|
||||
}
|
||||
export function RadialBarSegmented({
|
||||
fieldDisplay,
|
||||
dimensions,
|
||||
startAngle,
|
||||
angleRange,
|
||||
glowFilter,
|
||||
segmentCount,
|
||||
segmentSpacing,
|
||||
colorDefs,
|
||||
}: RadialBarSegmentedProps) {
|
||||
const segments: React.ReactNode[] = [];
|
||||
const theme = useTheme2();
|
||||
|
||||
const segmentCountAdjusted = getOptimalSegmentCount(dimensions, segmentSpacing, segmentCount, angleRange);
|
||||
const min = fieldDisplay.field.min ?? 0;
|
||||
const max = fieldDisplay.field.max ?? 100;
|
||||
const value = fieldDisplay.display.numeric;
|
||||
const angleBetweenSegments = getAngleBetweenSegments(segmentSpacing, segmentCount, angleRange);
|
||||
const segmentArcLengthDeg = angleRange / segmentCountAdjusted - angleBetweenSegments;
|
||||
|
||||
for (let i = 0; i < segmentCountAdjusted; i++) {
|
||||
const angleValue = min + ((max - min) / segmentCountAdjusted) * i;
|
||||
const angleColor = colorDefs.getSegmentColor(angleValue);
|
||||
const segmentAngle = startAngle + (angleRange / segmentCountAdjusted) * i + 0.01;
|
||||
const segmentColor = angleValue >= value ? theme.colors.action.hover : angleColor;
|
||||
|
||||
segments.push(
|
||||
<RadialArcPath
|
||||
key={i}
|
||||
startAngle={segmentAngle}
|
||||
dimensions={dimensions}
|
||||
color={segmentColor}
|
||||
glowFilter={glowFilter}
|
||||
arcLengthDeg={segmentArcLengthDeg}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<g>{segments}</g>
|
||||
<defs>{colorDefs.getDefs()}</defs>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const RadialBarSegmented = memo(
|
||||
({
|
||||
fieldDisplay,
|
||||
dimensions,
|
||||
startAngle,
|
||||
angleRange,
|
||||
glowFilter,
|
||||
gradient,
|
||||
segmentCount,
|
||||
segmentSpacing,
|
||||
shape,
|
||||
}: RadialBarSegmentedProps) => {
|
||||
const theme = useTheme2();
|
||||
const segments: React.ReactNode[] = [];
|
||||
const segmentCountAdjusted = getOptimalSegmentCount(dimensions, segmentSpacing, segmentCount, angleRange);
|
||||
const [min, max] = getFieldConfigMinMax(fieldDisplay);
|
||||
const value = fieldDisplay.display.numeric;
|
||||
const angleBetweenSegments = getAngleBetweenSegments(segmentSpacing, segmentCount, angleRange);
|
||||
const segmentArcLengthDeg = angleRange / segmentCountAdjusted - angleBetweenSegments;
|
||||
const displayProcessor = getFieldDisplayProcessor(fieldDisplay);
|
||||
export function getAngleBetweenSegments(segmentSpacing: number, segmentCount: number, range: number) {
|
||||
// Max spacing is 8 degrees between segments
|
||||
// Changing this constant could be considered a breaking change
|
||||
const maxAngleBetweenSegments = Math.max(range / 1.5 / segmentCount, 2);
|
||||
return segmentSpacing * maxAngleBetweenSegments;
|
||||
}
|
||||
|
||||
for (let i = 0; i < segmentCountAdjusted; i++) {
|
||||
const angleValue = min + ((max - min) / segmentCountAdjusted) * i;
|
||||
const segmentAngle = startAngle + (angleRange / segmentCountAdjusted) * i + 0.01;
|
||||
const segmentColor =
|
||||
angleValue >= value ? theme.colors.border.medium : (displayProcessor(angleValue).color ?? FALLBACK_COLOR);
|
||||
const colorProps = angleValue < value && gradient ? { gradient } : { color: segmentColor };
|
||||
function getOptimalSegmentCount(
|
||||
dimensions: GaugeDimensions,
|
||||
segmentSpacing: number,
|
||||
segmentCount: number,
|
||||
range: number
|
||||
) {
|
||||
const angleBetweenSegments = getAngleBetweenSegments(segmentSpacing, segmentCount, range);
|
||||
|
||||
segments.push(
|
||||
<RadialArcPath
|
||||
key={i}
|
||||
arcLengthDeg={segmentArcLengthDeg}
|
||||
dimensions={dimensions}
|
||||
fieldDisplay={fieldDisplay}
|
||||
glowFilter={glowFilter}
|
||||
shape={shape}
|
||||
startAngle={segmentAngle}
|
||||
{...colorProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const innerRadius = dimensions.radius - dimensions.barWidth / 2;
|
||||
const circumference = Math.PI * innerRadius * 2 * (range / 360);
|
||||
const maxSegments = Math.floor(circumference / (angleBetweenSegments + 3));
|
||||
|
||||
return <g>{segments}</g>;
|
||||
}
|
||||
);
|
||||
return Math.min(maxSegments, segmentCount);
|
||||
}
|
||||
|
||||
RadialBarSegmented.displayName = 'RadialBarSegmented';
|
||||
// export function RadialSegmentLine({
|
||||
// gaugeId,
|
||||
// center,
|
||||
// angle,
|
||||
// size,
|
||||
// color,
|
||||
// barWidth,
|
||||
// roundedBars,
|
||||
// glow,
|
||||
// margin,
|
||||
// segmentWidth,
|
||||
// }: RadialSegmentProps) {
|
||||
// const arcSize = size - barWidth;
|
||||
// const radius = arcSize / 2 - margin;
|
||||
|
||||
// const angleRad = (Math.PI * (angle - 90)) / 180;
|
||||
// const lineLength = radius - barWidth;
|
||||
|
||||
// const x1 = center + radius * Math.cos(angleRad);
|
||||
// const y1 = center + radius * Math.sin(angleRad);
|
||||
// const x2 = center + lineLength * Math.cos(angleRad);
|
||||
// const y2 = center + lineLength * Math.sin(angleRad);
|
||||
|
||||
// return (
|
||||
// <line
|
||||
// x1={x1}
|
||||
// y1={y1}
|
||||
// x2={x2}
|
||||
// y2={y2}
|
||||
// fill="none"
|
||||
// fillOpacity="0.85"
|
||||
// stroke={color}
|
||||
// strokeOpacity="1"
|
||||
// strokeLinecap={roundedBars ? 'round' : 'butt'}
|
||||
// strokeWidth={segmentWidth}
|
||||
// strokeDasharray="0"
|
||||
// filter={glow ? `url(#glow-${gaugeId})` : undefined}
|
||||
// />
|
||||
// );
|
||||
// }
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import tinycolor from 'tinycolor2';
|
||||
|
||||
import { DisplayProcessor, FALLBACK_COLOR, FieldDisplay, getFieldColorMode, GrafanaTheme2 } from '@grafana/data';
|
||||
|
||||
import { RadialGradientMode, RadialShape } from './RadialGauge';
|
||||
import { GaugeDimensions } from './utils';
|
||||
|
||||
export interface RadialColorDefsOptions {
|
||||
gradient: RadialGradientMode;
|
||||
fieldDisplay: FieldDisplay;
|
||||
theme: GrafanaTheme2;
|
||||
dimensions: GaugeDimensions;
|
||||
shape: RadialShape;
|
||||
gaugeId: string;
|
||||
displayProcessor: DisplayProcessor;
|
||||
}
|
||||
|
||||
export class RadialColorDefs {
|
||||
private colorToIds: Record<string, string> = {};
|
||||
private defs: React.ReactNode[] = [];
|
||||
|
||||
constructor(private options: RadialColorDefsOptions) {}
|
||||
|
||||
getSegmentColor(forValue: number): string {
|
||||
const { displayProcessor } = this.options;
|
||||
const baseColor = displayProcessor(forValue).color ?? FALLBACK_COLOR;
|
||||
|
||||
return this.getColor(baseColor, true);
|
||||
}
|
||||
|
||||
getColor(baseColor: string, forSegment?: boolean): string {
|
||||
const { gradient, dimensions, gaugeId, fieldDisplay, shape, theme } = this.options;
|
||||
|
||||
const id = `value-color-${baseColor}-${gaugeId}`;
|
||||
|
||||
if (this.colorToIds[id]) {
|
||||
return this.colorToIds[id];
|
||||
}
|
||||
|
||||
// If no gradient, just return the base color
|
||||
if (gradient === 'none') {
|
||||
this.colorToIds[id] = baseColor;
|
||||
return baseColor;
|
||||
}
|
||||
|
||||
const returnColor = (this.colorToIds[id] = `url(#${id})`);
|
||||
const colorModeId = fieldDisplay.field.color?.mode;
|
||||
const colorMode = getFieldColorMode(colorModeId);
|
||||
const valuePercent = fieldDisplay.display.percent ?? 0;
|
||||
|
||||
// Handle continusous color modes first
|
||||
// If it's a segment color we don't want to do continuous gradients
|
||||
if (colorMode.isContinuous && colorMode.getColors && !forSegment) {
|
||||
const colors = colorMode.getColors(theme);
|
||||
const count = colors.length;
|
||||
|
||||
this.defs.push(
|
||||
<linearGradient x1="0" y1="0" x2={1 / valuePercent} y2="0" id={id}>
|
||||
{colors.map((stopColor, i) => (
|
||||
<stop key={i} offset={`${(i / (count - 1)).toFixed(2)}`} stopColor={stopColor} stopOpacity={1} />
|
||||
))}
|
||||
</linearGradient>
|
||||
);
|
||||
|
||||
return returnColor;
|
||||
}
|
||||
|
||||
// For value based colors we want to stay more true to the specific color
|
||||
// So a radial gradient that adds a bit of light and shade works best
|
||||
if (colorMode.isByValue) {
|
||||
const color1 = tinycolor(baseColor).darken(5);
|
||||
|
||||
this.defs.push(
|
||||
<radialGradient
|
||||
key={id}
|
||||
id={id}
|
||||
cx={dimensions.centerX}
|
||||
cy={dimensions.centerY}
|
||||
r={dimensions.radius + dimensions.barWidth / 2}
|
||||
fr={dimensions.radius - dimensions.barWidth / 2}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" stopColor={tinycolor(baseColor).spin(20).lighten(10).toString()} stopOpacity={1} />
|
||||
<stop offset="60%" stopColor={color1.toString()} stopOpacity={1} />
|
||||
<stop offset="100%" stopColor={color1.toString()} stopOpacity={1} />
|
||||
</radialGradient>
|
||||
);
|
||||
|
||||
return returnColor;
|
||||
}
|
||||
|
||||
// For fixed / palette based color scales we can create a more fun
|
||||
// hue and light based linear gradient that we rotate/move with the value
|
||||
|
||||
const x2 = shape === 'circle' ? 0 : dimensions.centerX + dimensions.radius;
|
||||
const y2 = shape === 'circle' ? dimensions.centerY + dimensions.radius : 0;
|
||||
const color1 = tinycolor(baseColor).spin(-20).darken(5);
|
||||
const color2 = tinycolor(baseColor).saturate(20).spin(20).brighten(10);
|
||||
|
||||
// this makes it so the gradient is always brightest at the current value
|
||||
const transform =
|
||||
shape === 'circle'
|
||||
? `rotate(${360 * valuePercent - 180} ${dimensions.centerX} ${dimensions.centerY})`
|
||||
: `translate(-${dimensions.radius * 2 * (1 - valuePercent)}, 0)`;
|
||||
|
||||
this.defs.push(
|
||||
<linearGradient
|
||||
key={id}
|
||||
id={id}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2={x2}
|
||||
y2={y2}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform={transform}
|
||||
>
|
||||
{theme.isDark ? (
|
||||
<>
|
||||
<stop offset="0%" stopColor={color1.darken(10).toString()} stopOpacity={1} />
|
||||
<stop offset="100%" stopColor={color2.lighten(10).toString()} stopOpacity={1} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<stop offset="0%" stopColor={color2.lighten(10).toString()} stopOpacity={1} />
|
||||
<stop offset="100%" stopColor={color1.toString()} stopOpacity={1} />
|
||||
</>
|
||||
)}
|
||||
</linearGradient>
|
||||
);
|
||||
|
||||
return returnColor;
|
||||
}
|
||||
|
||||
getMainBarColor(): string {
|
||||
return this.getColor(this.options.fieldDisplay.display.color ?? FALLBACK_COLOR);
|
||||
}
|
||||
|
||||
getDefs(): React.ReactNode[] {
|
||||
return this.defs;
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,7 @@ import { FieldColorModeId } from '@grafana/schema';
|
||||
import { useTheme2 } from '../../themes/ThemeContext';
|
||||
import { Stack } from '../Layout/Stack/Stack';
|
||||
|
||||
import { RadialGauge, RadialGaugeProps } from './RadialGauge';
|
||||
import { RadialShape, RadialTextMode } from './types';
|
||||
import { RadialGauge, RadialGaugeProps, RadialGradientMode, RadialShape, RadialTextMode } from './RadialGauge';
|
||||
|
||||
interface StoryProps extends RadialGaugeProps {
|
||||
value: number;
|
||||
@@ -32,27 +31,10 @@ const meta: Meta<StoryProps> = {
|
||||
controls: {
|
||||
exclude: ['theme', 'values', 'vizCount'],
|
||||
},
|
||||
a11y: {
|
||||
config: {
|
||||
rules: [
|
||||
{
|
||||
id: 'scrollable-region-focusable',
|
||||
selector: 'body',
|
||||
enabled: false,
|
||||
},
|
||||
// NOTE: this is necessary due to a false positive with the filered svg glow in one of the examples.
|
||||
// The color-contrast in this component should be accessible!
|
||||
{
|
||||
id: 'color-contrast',
|
||||
selector: 'text',
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
args: {
|
||||
barWidthFactor: 0.2,
|
||||
spotlight: false,
|
||||
glowBar: false,
|
||||
glowCenter: false,
|
||||
sparkline: false,
|
||||
@@ -60,7 +42,7 @@ const meta: Meta<StoryProps> = {
|
||||
width: 200,
|
||||
height: 200,
|
||||
shape: 'circle',
|
||||
gradient: false,
|
||||
gradient: 'none',
|
||||
seriesCount: 1,
|
||||
segmentCount: 0,
|
||||
segmentSpacing: 0.2,
|
||||
@@ -74,14 +56,14 @@ const meta: Meta<StoryProps> = {
|
||||
width: { control: { type: 'range', min: 50, max: 600 } },
|
||||
height: { control: { type: 'range', min: 50, max: 600 } },
|
||||
value: { control: { type: 'range', min: 0, max: 110 } },
|
||||
spotlight: { control: 'boolean' },
|
||||
roundedBars: { control: 'boolean' },
|
||||
sparkline: { control: 'boolean' },
|
||||
thresholdsBar: { control: 'boolean' },
|
||||
gradient: { control: { type: 'boolean' } },
|
||||
gradient: { control: { type: 'radio' } },
|
||||
seriesCount: { control: { type: 'range', min: 1, max: 20 } },
|
||||
segmentCount: { control: { type: 'range', min: 0, max: 100 } },
|
||||
segmentSpacing: { control: { type: 'range', min: 0, max: 1, step: 0.01 } },
|
||||
endpointMarker: { control: { type: 'select' }, options: ['none', 'point', 'glow'] },
|
||||
colorScheme: {
|
||||
control: { type: 'select' },
|
||||
options: [
|
||||
@@ -120,17 +102,57 @@ export const Examples: StoryFn<StoryProps> = (args) => {
|
||||
<Stack direction={'column'} gap={3} wrap="wrap">
|
||||
<div>Bar width</div>
|
||||
<Stack direction="row" alignItems="center" gap={3} wrap="wrap">
|
||||
<RadialGaugeExample seriesName="0.1" value={args.value ?? 30} color="blue" gradient barWidthFactor={0.1} />
|
||||
<RadialGaugeExample seriesName="0.4" value={args.value ?? 40} color="green" gradient barWidthFactor={0.4} />
|
||||
<RadialGaugeExample seriesName="0.6" value={args.value ?? 60} color="red" gradient barWidthFactor={0.6} />
|
||||
<RadialGaugeExample seriesName="0.8" value={args.value ?? 70} color="purple" gradient barWidthFactor={0.8} />
|
||||
<RadialGaugeExample
|
||||
seriesName="0.1"
|
||||
value={args.value ?? 30}
|
||||
color="blue"
|
||||
gradient="auto"
|
||||
barWidthFactor={0.1}
|
||||
/>
|
||||
<RadialGaugeExample
|
||||
seriesName="0.4"
|
||||
value={args.value ?? 40}
|
||||
color="green"
|
||||
gradient="auto"
|
||||
barWidthFactor={0.4}
|
||||
/>
|
||||
<RadialGaugeExample
|
||||
seriesName="0.6"
|
||||
value={args.value ?? 60}
|
||||
color="red"
|
||||
gradient="auto"
|
||||
barWidthFactor={0.6}
|
||||
/>
|
||||
<RadialGaugeExample
|
||||
seriesName="0.8"
|
||||
value={args.value ?? 70}
|
||||
color="purple"
|
||||
gradient="auto"
|
||||
barWidthFactor={0.8}
|
||||
/>
|
||||
</Stack>
|
||||
<div>Effects</div>
|
||||
<Stack direction="row" alignItems="center" gap={3} wrap="wrap">
|
||||
<RadialGaugeExample value={args.value ?? 30} glowBar glowCenter color="blue" gradient />
|
||||
<RadialGaugeExample value={args.value ?? 40} glowBar glowCenter color="green" gradient />
|
||||
<RadialGaugeExample value={args.value ?? 60} glowBar glowCenter color="red" gradient roundedBars />
|
||||
<RadialGaugeExample value={args.value ?? 70} glowBar glowCenter color="purple" gradient roundedBars />
|
||||
<RadialGaugeExample value={args.value ?? 30} spotlight glowBar glowCenter color="blue" gradient="auto" />
|
||||
<RadialGaugeExample value={args.value ?? 40} spotlight glowBar glowCenter color="green" gradient="auto" />
|
||||
<RadialGaugeExample
|
||||
value={args.value ?? 60}
|
||||
spotlight
|
||||
glowBar
|
||||
glowCenter
|
||||
color="red"
|
||||
gradient="auto"
|
||||
roundedBars
|
||||
/>
|
||||
<RadialGaugeExample
|
||||
value={args.value ?? 70}
|
||||
spotlight
|
||||
glowBar
|
||||
glowCenter
|
||||
color="purple"
|
||||
gradient="auto"
|
||||
roundedBars
|
||||
/>
|
||||
</Stack>
|
||||
<div>Shape: Gauge & color scale</div>
|
||||
<Stack direction="row" alignItems="center" gap={3} wrap="wrap">
|
||||
@@ -138,14 +160,14 @@ export const Examples: StoryFn<StoryProps> = (args) => {
|
||||
value={40}
|
||||
shape="gauge"
|
||||
width={250}
|
||||
gradient
|
||||
gradient="auto"
|
||||
colorScheme={FieldColorModeId.ContinuousGrYlRd}
|
||||
glowCenter={true}
|
||||
barWidthFactor={0.6}
|
||||
/>
|
||||
<RadialGaugeExample
|
||||
colorScheme={FieldColorModeId.ContinuousGrYlRd}
|
||||
gradient
|
||||
gradient="auto"
|
||||
width={250}
|
||||
value={90}
|
||||
barWidthFactor={0.6}
|
||||
@@ -161,8 +183,9 @@ export const Examples: StoryFn<StoryProps> = (args) => {
|
||||
value={args.value ?? 70}
|
||||
color="blue"
|
||||
shape="gauge"
|
||||
gradient
|
||||
gradient="auto"
|
||||
sparkline={true}
|
||||
spotlight
|
||||
glowBar={true}
|
||||
glowCenter={true}
|
||||
barWidthFactor={0.2}
|
||||
@@ -171,8 +194,9 @@ export const Examples: StoryFn<StoryProps> = (args) => {
|
||||
value={args.value ?? 30}
|
||||
color="green"
|
||||
shape="gauge"
|
||||
gradient
|
||||
gradient="auto"
|
||||
sparkline={true}
|
||||
spotlight
|
||||
glowBar={true}
|
||||
glowCenter={true}
|
||||
barWidthFactor={0.8}
|
||||
@@ -182,8 +206,9 @@ export const Examples: StoryFn<StoryProps> = (args) => {
|
||||
color="red"
|
||||
shape="gauge"
|
||||
width={250}
|
||||
gradient
|
||||
gradient="auto"
|
||||
sparkline={true}
|
||||
spotlight
|
||||
glowBar={true}
|
||||
glowCenter={true}
|
||||
barWidthFactor={0.2}
|
||||
@@ -193,8 +218,9 @@ export const Examples: StoryFn<StoryProps> = (args) => {
|
||||
color="red"
|
||||
width={250}
|
||||
shape="gauge"
|
||||
gradient
|
||||
gradient="auto"
|
||||
sparkline={true}
|
||||
spotlight
|
||||
glowBar={true}
|
||||
glowCenter={true}
|
||||
barWidthFactor={0.8}
|
||||
@@ -205,7 +231,7 @@ export const Examples: StoryFn<StoryProps> = (args) => {
|
||||
<RadialGaugeExample
|
||||
value={args.value ?? 70}
|
||||
color="green"
|
||||
gradient
|
||||
gradient="auto"
|
||||
glowCenter={true}
|
||||
segmentCount={8}
|
||||
segmentSpacing={0.1}
|
||||
@@ -214,7 +240,7 @@ export const Examples: StoryFn<StoryProps> = (args) => {
|
||||
<RadialGaugeExample
|
||||
value={args.value ?? 30}
|
||||
color="purple"
|
||||
gradient
|
||||
gradient="auto"
|
||||
segmentCount={30}
|
||||
glowCenter={true}
|
||||
barWidthFactor={0.6}
|
||||
@@ -222,7 +248,7 @@ export const Examples: StoryFn<StoryProps> = (args) => {
|
||||
<RadialGaugeExample
|
||||
value={args.value ?? 50}
|
||||
color="red"
|
||||
gradient
|
||||
gradient="auto"
|
||||
segmentCount={40}
|
||||
glowCenter={true}
|
||||
barWidthFactor={1}
|
||||
@@ -234,6 +260,7 @@ export const Examples: StoryFn<StoryProps> = (args) => {
|
||||
<RadialGaugeExample
|
||||
value={args.value ?? 80}
|
||||
colorScheme={FieldColorModeId.ContinuousGrYlRd}
|
||||
spotlight
|
||||
glowBar={true}
|
||||
glowCenter={true}
|
||||
segmentCount={20}
|
||||
@@ -243,8 +270,9 @@ export const Examples: StoryFn<StoryProps> = (args) => {
|
||||
value={args.value ?? 80}
|
||||
width={250}
|
||||
colorScheme={FieldColorModeId.ContinuousGrYlRd}
|
||||
spotlight
|
||||
shape="gauge"
|
||||
gradient
|
||||
gradient="auto"
|
||||
glowBar={true}
|
||||
glowCenter={true}
|
||||
segmentCount={40}
|
||||
@@ -257,9 +285,10 @@ export const Examples: StoryFn<StoryProps> = (args) => {
|
||||
<RadialGaugeExample
|
||||
value={args.value ?? 70}
|
||||
colorScheme={FieldColorModeId.Thresholds}
|
||||
gradient
|
||||
gradient="auto"
|
||||
thresholdsBar={true}
|
||||
roundedBars={false}
|
||||
spotlight
|
||||
glowCenter={true}
|
||||
barWidthFactor={0.7}
|
||||
/>
|
||||
@@ -267,7 +296,7 @@ export const Examples: StoryFn<StoryProps> = (args) => {
|
||||
value={args.value ?? 70}
|
||||
width={250}
|
||||
colorScheme={FieldColorModeId.Thresholds}
|
||||
gradient
|
||||
gradient="auto"
|
||||
glowCenter={true}
|
||||
thresholdsBar={true}
|
||||
roundedBars={false}
|
||||
@@ -278,7 +307,7 @@ export const Examples: StoryFn<StoryProps> = (args) => {
|
||||
value={args.value ?? 70}
|
||||
width={250}
|
||||
colorScheme={FieldColorModeId.Thresholds}
|
||||
gradient
|
||||
gradient="auto"
|
||||
glowCenter={true}
|
||||
thresholdsBar={true}
|
||||
roundedBars={false}
|
||||
@@ -318,12 +347,14 @@ export const Temp: StoryFn<StoryProps> = (args) => {
|
||||
shape="gauge"
|
||||
roundedBars={false}
|
||||
barWidthFactor={0.8}
|
||||
spotlight
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
interface ExampleProps {
|
||||
gradient?: RadialGradientMode;
|
||||
color?: string;
|
||||
seriesName?: string;
|
||||
value?: number;
|
||||
@@ -332,7 +363,7 @@ interface ExampleProps {
|
||||
max?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
gradient?: boolean;
|
||||
spotlight?: boolean;
|
||||
glowBar?: boolean;
|
||||
glowCenter?: boolean;
|
||||
barWidthFactor?: number;
|
||||
@@ -345,12 +376,12 @@ interface ExampleProps {
|
||||
roundedBars?: boolean;
|
||||
thresholdsBar?: boolean;
|
||||
colorScheme?: FieldColorModeId;
|
||||
endpointMarker?: RadialGaugeProps['endpointMarker'];
|
||||
decimals?: number;
|
||||
showScaleLabels?: boolean;
|
||||
}
|
||||
|
||||
export function RadialGaugeExample({
|
||||
gradient = 'none',
|
||||
color,
|
||||
seriesName = 'Server A',
|
||||
value = 70,
|
||||
@@ -359,7 +390,7 @@ export function RadialGaugeExample({
|
||||
max = 100,
|
||||
width = 200,
|
||||
height = 200,
|
||||
gradient = false,
|
||||
spotlight = false,
|
||||
glowBar = false,
|
||||
glowCenter = false,
|
||||
barWidthFactor = 0.4,
|
||||
@@ -372,7 +403,6 @@ export function RadialGaugeExample({
|
||||
roundedBars = false,
|
||||
thresholdsBar = false,
|
||||
colorScheme = FieldColorModeId.Thresholds,
|
||||
endpointMarker = 'glow',
|
||||
decimals = 0,
|
||||
showScaleLabels,
|
||||
}: ExampleProps) {
|
||||
@@ -450,6 +480,7 @@ export function RadialGaugeExample({
|
||||
barWidthFactor={barWidthFactor}
|
||||
gradient={gradient}
|
||||
shape={shape}
|
||||
spotlight={spotlight}
|
||||
glowBar={glowBar}
|
||||
glowCenter={glowCenter}
|
||||
textMode={textMode}
|
||||
@@ -459,7 +490,6 @@ export function RadialGaugeExample({
|
||||
roundedBars={roundedBars}
|
||||
thresholdsBar={thresholdsBar}
|
||||
showScaleLabels={showScaleLabels}
|
||||
endpointMarker={endpointMarker}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,28 +1,13 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ComponentProps } from 'react';
|
||||
|
||||
import { RadialGaugeExample } from './RadialGauge.story';
|
||||
|
||||
describe('RadialGauge', () => {
|
||||
it.each([
|
||||
{ description: 'default', props: {} },
|
||||
{ description: 'gauge shape', props: { shape: 'gauge' } },
|
||||
{ description: 'with gradient', props: { gradient: true } },
|
||||
{ description: 'with glow bar', props: { glowBar: true } },
|
||||
{ description: 'with glow center', props: { glowCenter: true } },
|
||||
{ description: 'with segments', props: { segmentCount: 5 } },
|
||||
{ description: 'with rounded bars', props: { roundedBars: true } },
|
||||
{ description: 'with endpoint marker glow', props: { roundedBars: true, endpointMarker: 'glow' } },
|
||||
{ description: 'with endpoint marker point', props: { roundedBars: true, endpointMarker: 'point' } },
|
||||
{ description: 'with thresholds bar', props: { thresholdsBar: true } },
|
||||
{ description: 'with sparkline', props: { sparkline: true } },
|
||||
] satisfies Array<{ description: string; props?: ComponentProps<typeof RadialGaugeExample> }>)(
|
||||
'should render $description without throwing',
|
||||
({ props }) => {
|
||||
render(<RadialGaugeExample {...props} />);
|
||||
expect(screen.getByRole('img')).toBeInTheDocument();
|
||||
}
|
||||
);
|
||||
it('should render', () => {
|
||||
render(<RadialGaugeExample />);
|
||||
|
||||
expect(screen.getByRole('img')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render threshold labels', () => {
|
||||
render(<RadialGaugeExample showScaleLabels={true} />);
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { isNumber } from 'lodash';
|
||||
import { useId } from 'react';
|
||||
|
||||
import { DisplayValueAlignmentFactors, FALLBACK_COLOR, FieldDisplay, GrafanaTheme2, TimeRange } from '@grafana/data';
|
||||
import {
|
||||
DisplayValueAlignmentFactors,
|
||||
FieldDisplay,
|
||||
getDisplayProcessor,
|
||||
GrafanaTheme2,
|
||||
TimeRange,
|
||||
} from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
|
||||
import { useStyles2, useTheme2 } from '../../themes/ThemeContext';
|
||||
@@ -9,13 +16,12 @@ import { getFormattedThresholds } from '../Gauge/utils';
|
||||
|
||||
import { RadialBar } from './RadialBar';
|
||||
import { RadialBarSegmented } from './RadialBarSegmented';
|
||||
import { RadialColorDefs } from './RadialColorDefs';
|
||||
import { RadialScaleLabels } from './RadialScaleLabels';
|
||||
import { RadialSparkline } from './RadialSparkline';
|
||||
import { RadialText } from './RadialText';
|
||||
import { ThresholdsBar } from './ThresholdsBar';
|
||||
import { buildGradientColors } from './colors';
|
||||
import { GlowGradient, MiddleCircleGlow, SpotlightGradient } from './effects';
|
||||
import { RadialShape, RadialTextMode } from './types';
|
||||
import { calculateDimensions, getValueAngleForValue } from './utils';
|
||||
|
||||
export interface RadialGaugeProps {
|
||||
@@ -26,7 +32,7 @@ export interface RadialGaugeProps {
|
||||
* Circle or gauge (partial circle)
|
||||
*/
|
||||
shape?: RadialShape;
|
||||
gradient?: boolean;
|
||||
gradient?: RadialGradientMode;
|
||||
/**
|
||||
* Bar width is always relative to size of the gauge.
|
||||
* But this gives you control over the width relative to size.
|
||||
@@ -34,14 +40,12 @@ export interface RadialGaugeProps {
|
||||
* Defaults to 0.4
|
||||
**/
|
||||
barWidthFactor?: number;
|
||||
/** Adds a white spotlight for the end position */
|
||||
spotlight?: boolean;
|
||||
glowBar?: boolean;
|
||||
glowCenter?: boolean;
|
||||
roundedBars?: boolean;
|
||||
thresholdsBar?: boolean;
|
||||
/**
|
||||
* Specify if an endpoint marker should be shown at the end of the bar
|
||||
*/
|
||||
endpointMarker?: 'point' | 'glow';
|
||||
/**
|
||||
* Number of segments depends on size of gauge but this
|
||||
* factor 1-10 gives you relative control
|
||||
@@ -71,6 +75,10 @@ export interface RadialGaugeProps {
|
||||
timeRange?: TimeRange;
|
||||
}
|
||||
|
||||
export type RadialGradientMode = 'none' | 'auto';
|
||||
export type RadialTextMode = 'auto' | 'value_and_name' | 'value' | 'name' | 'none';
|
||||
export type RadialShape = 'circle' | 'gauge';
|
||||
|
||||
/**
|
||||
* https://developers.grafana.com/ui/latest/index.html?path=/docs/plugins-radialgauge--docs
|
||||
*/
|
||||
@@ -79,8 +87,9 @@ export function RadialGauge(props: RadialGaugeProps) {
|
||||
width = 256,
|
||||
height = 256,
|
||||
shape = 'circle',
|
||||
gradient = false,
|
||||
gradient = 'none',
|
||||
barWidthFactor = 0.4,
|
||||
spotlight = false,
|
||||
glowBar = false,
|
||||
glowCenter = false,
|
||||
textMode = 'auto',
|
||||
@@ -90,7 +99,6 @@ export function RadialGauge(props: RadialGaugeProps) {
|
||||
roundedBars = true,
|
||||
thresholdsBar = false,
|
||||
showScaleLabels = false,
|
||||
endpointMarker,
|
||||
onClick,
|
||||
values,
|
||||
} = props;
|
||||
@@ -113,8 +121,7 @@ export function RadialGauge(props: RadialGaugeProps) {
|
||||
for (let barIndex = 0; barIndex < values.length; barIndex++) {
|
||||
const displayValue = values[barIndex];
|
||||
const { angle, angleRange } = getValueAngleForValue(displayValue, startAngle, endAngle);
|
||||
const gradientStops = buildGradientColors(gradient, theme, displayValue);
|
||||
const color = displayValue.display.color ?? FALLBACK_COLOR;
|
||||
const color = displayValue.display.color ?? 'gray';
|
||||
const dimensions = calculateDimensions(
|
||||
width,
|
||||
height,
|
||||
@@ -127,12 +134,20 @@ export function RadialGauge(props: RadialGaugeProps) {
|
||||
showScaleLabels
|
||||
);
|
||||
|
||||
// FIXME: I want to move the ids for these filters into a context which the children
|
||||
// can reference via a hook, rather than passing them down as props
|
||||
const displayProcessor = getFieldDisplayProcessor(displayValue);
|
||||
const spotlightGradientId = `spotlight-${barIndex}-${gaugeId}`;
|
||||
const glowFilterId = `glow-${gaugeId}`;
|
||||
const colorDefs = new RadialColorDefs({
|
||||
gradient,
|
||||
fieldDisplay: displayValue,
|
||||
theme,
|
||||
dimensions,
|
||||
shape,
|
||||
gaugeId,
|
||||
displayProcessor,
|
||||
});
|
||||
|
||||
if (endpointMarker === 'glow') {
|
||||
if (spotlight && theme.isDark) {
|
||||
defs.push(
|
||||
<SpotlightGradient
|
||||
key={spotlightGradientId}
|
||||
@@ -156,8 +171,7 @@ export function RadialGauge(props: RadialGaugeProps) {
|
||||
glowFilter={`url(#${glowFilterId})`}
|
||||
segmentCount={segmentCount}
|
||||
segmentSpacing={segmentSpacing}
|
||||
shape={shape}
|
||||
gradient={gradientStops}
|
||||
colorDefs={colorDefs}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
@@ -165,16 +179,13 @@ export function RadialGauge(props: RadialGaugeProps) {
|
||||
<RadialBar
|
||||
key={`radial-bar-${barIndex}-${gaugeId}`}
|
||||
dimensions={dimensions}
|
||||
colorDefs={colorDefs}
|
||||
angle={angle}
|
||||
angleRange={angleRange}
|
||||
startAngle={startAngle}
|
||||
roundedBars={roundedBars}
|
||||
spotlightStroke={`url(#${spotlightGradientId})`}
|
||||
glowFilter={`url(#${glowFilterId})`}
|
||||
endpointMarkerGlowFilter={`url(#${spotlightGradientId})`}
|
||||
shape={shape}
|
||||
gradient={gradientStops}
|
||||
fieldDisplay={displayValue}
|
||||
endpointMarker={endpointMarker}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -234,8 +245,7 @@ export function RadialGauge(props: RadialGaugeProps) {
|
||||
angleRange={angleRange}
|
||||
roundedBars={roundedBars}
|
||||
glowFilter={`url(#${glowFilterId})`}
|
||||
shape={shape}
|
||||
gradient={gradientStops}
|
||||
colorDefs={colorDefs}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -281,6 +291,17 @@ export function RadialGauge(props: RadialGaugeProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function getFieldDisplayProcessor(displayValue: FieldDisplay) {
|
||||
if (displayValue.view && isNumber(displayValue.colIndex)) {
|
||||
const dp = displayValue.view.getFieldDisplayProcessor(displayValue.colIndex);
|
||||
if (dp) {
|
||||
return dp;
|
||||
}
|
||||
}
|
||||
|
||||
return getDisplayProcessor();
|
||||
}
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
vizWrapper: css({
|
||||
|
||||
@@ -1,84 +1,87 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
import { FieldDisplay, GrafanaTheme2, Threshold } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
|
||||
import { measureText } from '../../utils/measureText';
|
||||
|
||||
import { RadialGaugeDimensions } from './types';
|
||||
import { getFieldConfigMinMax, toCartesian } from './utils';
|
||||
import { GaugeDimensions, toCartesian } from './utils';
|
||||
|
||||
interface RadialScaleLabelsProps {
|
||||
fieldDisplay: FieldDisplay;
|
||||
theme: GrafanaTheme2;
|
||||
thresholds: Threshold[];
|
||||
dimensions: RadialGaugeDimensions;
|
||||
dimensions: GaugeDimensions;
|
||||
startAngle: number;
|
||||
endAngle: number;
|
||||
angleRange: number;
|
||||
}
|
||||
|
||||
const LINE_HEIGHT_FACTOR = 1.2;
|
||||
export function RadialScaleLabels({
|
||||
fieldDisplay,
|
||||
thresholds,
|
||||
theme,
|
||||
dimensions,
|
||||
startAngle,
|
||||
endAngle,
|
||||
angleRange,
|
||||
}: RadialScaleLabelsProps) {
|
||||
const { centerX, centerY, scaleLabelsFontSize, scaleLabelsRadius } = dimensions;
|
||||
|
||||
export const RadialScaleLabels = memo(
|
||||
({ fieldDisplay, thresholds, theme, dimensions, startAngle, endAngle, angleRange }: RadialScaleLabelsProps) => {
|
||||
const { centerX, centerY, scaleLabelsFontSize, scaleLabelsRadius } = dimensions;
|
||||
const [min, max] = getFieldConfigMinMax(fieldDisplay);
|
||||
const fieldConfig = fieldDisplay.field;
|
||||
const min = fieldConfig.min ?? 0;
|
||||
const max = fieldConfig.max ?? 100;
|
||||
|
||||
const fontSize = scaleLabelsFontSize;
|
||||
const textLineHeight = scaleLabelsFontSize * LINE_HEIGHT_FACTOR;
|
||||
const radius = scaleLabelsRadius - textLineHeight;
|
||||
const fontSize = scaleLabelsFontSize;
|
||||
const textLineHeight = scaleLabelsFontSize * 1.2;
|
||||
const radius = scaleLabelsRadius - textLineHeight;
|
||||
|
||||
function getTextPosition(text: string, value: number, index: number) {
|
||||
const isLast = index === thresholds.length - 1;
|
||||
const isFirst = index === 0;
|
||||
function getTextPosition(text: string, value: number, index: number) {
|
||||
const isLast = index === thresholds.length - 1;
|
||||
const isFirst = index === 0;
|
||||
|
||||
let valueDeg = ((value - min) / (max - min)) * angleRange;
|
||||
let finalAngle = startAngle + valueDeg;
|
||||
let valueDeg = ((value - min) / (max - min)) * angleRange;
|
||||
let finalAngle = startAngle + valueDeg;
|
||||
|
||||
// Now adjust the final angle based on the label text width and the labels position on the arc
|
||||
let measure = measureText(text, fontSize, theme.typography.fontWeightMedium);
|
||||
let textWidthAngle = (measure.width / (2 * Math.PI * radius)) * angleRange;
|
||||
// Now adjust the final angle based on the label text width and the labels position on the arc
|
||||
let measure = measureText(text, fontSize, theme.typography.fontWeightMedium);
|
||||
let textWidthAngle = (measure.width / (2 * Math.PI * radius)) * angleRange;
|
||||
|
||||
// the centering is different for gauge or circle shapes for some reason
|
||||
finalAngle -= endAngle < 180 ? textWidthAngle : textWidthAngle / 2;
|
||||
// the centering is different for gauge or circle shapes for some reason
|
||||
finalAngle -= endAngle < 180 ? textWidthAngle : textWidthAngle / 2;
|
||||
|
||||
// For circle gauges we need to shift the first label more
|
||||
if (isFirst) {
|
||||
finalAngle += textWidthAngle;
|
||||
}
|
||||
|
||||
// For circle gauges we need to shift the last label more
|
||||
if (isLast && endAngle === 360) {
|
||||
finalAngle -= textWidthAngle;
|
||||
}
|
||||
|
||||
const position = toCartesian(centerX, centerY, radius, finalAngle);
|
||||
|
||||
return { ...position, transform: `rotate(${finalAngle}, ${position.x}, ${position.y})` };
|
||||
// For circle gauges we need to shift the first label more
|
||||
if (isFirst) {
|
||||
finalAngle += textWidthAngle;
|
||||
}
|
||||
|
||||
return (
|
||||
<g>
|
||||
{thresholds.map((threshold, index) => {
|
||||
const labelPos = getTextPosition(String(threshold.value), threshold.value, index);
|
||||
return (
|
||||
<text
|
||||
key={index}
|
||||
x={labelPos.x}
|
||||
y={labelPos.y}
|
||||
fontSize={fontSize}
|
||||
fill={theme.colors.text.primary}
|
||||
transform={labelPos.transform}
|
||||
aria-label={t(`gauge.threshold`, 'Threshold {{value}}', { value: threshold.value })}
|
||||
>
|
||||
{threshold.value}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
);
|
||||
// For circle gauges we need to shift the last label more
|
||||
if (isLast && endAngle === 360) {
|
||||
finalAngle -= textWidthAngle;
|
||||
}
|
||||
|
||||
RadialScaleLabels.displayName = 'RadialScaleLabels';
|
||||
const position = toCartesian(centerX, centerY, radius, finalAngle);
|
||||
|
||||
return { ...position, transform: `rotate(${finalAngle}, ${position.x}, ${position.y})` };
|
||||
}
|
||||
|
||||
return (
|
||||
<g>
|
||||
{thresholds.map((threshold, index) => {
|
||||
const labelPos = getTextPosition(String(threshold.value), threshold.value, index);
|
||||
|
||||
return (
|
||||
<text
|
||||
key={index}
|
||||
x={labelPos.x}
|
||||
y={labelPos.y}
|
||||
fontSize={fontSize}
|
||||
fill={theme.colors.text.primary}
|
||||
transform={labelPos.transform}
|
||||
aria-label={t(`gauge.threshold`, 'Threshold {{value}}', { value: threshold.value })}
|
||||
>
|
||||
{threshold.value}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,76 +1,49 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
|
||||
import { FieldDisplay, GrafanaTheme2, FieldConfig } from '@grafana/data';
|
||||
import { GraphFieldConfig, GraphGradientMode, LineInterpolation } from '@grafana/schema';
|
||||
|
||||
import { Sparkline } from '../Sparkline/Sparkline';
|
||||
|
||||
import { RadialShape, RadialTextMode, RadialGaugeDimensions } from './types';
|
||||
import { RadialShape, RadialTextMode } from './RadialGauge';
|
||||
import { GaugeDimensions } from './utils';
|
||||
|
||||
interface RadialSparklineProps {
|
||||
color?: string;
|
||||
dimensions: RadialGaugeDimensions;
|
||||
shape: RadialShape;
|
||||
sparkline: FieldDisplay['sparkline'];
|
||||
textMode: Exclude<RadialTextMode, 'auto'>;
|
||||
dimensions: GaugeDimensions;
|
||||
theme: GrafanaTheme2;
|
||||
color?: string;
|
||||
shape?: RadialShape;
|
||||
textMode: Exclude<RadialTextMode, 'auto'>;
|
||||
}
|
||||
export function RadialSparkline({ sparkline, dimensions, theme, color, shape, textMode }: RadialSparklineProps) {
|
||||
const { radius, barWidth } = dimensions;
|
||||
|
||||
const SPARKLINE_HEIGHT_DIVISOR = 4;
|
||||
const SPARKLINE_HEIGHT_DIVISOR_NAME_AND_VALUE = 4;
|
||||
const SPARKLINE_WIDTH_FACTOR_ARC = 1.4;
|
||||
const SPARKLINE_WIDTH_FACTOR_CIRCLE = 1.6;
|
||||
const SPARKLINE_TOP_OFFSET_DIVISOR_CIRCLE = 4;
|
||||
const SPARKLINE_TOP_OFFSET_DIVISOR_CIRCLE_NAME_AND_VALUE = 3.3;
|
||||
const SPARKLINE_SPACING = 8;
|
||||
|
||||
export function getSparklineDimensions(
|
||||
radius: number,
|
||||
barWidth: number,
|
||||
showNameAndValue: boolean,
|
||||
shape: RadialShape
|
||||
): { width: number; height: number } {
|
||||
const height = radius / (showNameAndValue ? SPARKLINE_HEIGHT_DIVISOR_NAME_AND_VALUE : SPARKLINE_HEIGHT_DIVISOR);
|
||||
const width = radius * (shape === 'gauge' ? SPARKLINE_WIDTH_FACTOR_ARC : SPARKLINE_WIDTH_FACTOR_CIRCLE) - barWidth;
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
export const RadialSparkline = memo(
|
||||
({ sparkline, dimensions, theme, color, shape, textMode }: RadialSparklineProps) => {
|
||||
const { radius, barWidth } = dimensions;
|
||||
|
||||
const showNameAndValue = textMode === 'value_and_name';
|
||||
const { width, height } = getSparklineDimensions(radius, barWidth, showNameAndValue, shape);
|
||||
const topPos =
|
||||
shape === 'gauge'
|
||||
? dimensions.gaugeBottomY - height - SPARKLINE_SPACING
|
||||
: `calc(50% + ${radius / (showNameAndValue ? SPARKLINE_TOP_OFFSET_DIVISOR_CIRCLE_NAME_AND_VALUE : SPARKLINE_TOP_OFFSET_DIVISOR_CIRCLE)}px)`;
|
||||
|
||||
const config: FieldConfig<GraphFieldConfig> = useMemo(
|
||||
() => ({
|
||||
color: {
|
||||
mode: 'fixed',
|
||||
fixedColor: color ?? 'blue',
|
||||
},
|
||||
custom: {
|
||||
gradientMode: GraphGradientMode.Opacity,
|
||||
fillOpacity: 40,
|
||||
lineInterpolation: LineInterpolation.Smooth,
|
||||
},
|
||||
}),
|
||||
[color]
|
||||
);
|
||||
|
||||
if (!sparkline) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'absolute', top: topPos }}>
|
||||
<Sparkline height={height} width={width} sparkline={sparkline} theme={theme} config={config} />
|
||||
</div>
|
||||
);
|
||||
if (!sparkline) {
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
RadialSparkline.displayName = 'RadialSparkline';
|
||||
const showNameAndValue = textMode === 'value_and_name';
|
||||
const height = radius / (showNameAndValue ? 4 : 3);
|
||||
const width = radius * (shape === 'gauge' ? 1.6 : 1.4) - barWidth;
|
||||
const topPos =
|
||||
shape === 'gauge'
|
||||
? `${dimensions.gaugeBottomY - height}px`
|
||||
: `calc(50% + ${radius / (showNameAndValue ? 3.3 : 4)}px)`;
|
||||
|
||||
const config: FieldConfig<GraphFieldConfig> = {
|
||||
color: {
|
||||
mode: 'fixed',
|
||||
fixedColor: color ?? 'blue',
|
||||
},
|
||||
custom: {
|
||||
gradientMode: GraphGradientMode.Opacity,
|
||||
fillOpacity: 40,
|
||||
lineInterpolation: LineInterpolation.Smooth,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: 'absolute', top: topPos }}>
|
||||
<Sparkline height={height} width={width} sparkline={sparkline} theme={theme} config={config} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { memo } from 'react';
|
||||
|
||||
import {
|
||||
DisplayValue,
|
||||
@@ -12,12 +11,13 @@ import {
|
||||
import { useStyles2 } from '../../themes/ThemeContext';
|
||||
import { calculateFontSize } from '../../utils/measureText';
|
||||
|
||||
import { RadialShape, RadialTextMode, RadialGaugeDimensions } from './types';
|
||||
import { RadialShape, RadialTextMode } from './RadialGauge';
|
||||
import { GaugeDimensions } from './utils';
|
||||
|
||||
interface RadialTextProps {
|
||||
displayValue: DisplayValue;
|
||||
theme: GrafanaTheme2;
|
||||
dimensions: RadialGaugeDimensions;
|
||||
dimensions: GaugeDimensions;
|
||||
textMode: Exclude<RadialTextMode, 'auto'>;
|
||||
shape: RadialShape;
|
||||
sparkline?: FieldSparkline;
|
||||
@@ -26,137 +26,123 @@ interface RadialTextProps {
|
||||
nameManualFontSize?: number;
|
||||
}
|
||||
|
||||
const LINE_HEIGHT_FACTOR = 1.21;
|
||||
const VALUE_WIDTH_TO_RADIUS_FACTOR = 0.82;
|
||||
const NAME_TO_HEIGHT_FACTOR = 0.45;
|
||||
const LARGE_RADIUS_SCALING_DECAY = 0.86;
|
||||
const MAX_TEXT_WIDTH_DIVISOR = 7;
|
||||
const MAX_NAME_HEIGHT_DIVISOR = 4;
|
||||
const VALUE_SPACE_PERCENTAGE = 0.7;
|
||||
const SPARKLINE_SPACING = 8;
|
||||
const MIN_VALUE_FONT_SIZE = 1;
|
||||
const MIN_NAME_FONT_SIZE = 10;
|
||||
const MIN_UNIT_FONT_SIZE = 6;
|
||||
export function RadialText({
|
||||
displayValue,
|
||||
theme,
|
||||
dimensions,
|
||||
textMode,
|
||||
shape,
|
||||
sparkline,
|
||||
alignmentFactors,
|
||||
valueManualFontSize,
|
||||
nameManualFontSize,
|
||||
}: RadialTextProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const { centerX, centerY, radius, barWidth } = dimensions;
|
||||
|
||||
export const RadialText = memo(
|
||||
({
|
||||
displayValue,
|
||||
theme,
|
||||
dimensions,
|
||||
textMode,
|
||||
shape,
|
||||
sparkline,
|
||||
alignmentFactors,
|
||||
valueManualFontSize,
|
||||
nameManualFontSize,
|
||||
}: RadialTextProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const { centerX, centerY, radius, barWidth } = dimensions;
|
||||
|
||||
if (textMode === 'none') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nameToAlignTo = (alignmentFactors ? alignmentFactors.title : displayValue.title) ?? '';
|
||||
const valueToAlignTo = formattedValueToString(alignmentFactors ? alignmentFactors : displayValue);
|
||||
|
||||
const showValue = textMode === 'value' || textMode === 'value_and_name';
|
||||
const showName = textMode === 'name' || textMode === 'value_and_name';
|
||||
const maxTextWidth = radius * 2 - barWidth - radius / MAX_TEXT_WIDTH_DIVISOR;
|
||||
|
||||
// This pow 0.92 factor is to create a decay so the font size does not become rediculously large for very large panels
|
||||
let maxValueHeight = VALUE_WIDTH_TO_RADIUS_FACTOR * Math.pow(radius, LARGE_RADIUS_SCALING_DECAY);
|
||||
let maxNameHeight = radius / MAX_NAME_HEIGHT_DIVISOR;
|
||||
|
||||
if (showValue && showName) {
|
||||
maxValueHeight = VALUE_WIDTH_TO_RADIUS_FACTOR * Math.pow(radius, LARGE_RADIUS_SCALING_DECAY);
|
||||
maxNameHeight = NAME_TO_HEIGHT_FACTOR * Math.pow(radius, LARGE_RADIUS_SCALING_DECAY);
|
||||
}
|
||||
|
||||
const valueFontSize = Math.max(
|
||||
valueManualFontSize ??
|
||||
calculateFontSize(
|
||||
valueToAlignTo,
|
||||
maxTextWidth,
|
||||
maxValueHeight,
|
||||
LINE_HEIGHT_FACTOR,
|
||||
undefined,
|
||||
theme.typography.body.fontWeight
|
||||
),
|
||||
MIN_VALUE_FONT_SIZE
|
||||
);
|
||||
|
||||
const nameFontSize = Math.max(
|
||||
nameManualFontSize ??
|
||||
calculateFontSize(
|
||||
nameToAlignTo,
|
||||
maxTextWidth,
|
||||
maxNameHeight,
|
||||
LINE_HEIGHT_FACTOR,
|
||||
undefined,
|
||||
theme.typography.body.fontWeight
|
||||
),
|
||||
MIN_NAME_FONT_SIZE
|
||||
);
|
||||
|
||||
const unitFontSize = Math.max(valueFontSize * VALUE_SPACE_PERCENTAGE, MIN_UNIT_FONT_SIZE);
|
||||
const valueHeight = valueFontSize * LINE_HEIGHT_FACTOR;
|
||||
const nameHeight = nameFontSize * LINE_HEIGHT_FACTOR;
|
||||
|
||||
const valueY = showName ? centerY - nameHeight * (1 - VALUE_SPACE_PERCENTAGE) : centerY;
|
||||
const nameY = showValue ? valueY + valueHeight * VALUE_SPACE_PERCENTAGE : centerY;
|
||||
const nameColor = showValue ? theme.colors.text.secondary : theme.colors.text.primary;
|
||||
const suffixShift = (valueFontSize - unitFontSize * LINE_HEIGHT_FACTOR) / 2;
|
||||
|
||||
// adjust the text up on gauges and when sparklines are present
|
||||
let yOffset = 0;
|
||||
if (shape === 'gauge') {
|
||||
// we render from the center of the gauge, so move up by half of half of the total height
|
||||
yOffset -= (valueHeight + nameHeight) / 4;
|
||||
}
|
||||
if (sparkline) {
|
||||
yOffset -= SPARKLINE_SPACING;
|
||||
}
|
||||
|
||||
return (
|
||||
<g transform={`translate(0, ${yOffset})`}>
|
||||
{showValue && (
|
||||
<text
|
||||
x={centerX}
|
||||
y={valueY}
|
||||
fontSize={valueFontSize}
|
||||
fill={theme.colors.text.primary}
|
||||
className={styles.text}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
<tspan fontSize={unitFontSize}>{displayValue.prefix ?? ''}</tspan>
|
||||
<tspan>{displayValue.text}</tspan>
|
||||
<tspan className={styles.text} fontSize={unitFontSize} dy={suffixShift}>
|
||||
{displayValue.suffix ?? ''}
|
||||
</tspan>
|
||||
</text>
|
||||
)}
|
||||
{showName && (
|
||||
<text
|
||||
fontSize={nameFontSize}
|
||||
x={centerX}
|
||||
y={nameY}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill={nameColor}
|
||||
>
|
||||
{displayValue.title}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
if (textMode === 'none') {
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
RadialText.displayName = 'RadialText';
|
||||
const nameToAlignTo = (alignmentFactors ? alignmentFactors.title : displayValue.title) ?? '';
|
||||
const valueToAlignTo = formattedValueToString(alignmentFactors ? alignmentFactors : displayValue);
|
||||
|
||||
const getStyles = (_theme: GrafanaTheme2) => ({
|
||||
const showValue = textMode === 'value' || textMode === 'value_and_name';
|
||||
const showName = textMode === 'name' || textMode === 'value_and_name';
|
||||
const maxTextWidth = radius * 2 - barWidth - radius / 7;
|
||||
|
||||
// Not sure where this comes from but svg text is not using body line-height
|
||||
const lineHeight = 1.21;
|
||||
const valueWidthToRadiusFactor = 0.82;
|
||||
const nameToHeightFactor = 0.45;
|
||||
const largeRadiusScalingDecay = 0.86;
|
||||
|
||||
// This pow 0.92 factor is to create a decay so the font size does not become rediculously large for very large panels
|
||||
let maxValueHeight = valueWidthToRadiusFactor * Math.pow(radius, largeRadiusScalingDecay);
|
||||
let maxNameHeight = radius / 4;
|
||||
|
||||
if (showValue && showName) {
|
||||
maxValueHeight = valueWidthToRadiusFactor * Math.pow(radius, largeRadiusScalingDecay);
|
||||
maxNameHeight = nameToHeightFactor * Math.pow(radius, largeRadiusScalingDecay);
|
||||
}
|
||||
|
||||
const valueFontSize =
|
||||
valueManualFontSize ??
|
||||
calculateFontSize(
|
||||
valueToAlignTo,
|
||||
maxTextWidth,
|
||||
maxValueHeight,
|
||||
lineHeight,
|
||||
undefined,
|
||||
theme.typography.body.fontWeight
|
||||
);
|
||||
|
||||
const nameFontSize =
|
||||
nameManualFontSize ??
|
||||
calculateFontSize(
|
||||
nameToAlignTo,
|
||||
maxTextWidth,
|
||||
maxNameHeight,
|
||||
lineHeight,
|
||||
undefined,
|
||||
theme.typography.body.fontWeight
|
||||
);
|
||||
|
||||
const unitFontSize = Math.max(valueFontSize * 0.7, 5);
|
||||
const valueHeight = valueFontSize * lineHeight;
|
||||
const nameHeight = nameFontSize * lineHeight;
|
||||
|
||||
const valueY = showName ? centerY - nameHeight * 0.3 : centerY;
|
||||
const nameY = showValue ? valueY + valueHeight * 0.7 : centerY;
|
||||
const nameColor = showValue ? theme.colors.text.secondary : theme.colors.text.primary;
|
||||
const suffixShift = (valueFontSize - unitFontSize * 1.2) / 2;
|
||||
|
||||
// adjust the text up on gauges and when sparklines are present
|
||||
let yOffset = 0;
|
||||
if (shape === 'gauge') {
|
||||
// we render from the center of the gauge, so move up by half of half of the total height
|
||||
yOffset -= (valueHeight + nameHeight) / 4;
|
||||
}
|
||||
if (sparkline) {
|
||||
yOffset -= 8;
|
||||
}
|
||||
|
||||
return (
|
||||
<g transform={`translate(0, ${yOffset})`}>
|
||||
{showValue && (
|
||||
<text
|
||||
x={centerX}
|
||||
y={valueY}
|
||||
fontSize={valueFontSize}
|
||||
fill={theme.colors.text.primary}
|
||||
className={styles.text}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
<tspan fontSize={unitFontSize}>{displayValue.prefix ?? ''}</tspan>
|
||||
<tspan>{displayValue.text}</tspan>
|
||||
<tspan className={styles.text} fontSize={unitFontSize} dy={suffixShift}>
|
||||
{displayValue.suffix ?? ''}
|
||||
</tspan>
|
||||
</text>
|
||||
)}
|
||||
{showName && (
|
||||
<text
|
||||
fontSize={nameFontSize}
|
||||
x={centerX}
|
||||
y={nameY}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill={nameColor}
|
||||
>
|
||||
{displayValue.title}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
text: css({
|
||||
verticalAlign: 'bottom',
|
||||
}),
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
import { FieldDisplay, Threshold } from '@grafana/data';
|
||||
|
||||
import { RadialArcPath } from './RadialArcPath';
|
||||
import { GradientStop, RadialGaugeDimensions, RadialShape } from './types';
|
||||
import { getFieldConfigMinMax } from './utils';
|
||||
import { RadialColorDefs } from './RadialColorDefs';
|
||||
import { GaugeDimensions } from './utils';
|
||||
|
||||
interface ThresholdsBarProps {
|
||||
dimensions: RadialGaugeDimensions;
|
||||
export interface Props {
|
||||
dimensions: GaugeDimensions;
|
||||
angleRange: number;
|
||||
startAngle: number;
|
||||
endAngle: number;
|
||||
shape: RadialShape;
|
||||
fieldDisplay: FieldDisplay;
|
||||
roundedBars?: boolean;
|
||||
glowFilter?: string;
|
||||
colorDefs: RadialColorDefs;
|
||||
thresholds: Threshold[];
|
||||
gradient?: GradientStop[];
|
||||
}
|
||||
|
||||
export function ThresholdsBar({
|
||||
dimensions,
|
||||
fieldDisplay,
|
||||
@@ -24,18 +22,19 @@ export function ThresholdsBar({
|
||||
angleRange,
|
||||
roundedBars,
|
||||
glowFilter,
|
||||
colorDefs,
|
||||
thresholds,
|
||||
shape,
|
||||
gradient,
|
||||
}: ThresholdsBarProps) {
|
||||
}: Props) {
|
||||
const fieldConfig = fieldDisplay.field;
|
||||
const min = fieldConfig.min ?? 0;
|
||||
const max = fieldConfig.max ?? 100;
|
||||
|
||||
const thresholdDimensions = {
|
||||
...dimensions,
|
||||
barWidth: dimensions.thresholdsBarWidth,
|
||||
radius: dimensions.thresholdsBarRadius,
|
||||
};
|
||||
|
||||
const [min, max] = getFieldConfigMinMax(fieldDisplay);
|
||||
|
||||
let currentStart = startAngle;
|
||||
let paths: React.ReactNode[] = [];
|
||||
|
||||
@@ -49,26 +48,27 @@ export function ThresholdsBar({
|
||||
valueDeg = 0;
|
||||
}
|
||||
|
||||
const lengthDeg = valueDeg - currentStart + startAngle;
|
||||
const colorProps = gradient ? { gradient } : { color: threshold.color };
|
||||
let lengthDeg = valueDeg - currentStart + startAngle;
|
||||
|
||||
paths.push(
|
||||
<RadialArcPath
|
||||
key={i}
|
||||
arcLengthDeg={lengthDeg}
|
||||
barEndcaps={shape === 'circle' && roundedBars}
|
||||
dimensions={thresholdDimensions}
|
||||
fieldDisplay={fieldDisplay}
|
||||
glowFilter={glowFilter}
|
||||
roundedBars={roundedBars}
|
||||
shape={shape}
|
||||
startAngle={currentStart}
|
||||
{...colorProps}
|
||||
arcLengthDeg={lengthDeg}
|
||||
dimensions={thresholdDimensions}
|
||||
roundedBars={roundedBars}
|
||||
glowFilter={glowFilter}
|
||||
color={colorDefs.getColor(threshold.color, true)}
|
||||
/>
|
||||
);
|
||||
|
||||
currentStart += lengthDeg;
|
||||
}
|
||||
|
||||
return <g>{paths}</g>;
|
||||
return (
|
||||
<>
|
||||
<g>{paths}</g>
|
||||
<defs>{colorDefs.getDefs()}</defs>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`RadialGauge color utils buildGradientColors should map threshold colors correctly (with baseColor if displayProcessor does not return colors) 1`] = `
|
||||
[
|
||||
{
|
||||
"color": "#444444",
|
||||
"percent": 0,
|
||||
},
|
||||
{
|
||||
"color": "#FADE2A",
|
||||
"percent": 0.5,
|
||||
},
|
||||
{
|
||||
"color": "#F2495C",
|
||||
"percent": 0.8,
|
||||
},
|
||||
{
|
||||
"color": "#444444",
|
||||
"percent": 1,
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`RadialGauge color utils buildGradientColors should map threshold colors correctly (with baseColor if displayProcessor does not return colors) 2`] = `
|
||||
[
|
||||
{
|
||||
"color": "#FF0000",
|
||||
"percent": 0,
|
||||
},
|
||||
{
|
||||
"color": "#FADE2A",
|
||||
"percent": 0.5,
|
||||
},
|
||||
{
|
||||
"color": "#F2495C",
|
||||
"percent": 0.8,
|
||||
},
|
||||
{
|
||||
"color": "#FF0000",
|
||||
"percent": 1,
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`RadialGauge color utils buildGradientColors should return gradient colors for by-value color mode in dark theme 1`] = `
|
||||
[
|
||||
{
|
||||
"color": "#181b1f",
|
||||
"percent": 0,
|
||||
},
|
||||
{
|
||||
"color": "#1F60C4",
|
||||
"percent": 1,
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`RadialGauge color utils buildGradientColors should return gradient colors for by-value color mode in light theme 1`] = `
|
||||
[
|
||||
{
|
||||
"color": "#ffffff",
|
||||
"percent": 0,
|
||||
},
|
||||
{
|
||||
"color": "#1250B0",
|
||||
"percent": 1,
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`RadialGauge color utils buildGradientColors should return gradient colors for continuous color modes 1`] = `
|
||||
[
|
||||
{
|
||||
"color": "rgb(0, 32, 81)",
|
||||
"percent": 0,
|
||||
},
|
||||
{
|
||||
"color": "rgb(17, 54, 108)",
|
||||
"percent": 0.125,
|
||||
},
|
||||
{
|
||||
"color": "rgb(60, 77, 110)",
|
||||
"percent": 0.25,
|
||||
},
|
||||
{
|
||||
"color": "rgb(98, 100, 111)",
|
||||
"percent": 0.375,
|
||||
},
|
||||
{
|
||||
"color": "rgb(127, 124, 117)",
|
||||
"percent": 0.5,
|
||||
},
|
||||
{
|
||||
"color": "rgb(154, 148, 120)",
|
||||
"percent": 0.625,
|
||||
},
|
||||
{
|
||||
"color": "rgb(187, 175, 113)",
|
||||
"percent": 0.75,
|
||||
},
|
||||
{
|
||||
"color": "rgb(226, 203, 92)",
|
||||
"percent": 0.875,
|
||||
},
|
||||
{
|
||||
"color": "rgb(253, 234, 69)",
|
||||
"percent": 1,
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`RadialGauge color utils buildGradientColors should return gradient colors for fixed color mode in dark theme 1`] = `
|
||||
[
|
||||
{
|
||||
"color": "#37237a",
|
||||
"percent": 0,
|
||||
},
|
||||
{
|
||||
"color": "#a146da",
|
||||
"percent": 0.75,
|
||||
},
|
||||
{
|
||||
"color": "#a146da",
|
||||
"percent": 1,
|
||||
},
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`RadialGauge color utils buildGradientColors should return gradient colors for fixed color mode in light theme 1`] = `
|
||||
[
|
||||
{
|
||||
"color": "#a146da",
|
||||
"percent": 0,
|
||||
},
|
||||
{
|
||||
"color": "#3e2b9a",
|
||||
"percent": 0.75,
|
||||
},
|
||||
{
|
||||
"color": "#3e2b9a",
|
||||
"percent": 1,
|
||||
},
|
||||
]
|
||||
`;
|
||||
@@ -1,17 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`RadialGauge utils drawRadialArcPath should draw correct path for center x and y 1`] = `"M 150 110 A 90 90 0 1 1 149.98429203681178 110.00000137077838 A 10 10 0 0 1 149.98778269529805 130.00000106616096 A 70 70 0 1 0 150 130 A 10 10 0 0 1 150 110 Z"`;
|
||||
|
||||
exports[`RadialGauge utils drawRadialArcPath should draw correct path for half arc 1`] = `"M 100 10 A 90 90 0 0 1 100 190 L 100 170 A 70 70 0 0 0 100 30 L 100 10 Z"`;
|
||||
|
||||
exports[`RadialGauge utils drawRadialArcPath should draw correct path for narrow bar width 1`] = `"M 100 17.5 A 82.5 82.5 0 0 1 100 182.5 L 100 177.5 A 77.5 77.5 0 0 0 100 22.5 L 100 17.5 Z"`;
|
||||
|
||||
exports[`RadialGauge utils drawRadialArcPath should draw correct path for narrow radius 1`] = `"M 100 40 A 60 60 0 0 1 100 160 L 100 140 A 40 40 0 0 0 100 60 L 100 40 Z"`;
|
||||
|
||||
exports[`RadialGauge utils drawRadialArcPath should draw correct path for quarter arc 1`] = `"M 100 10 A 90 90 0 0 1 190 100 L 170 100 A 70 70 0 0 0 100 30 L 100 10 Z"`;
|
||||
|
||||
exports[`RadialGauge utils drawRadialArcPath should draw correct path for rounded bars 1`] = `"M 100 10 A 90 90 0 1 1 10 100.00000000000001 A 10 10 0 0 1 30 100.00000000000001 A 70 70 0 1 0 100 30 A 10 10 0 0 1 100 10 Z"`;
|
||||
|
||||
exports[`RadialGauge utils drawRadialArcPath should draw correct path for three quarter arc 1`] = `"M 100 10 A 90 90 0 1 1 10 100.00000000000001 L 30 100.00000000000001 A 70 70 0 1 0 100 30 L 100 10 Z"`;
|
||||
|
||||
exports[`RadialGauge utils drawRadialArcPath should draw correct path for wide bar width 1`] = `"M 100 -5 A 105 105 0 0 1 100 205 L 100 155 A 55 55 0 0 0 100 45 L 100 -5 Z"`;
|
||||
@@ -1,306 +0,0 @@
|
||||
import { defaultsDeep } from 'lodash';
|
||||
|
||||
import { createTheme, FALLBACK_COLOR, Field, FieldDisplay, FieldType, ThresholdsMode } from '@grafana/data';
|
||||
import { FieldColorModeId } from '@grafana/schema';
|
||||
|
||||
import {
|
||||
buildGradientColors,
|
||||
colorAtGradientPercent,
|
||||
getBarEndcapColors,
|
||||
getEndpointMarkerColors,
|
||||
getGradientCss,
|
||||
getGradientStopsForPercent,
|
||||
} from './colors';
|
||||
|
||||
export type DeepPartial<T> = {
|
||||
[P in keyof T]?: DeepPartial<T[P]>;
|
||||
};
|
||||
|
||||
describe('RadialGauge color utils', () => {
|
||||
describe('buildGradientColors', () => {
|
||||
const createField = (colorMode: FieldColorModeId): Field =>
|
||||
({
|
||||
type: FieldType.number,
|
||||
name: 'Test Field',
|
||||
config: {
|
||||
color: {
|
||||
mode: colorMode,
|
||||
},
|
||||
thresholds: {
|
||||
mode: ThresholdsMode.Absolute,
|
||||
steps: [
|
||||
{ value: -Infinity, color: 'green' },
|
||||
{ value: 50, color: 'yellow' },
|
||||
{ value: 80, color: 'red' },
|
||||
],
|
||||
},
|
||||
},
|
||||
values: [70, 40, 30, 90, 55],
|
||||
}) satisfies Field;
|
||||
|
||||
const buildFieldDisplay = (field: Field, part = {}): FieldDisplay =>
|
||||
defaultsDeep(part, {
|
||||
field: field.config,
|
||||
colIndex: 0,
|
||||
view: {
|
||||
getFieldDisplayProcessor: jest.fn(() => jest.fn(() => ({ color: undefined }))),
|
||||
},
|
||||
display: {
|
||||
numeric: 75,
|
||||
},
|
||||
});
|
||||
|
||||
it('should return the baseColor if gradient is false-y', () => {
|
||||
expect(
|
||||
buildGradientColors(false, createTheme(), buildFieldDisplay(createField(FieldColorModeId.Fixed)), '#FF0000')
|
||||
).toEqual([
|
||||
{ color: '#FF0000', percent: 0 },
|
||||
{ color: '#FF0000', percent: 1 },
|
||||
]);
|
||||
|
||||
expect(
|
||||
buildGradientColors(undefined, createTheme(), buildFieldDisplay(createField(FieldColorModeId.Fixed)), '#FF0000')
|
||||
).toEqual([
|
||||
{ color: '#FF0000', percent: 0 },
|
||||
{ color: '#FF0000', percent: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the fallback color if no baseColor is set', () => {
|
||||
expect(buildGradientColors(false, createTheme(), buildFieldDisplay(createField(FieldColorModeId.Fixed)))).toEqual(
|
||||
[
|
||||
{ color: FALLBACK_COLOR, percent: 0 },
|
||||
{ color: FALLBACK_COLOR, percent: 1 },
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it('should map threshold colors correctly (with baseColor if displayProcessor does not return colors)', () => {
|
||||
expect(
|
||||
buildGradientColors(
|
||||
true,
|
||||
createTheme(),
|
||||
buildFieldDisplay(createField(FieldColorModeId.Thresholds), {
|
||||
view: { getFieldDisplayProcessor: jest.fn(() => jest.fn(() => ({ color: '#444444' }))) },
|
||||
})
|
||||
)
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should map threshold colors correctly (with baseColor if displayProcessor does not return colors)', () => {
|
||||
expect(
|
||||
buildGradientColors(true, createTheme(), buildFieldDisplay(createField(FieldColorModeId.Thresholds)), '#FF0000')
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should return gradient colors for continuous color modes', () => {
|
||||
expect(
|
||||
buildGradientColors(
|
||||
true,
|
||||
createTheme(),
|
||||
buildFieldDisplay(createField(FieldColorModeId.ContinuousCividis)),
|
||||
'#00FF00'
|
||||
)
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it.each(['dark', 'light'] as const)('should return gradient colors for by-value color mode in %s theme', (mode) => {
|
||||
expect(
|
||||
buildGradientColors(
|
||||
true,
|
||||
createTheme({ colors: { mode } }),
|
||||
buildFieldDisplay(createField(FieldColorModeId.ContinuousBlues))
|
||||
)
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it.each(['dark', 'light'] as const)('should return gradient colors for fixed color mode in %s theme', (mode) => {
|
||||
expect(
|
||||
buildGradientColors(
|
||||
true,
|
||||
createTheme({ colors: { mode } }),
|
||||
buildFieldDisplay(createField(FieldColorModeId.Fixed)),
|
||||
'#442299'
|
||||
)
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('colorAtGradientPercent', () => {
|
||||
it('should calculate the color at a given percent in a gradient of two colors', () => {
|
||||
const gradient = [
|
||||
{ color: '#ff0000', percent: 0 },
|
||||
{ color: '#0000ff', percent: 1 },
|
||||
];
|
||||
expect(colorAtGradientPercent(gradient, 0).toHexString()).toBe('#ff0000');
|
||||
expect(colorAtGradientPercent(gradient, 0.25).toHexString()).toBe('#bf0040');
|
||||
expect(colorAtGradientPercent(gradient, 0.5).toHexString()).toBe('#800080');
|
||||
expect(colorAtGradientPercent(gradient, 0.75).toHexString()).toBe('#4000bf');
|
||||
expect(colorAtGradientPercent(gradient, 1).toHexString()).toBe('#0000ff');
|
||||
});
|
||||
|
||||
it('should calculate the color at a given percent in a gradient of multiple colors', () => {
|
||||
const gradient = [
|
||||
{ color: '#ff0000', percent: 0 },
|
||||
{ color: '#00ff00', percent: 0.5 },
|
||||
{ color: '#0000ff', percent: 1 },
|
||||
];
|
||||
expect(colorAtGradientPercent(gradient, 0).toHexString()).toBe('#ff0000');
|
||||
expect(colorAtGradientPercent(gradient, 0.25).toHexString()).toBe('#808000');
|
||||
expect(colorAtGradientPercent(gradient, 0.5).toHexString()).toBe('#00ff00');
|
||||
expect(colorAtGradientPercent(gradient, 0.75).toHexString()).toBe('#008080');
|
||||
expect(colorAtGradientPercent(gradient, 1).toHexString()).toBe('#0000ff');
|
||||
});
|
||||
|
||||
it('will still work if unsorted', () => {
|
||||
const gradient = [
|
||||
{ color: '#0000ff', percent: 1 },
|
||||
{ color: '#00ff00', percent: 0.5 },
|
||||
{ color: '#ff0000', percent: 0 },
|
||||
];
|
||||
expect(colorAtGradientPercent(gradient, 0).toHexString()).toBe('#ff0000');
|
||||
expect(colorAtGradientPercent(gradient, 0.25).toHexString()).toBe('#808000');
|
||||
expect(colorAtGradientPercent(gradient, 0.5).toHexString()).toBe('#00ff00');
|
||||
expect(colorAtGradientPercent(gradient, 0.75).toHexString()).toBe('#008080');
|
||||
expect(colorAtGradientPercent(gradient, 1).toHexString()).toBe('#0000ff');
|
||||
});
|
||||
|
||||
it('should not throw an error when percent is outside 0-1 range', () => {
|
||||
const gradient = [
|
||||
{ color: '#ff0000', percent: 0 },
|
||||
{ color: '#0000ff', percent: 1 },
|
||||
];
|
||||
expect(colorAtGradientPercent(gradient, -0.5).toHexString()).toBe('#ff0000');
|
||||
expect(colorAtGradientPercent(gradient, 1.5).toHexString()).toBe('#0000ff');
|
||||
});
|
||||
|
||||
it('should throw an error when less than two stops are provided', () => {
|
||||
expect(() => {
|
||||
colorAtGradientPercent([], 0.5);
|
||||
}).toThrow('colorAtGradientPercent requires at least two color stops');
|
||||
expect(() => {
|
||||
colorAtGradientPercent([{ color: '#ff0000', percent: 0 }], 0.5);
|
||||
}).toThrow('colorAtGradientPercent requires at least two color stops');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBarEndcapColors', () => {
|
||||
it('should return the first and last colors in the gradient', () => {
|
||||
const gradient = [
|
||||
{ color: '#ff0000', percent: 0 },
|
||||
{ color: '#00ff00', percent: 0.5 },
|
||||
{ color: '#0000ff', percent: 1 },
|
||||
];
|
||||
const [startColor, endColor] = getBarEndcapColors(gradient);
|
||||
expect(startColor).toBe('#ff0000');
|
||||
expect(endColor).toBe('#0000ff');
|
||||
});
|
||||
|
||||
it('should return the correct end color based on percent', () => {
|
||||
const gradient = [
|
||||
{ color: '#ff0000', percent: 0 },
|
||||
{ color: '#00ff00', percent: 0.5 },
|
||||
{ color: '#0000ff', percent: 1 },
|
||||
];
|
||||
const [startColor, endColor] = getBarEndcapColors(gradient, 0.25);
|
||||
expect(startColor).toBe('#ff0000');
|
||||
expect(endColor).toBe('#808000');
|
||||
});
|
||||
|
||||
it('should handle gradients with only one colors', () => {
|
||||
const gradient = [{ color: '#ff0000', percent: 0 }];
|
||||
const [startColor, endColor] = getBarEndcapColors(gradient);
|
||||
expect(startColor).toBe('#ff0000');
|
||||
expect(endColor).toBe('#ff0000');
|
||||
});
|
||||
|
||||
it('should throw an error when no colors are provided', () => {
|
||||
expect(() => {
|
||||
getBarEndcapColors([]);
|
||||
}).toThrow('getBarEndcapColors requires at least one color stop');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGradientCss', () => {
|
||||
it('should return conic-gradient CSS for circle shape', () => {
|
||||
const gradient = [
|
||||
{ color: '#ff0000', percent: 0 },
|
||||
{ color: '#00ff00', percent: 0.5 },
|
||||
{ color: '#0000ff', percent: 1 },
|
||||
];
|
||||
const css = getGradientCss(gradient, 'circle');
|
||||
expect(css).toBe('conic-gradient(from 0deg, #ff0000 0.00%, #00ff00 50.00%, #0000ff 100.00%)');
|
||||
});
|
||||
|
||||
it('should return linear-gradient CSS for arc shape', () => {
|
||||
const gradient = [
|
||||
{ color: '#ff0000', percent: 0 },
|
||||
{ color: '#00ff00', percent: 0.5 },
|
||||
{ color: '#0000ff', percent: 1 },
|
||||
];
|
||||
const css = getGradientCss(gradient, 'gauge');
|
||||
expect(css).toBe('linear-gradient(90deg, #ff0000 0.00%, #00ff00 50.00%, #0000ff 100.00%)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEndpointMarkerColors', () => {
|
||||
it('should return contrasting guide dot colors based on the gradient endpoints and percent', () => {
|
||||
const gradient = [
|
||||
{ color: '#000000', percent: 0 },
|
||||
{ color: '#ffffff', percent: 0.5 },
|
||||
{ color: '#ffffff', percent: 1 },
|
||||
];
|
||||
const [startDotColor, endDotColor] = getEndpointMarkerColors(gradient, 0.35);
|
||||
expect(startDotColor).toBe('#fbfbfb');
|
||||
expect(endDotColor).toBe('#111217');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGradientStopsForPercent', () => {
|
||||
it('should return the correct gradient stops for a given percent', () => {
|
||||
const gradient = [
|
||||
{ color: '#ff0000', percent: 0 },
|
||||
{ color: '#00ff00', percent: 0.5 },
|
||||
{ color: '#0000ff', percent: 1 },
|
||||
];
|
||||
const [left, right] = getGradientStopsForPercent(gradient, 0.25);
|
||||
expect(left).toEqual({ color: '#ff0000', percent: 0 });
|
||||
expect(right).toEqual({ color: '#00ff00', percent: 0.5 });
|
||||
});
|
||||
|
||||
it('should handle edge cases where percent is at the boundaries', () => {
|
||||
const gradient = [
|
||||
{ color: '#ff0000', percent: 0 },
|
||||
{ color: '#00ff00', percent: 0.5 },
|
||||
{ color: '#0000ff', percent: 1 },
|
||||
];
|
||||
let [left, right] = getGradientStopsForPercent(gradient, 0);
|
||||
expect(left).toEqual({ color: '#ff0000', percent: 0 });
|
||||
expect(right).toEqual({ color: '#ff0000', percent: 0 });
|
||||
|
||||
[left, right] = getGradientStopsForPercent(gradient, 1);
|
||||
expect(left).toEqual({ color: '#0000ff', percent: 1 });
|
||||
expect(right).toEqual({ color: '#0000ff', percent: 1 });
|
||||
});
|
||||
|
||||
it('should return the same stop if there is one that is equal to the percentage', () => {
|
||||
const gradient = [
|
||||
{ color: '#ff0000', percent: 0 },
|
||||
{ color: '#00ff00', percent: 0.5 },
|
||||
{ color: '#0000ff', percent: 1 },
|
||||
];
|
||||
|
||||
let [left, right] = getGradientStopsForPercent(gradient, 0);
|
||||
expect(left).toEqual({ color: '#ff0000', percent: 0 });
|
||||
expect(right).toEqual({ color: '#ff0000', percent: 0 });
|
||||
|
||||
[left, right] = getGradientStopsForPercent(gradient, 0.5);
|
||||
expect(left).toEqual({ color: '#00ff00', percent: 0.5 });
|
||||
expect(right).toEqual({ color: '#00ff00', percent: 0.5 });
|
||||
|
||||
[left, right] = getGradientStopsForPercent(gradient, 1);
|
||||
expect(left).toEqual({ color: '#0000ff', percent: 1 });
|
||||
expect(right).toEqual({ color: '#0000ff', percent: 1 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,195 +0,0 @@
|
||||
import tinycolor from 'tinycolor2';
|
||||
|
||||
import { colorManipulator, FALLBACK_COLOR, FieldDisplay, getFieldColorMode, GrafanaTheme2 } from '@grafana/data';
|
||||
import { FieldColorModeId } from '@grafana/schema';
|
||||
|
||||
import { GradientStop, RadialShape } from './types';
|
||||
import { getFieldConfigMinMax, getFieldDisplayProcessor, getValuePercentageForValue } from './utils';
|
||||
|
||||
export function buildGradientColors(
|
||||
gradient = false,
|
||||
theme: GrafanaTheme2,
|
||||
fieldDisplay: FieldDisplay,
|
||||
baseColor = fieldDisplay.display.color ?? FALLBACK_COLOR
|
||||
): GradientStop[] {
|
||||
if (!gradient) {
|
||||
return [
|
||||
{ color: baseColor, percent: 0 },
|
||||
{ color: baseColor, percent: 1 },
|
||||
];
|
||||
}
|
||||
|
||||
const colorMode = getFieldColorMode(fieldDisplay.field.color?.mode);
|
||||
|
||||
// thresholds get special handling
|
||||
if (colorMode.id === FieldColorModeId.Thresholds) {
|
||||
const displayProcessor = getFieldDisplayProcessor(fieldDisplay);
|
||||
const [min, max] = getFieldConfigMinMax(fieldDisplay);
|
||||
const thresholds = fieldDisplay.field.thresholds?.steps ?? [];
|
||||
|
||||
const result: Array<{ color: string; percent: number }> = [
|
||||
{ color: displayProcessor(min).color ?? baseColor, percent: 0 },
|
||||
];
|
||||
|
||||
for (const threshold of thresholds) {
|
||||
if (threshold.value > min && threshold.value < max) {
|
||||
const percent = (threshold.value - min) / (max - min);
|
||||
result.push({ color: theme.visualization.getColorByName(threshold.color), percent });
|
||||
}
|
||||
}
|
||||
|
||||
result.push({ color: displayProcessor(max).color ?? baseColor, percent: 1 });
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Handle continuous color modes before other by-value modes
|
||||
if (colorMode.isContinuous && colorMode.getColors) {
|
||||
const colors = colorMode.getColors(theme);
|
||||
return colors.map((color, idx) => ({ color, percent: idx / (colors.length - 1) }));
|
||||
}
|
||||
|
||||
// For value-based colors, we want to stay more true to the specific color,
|
||||
// so a radial gradient that adds a bit of light and shade works best
|
||||
if (colorMode.isByValue) {
|
||||
const darkerColor = tinycolor(baseColor).darken(5);
|
||||
const lighterColor = tinycolor(baseColor).spin(20).lighten(10);
|
||||
|
||||
const color1 = theme.isDark ? lighterColor : darkerColor;
|
||||
const color2 = theme.isDark ? darkerColor : lighterColor;
|
||||
|
||||
return [
|
||||
{ color: color1.toString(), percent: 0 },
|
||||
{ color: color2.toString(), percent: 0.6 },
|
||||
{ color: color2.toString(), percent: 1 },
|
||||
];
|
||||
}
|
||||
|
||||
// For fixed / palette based color scales we can create a more hue and light
|
||||
// based linear gradient that we rotate with the value
|
||||
const darkerColor = tinycolor(baseColor)
|
||||
.spin(-20)
|
||||
.darken(theme.isDark ? 15 : 5);
|
||||
const lighterColor = tinycolor(baseColor).saturate(20).spin(20).brighten(10).lighten(10);
|
||||
|
||||
const underlyingGradient = [
|
||||
{ color: theme.isDark ? darkerColor.toString() : lighterColor.toString(), percent: 0 },
|
||||
{ color: theme.isDark ? lighterColor.toString() : darkerColor.toString(), percent: 1 },
|
||||
];
|
||||
|
||||
// rotate the gradient so that the highest contrasting point is the value, depending on theme.
|
||||
const valuePercent = getValuePercentageForValue(fieldDisplay);
|
||||
const startColor = theme.isDark
|
||||
? colorAtGradientPercent(underlyingGradient, 1 - valuePercent).toHexString()
|
||||
: underlyingGradient[0].color;
|
||||
const endColor = theme.isDark
|
||||
? underlyingGradient[1].color
|
||||
: colorAtGradientPercent(underlyingGradient, valuePercent).toHexString();
|
||||
return [
|
||||
{ color: startColor, percent: 0 },
|
||||
{ color: endColor, percent: valuePercent },
|
||||
{ color: endColor, percent: 1 },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* get the relevant gradient stops surrounding a given percentage. could be same stop if the
|
||||
* percent matches a stop exactly.
|
||||
*
|
||||
* @param sortedGradientStops - gradient stops sorted by percent
|
||||
* @param percent - percentage 0..1
|
||||
* @returns {[GradientStop, GradientStop]} - the two gradient stops surrounding the given percentage
|
||||
*/
|
||||
export function getGradientStopsForPercent(
|
||||
sortedGradientStops: GradientStop[],
|
||||
percent: number
|
||||
): [GradientStop, GradientStop] {
|
||||
if (percent <= 0) {
|
||||
return [sortedGradientStops[0], sortedGradientStops[0]];
|
||||
}
|
||||
if (percent >= 1) {
|
||||
const last = sortedGradientStops.length - 1;
|
||||
return [sortedGradientStops[last], sortedGradientStops[last]];
|
||||
}
|
||||
|
||||
// find surrounding stops using binary search
|
||||
let lo = 0;
|
||||
let hi = sortedGradientStops.length - 1;
|
||||
while (lo + 1 < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (percent === sortedGradientStops[mid].percent) {
|
||||
return [sortedGradientStops[mid], sortedGradientStops[mid]];
|
||||
}
|
||||
|
||||
if (percent < sortedGradientStops[mid].percent) {
|
||||
hi = mid;
|
||||
} else {
|
||||
lo = mid;
|
||||
}
|
||||
}
|
||||
return [sortedGradientStops[lo], sortedGradientStops[hi]];
|
||||
}
|
||||
|
||||
/**
|
||||
* @alpha - perhaps this should go in colorManipulator.ts
|
||||
* Given color stops (each with a color and percentage 0..1) returns the color at a given percentage.
|
||||
* Uses tinycolor.mix for interpolation.
|
||||
* @params stops - array of color stops (percentages 0..1)
|
||||
* @params percent - percentage 0..1
|
||||
* @returns color at the given percentage
|
||||
*/
|
||||
export function colorAtGradientPercent(stops: GradientStop[], percent: number): tinycolor.Instance {
|
||||
if (!stops || stops.length < 2) {
|
||||
throw new Error('colorAtGradientPercent requires at least two color stops');
|
||||
}
|
||||
|
||||
const sorted = stops
|
||||
.map((s: GradientStop): GradientStop => ({ color: s.color, percent: Math.min(Math.max(0, s.percent), 1) }))
|
||||
.sort((a: GradientStop, b: GradientStop) => a.percent - b.percent);
|
||||
|
||||
const [left, right] = getGradientStopsForPercent(sorted, percent);
|
||||
const range = right.percent - left.percent;
|
||||
const t = range === 0 ? 0 : (percent - left.percent) / range; // 0..1
|
||||
return tinycolor.mix(left.color, right.color, t * 100);
|
||||
}
|
||||
|
||||
export function getBarEndcapColors(gradientStops: GradientStop[], percent = 1): [string, string] {
|
||||
if (gradientStops.length === 0) {
|
||||
throw new Error('getBarEndcapColors requires at least one color stop');
|
||||
}
|
||||
|
||||
const startColor = gradientStops[0].color;
|
||||
let endColor = gradientStops[gradientStops.length - 1].color;
|
||||
|
||||
// if we have a percentageFilled, use it to get a the correct end color based on where the bar terminates
|
||||
if (gradientStops.length >= 2) {
|
||||
const endColorByPercentage = colorAtGradientPercent(gradientStops, percent);
|
||||
endColor =
|
||||
endColorByPercentage.getAlpha() === 1 ? endColorByPercentage.toHexString() : endColorByPercentage.toHex8String();
|
||||
}
|
||||
return [startColor, endColor];
|
||||
}
|
||||
|
||||
export function getGradientCss(gradientStops: GradientStop[], shape: RadialShape): string {
|
||||
const colorStrings = gradientStops.map((stop) => `${stop.color} ${(stop.percent * 100).toFixed(2)}%`);
|
||||
if (shape === 'circle') {
|
||||
return `conic-gradient(from 0deg, ${colorStrings.join(', ')})`;
|
||||
}
|
||||
return `linear-gradient(90deg, ${colorStrings.join(', ')})`;
|
||||
}
|
||||
|
||||
// the theme does not make the full palette available to us, and we
|
||||
// don't want transparent colors which our grays usually have.
|
||||
const GRAY_05 = '#111217';
|
||||
const GRAY_90 = '#fbfbfb';
|
||||
const CONTRAST_THRESHOLD_MAX = 4.5;
|
||||
const getGuideDotColor = (color: string): string => {
|
||||
const darkColor = GRAY_05;
|
||||
const lightColor = GRAY_90;
|
||||
return colorManipulator.getContrastRatio(darkColor, color) >= CONTRAST_THRESHOLD_MAX ? darkColor : lightColor;
|
||||
};
|
||||
|
||||
export function getEndpointMarkerColors(gradientStops: GradientStop[], percent = 1): [string, string] {
|
||||
const [startColor, endColor] = getBarEndcapColors(gradientStops, percent);
|
||||
return [getGuideDotColor(startColor), getGuideDotColor(endColor)];
|
||||
}
|
||||
@@ -1,18 +1,15 @@
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
|
||||
import { RadialGaugeDimensions } from './types';
|
||||
import { GaugeDimensions } from './utils';
|
||||
|
||||
export interface GlowGradientProps {
|
||||
id: string;
|
||||
barWidth: number;
|
||||
}
|
||||
|
||||
const MIN_GLOW_SIZE = 0.75;
|
||||
const GLOW_FACTOR = 0.08;
|
||||
|
||||
export function GlowGradient({ id, barWidth }: GlowGradientProps) {
|
||||
// 0.75 is the minimum glow size, and it scales with bar width
|
||||
const glowSize = MIN_GLOW_SIZE + barWidth * GLOW_FACTOR;
|
||||
const glowSize = 0.75 + barWidth * 0.08;
|
||||
|
||||
return (
|
||||
<filter id={id} filterUnits="userSpaceOnUse">
|
||||
@@ -25,19 +22,56 @@ export function GlowGradient({ id, barWidth }: GlowGradientProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const CENTER_GLOW_OPACITY = 0.15;
|
||||
export function SpotlightGradient({
|
||||
id,
|
||||
dimensions,
|
||||
roundedBars,
|
||||
angle,
|
||||
theme,
|
||||
}: {
|
||||
id: string;
|
||||
dimensions: GaugeDimensions;
|
||||
angle: number;
|
||||
roundedBars: boolean;
|
||||
theme: GrafanaTheme2;
|
||||
}) {
|
||||
const angleRadian = ((angle - 90) * Math.PI) / 180;
|
||||
|
||||
let x1 = dimensions.centerX + dimensions.radius * Math.cos(angleRadian - 0.2);
|
||||
let y1 = dimensions.centerY + dimensions.radius * Math.sin(angleRadian - 0.2);
|
||||
let x2 = dimensions.centerX + dimensions.radius * Math.cos(angleRadian);
|
||||
let y2 = dimensions.centerY + dimensions.radius * Math.sin(angleRadian);
|
||||
|
||||
if (theme.isLight) {
|
||||
return (
|
||||
<linearGradient x1={x1} y1={y1} x2={x2} y2={y2} id={id} gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stopColor={'black'} stopOpacity={0.0} />
|
||||
<stop offset="90%" stopColor={'black'} stopOpacity={0.0} />
|
||||
<stop offset="91%" stopColor={'black'} stopOpacity={1} />
|
||||
</linearGradient>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<linearGradient x1={x1} y1={y1} x2={x2} y2={y2} id={id} gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stopColor={'white'} stopOpacity={0.0} />
|
||||
<stop offset="95%" stopColor={'white'} stopOpacity={0.5} />
|
||||
{roundedBars && <stop offset="100%" stopColor={'white'} stopOpacity={roundedBars ? 0.7 : 1} />}
|
||||
</linearGradient>
|
||||
);
|
||||
}
|
||||
|
||||
export function CenterGlowGradient({ gaugeId, color }: { gaugeId: string; color: string }) {
|
||||
return (
|
||||
<radialGradient id={`circle-glow-${gaugeId}`} r="50%" fr="0%">
|
||||
<stop offset="0%" stopColor={color} stopOpacity={CENTER_GLOW_OPACITY} />
|
||||
<radialGradient id={`circle-glow-${gaugeId}`} r={'50%'} fr={'0%'}>
|
||||
<stop offset="0%" stopColor={color} stopOpacity={0.2} />
|
||||
<stop offset="90%" stopColor={color} stopOpacity={0} />
|
||||
</radialGradient>
|
||||
);
|
||||
}
|
||||
|
||||
export interface CenterGlowProps {
|
||||
dimensions: RadialGaugeDimensions;
|
||||
dimensions: GaugeDimensions;
|
||||
gaugeId: string;
|
||||
color?: string;
|
||||
}
|
||||
@@ -48,8 +82,8 @@ export function MiddleCircleGlow({ dimensions, gaugeId, color }: CenterGlowProps
|
||||
return (
|
||||
<>
|
||||
<defs>
|
||||
<radialGradient id={gradientId} r="50%" fr="0%">
|
||||
<stop offset="0%" stopColor={color} stopOpacity={CENTER_GLOW_OPACITY} />
|
||||
<radialGradient id={gradientId} r={'50%'} fr={'0%'}>
|
||||
<stop offset="0%" stopColor={color} stopOpacity={0.15} />
|
||||
<stop offset="90%" stopColor={color} stopOpacity={0} />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
@@ -59,36 +93,3 @@ export function MiddleCircleGlow({ dimensions, gaugeId, color }: CenterGlowProps
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpotlightGradient({
|
||||
id,
|
||||
dimensions,
|
||||
roundedBars,
|
||||
angle,
|
||||
theme,
|
||||
}: {
|
||||
id: string;
|
||||
dimensions: RadialGaugeDimensions;
|
||||
angle: number;
|
||||
roundedBars: boolean;
|
||||
theme: GrafanaTheme2;
|
||||
}) {
|
||||
if (theme.isLight) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const angleRadian = ((angle - 90) * Math.PI) / 180;
|
||||
|
||||
let x1 = dimensions.centerX + dimensions.radius * Math.cos(angleRadian - 0.2);
|
||||
let y1 = dimensions.centerY + dimensions.radius * Math.sin(angleRadian - 0.2);
|
||||
let x2 = dimensions.centerX + dimensions.radius * Math.cos(angleRadian);
|
||||
let y2 = dimensions.centerY + dimensions.radius * Math.sin(angleRadian);
|
||||
|
||||
return (
|
||||
<linearGradient x1={x1} y1={y1} x2={x2} y2={y2} id={id} gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stopColor={'white'} stopOpacity={0.0} />
|
||||
<stop offset="95%" stopColor={'white'} stopOpacity={0.5} />
|
||||
{roundedBars && <stop offset="100%" stopColor={'white'} stopOpacity={roundedBars ? 0.7 : 1} />}
|
||||
</linearGradient>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
export type RadialTextMode = 'auto' | 'value_and_name' | 'value' | 'name' | 'none';
|
||||
export type RadialShape = 'circle' | 'gauge';
|
||||
|
||||
export interface RadialGaugeDimensions {
|
||||
margin: number;
|
||||
radius: number;
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
barWidth: number;
|
||||
endAngle?: number;
|
||||
barIndex: number;
|
||||
thresholdsBarRadius: number;
|
||||
thresholdsBarWidth: number;
|
||||
thresholdsBarSpacing: number;
|
||||
scaleLabelsFontSize: number;
|
||||
scaleLabelsSpacing: number;
|
||||
scaleLabelsRadius: number;
|
||||
gaugeBottomY: number;
|
||||
}
|
||||
|
||||
/** @alpha - perhaps this should go in @grafana/data */
|
||||
export interface GradientStop {
|
||||
color: string;
|
||||
percent: number;
|
||||
}
|
||||
@@ -1,111 +1,24 @@
|
||||
import { DataFrameView, FieldDisplay } from '@grafana/data';
|
||||
import { FieldDisplay } from '@grafana/data';
|
||||
|
||||
import type { RadialGaugeProps } from './RadialGauge';
|
||||
import { RadialGaugeDimensions } from './types';
|
||||
import {
|
||||
calculateDimensions,
|
||||
toRad,
|
||||
getValueAngleForValue,
|
||||
drawRadialArcPath,
|
||||
getFieldConfigMinMax,
|
||||
getFieldDisplayProcessor,
|
||||
getAngleBetweenSegments,
|
||||
getOptimalSegmentCount,
|
||||
} from './utils';
|
||||
import { calculateDimensions, toRad, getValueAngleForValue } from './utils';
|
||||
|
||||
describe('RadialGauge utils', () => {
|
||||
describe('getFieldDisplayProcessor', () => {
|
||||
it('should return display processor from view when available', () => {
|
||||
const mockProcessor = jest.fn();
|
||||
const mockView = {
|
||||
getFieldDisplayProcessor: jest.fn().mockReturnValue(mockProcessor),
|
||||
} as unknown as DataFrameView;
|
||||
|
||||
const fieldDisplay: FieldDisplay = {
|
||||
display: { numeric: 50, text: '50', color: 'blue' },
|
||||
field: {},
|
||||
view: mockView,
|
||||
colIndex: 0,
|
||||
rowIndex: 0,
|
||||
name: 'test',
|
||||
getLinks: () => [],
|
||||
hasLinks: false,
|
||||
};
|
||||
|
||||
const dp = getFieldDisplayProcessor(fieldDisplay);
|
||||
expect(dp).toBe(mockProcessor);
|
||||
expect(mockView.getFieldDisplayProcessor).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it('should return default display processor when view is not available', () => {
|
||||
const fieldDisplay: FieldDisplay = {
|
||||
display: { numeric: 50, text: '50', color: 'blue' },
|
||||
field: {},
|
||||
view: undefined,
|
||||
colIndex: 0,
|
||||
rowIndex: 0,
|
||||
name: 'test',
|
||||
getLinks: () => [],
|
||||
hasLinks: false,
|
||||
};
|
||||
|
||||
const dp = getFieldDisplayProcessor(fieldDisplay);
|
||||
expect(dp).toBeDefined();
|
||||
expect(typeof dp).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFieldConfigMinMax', () => {
|
||||
it('should return min and max from field config when defined', () => {
|
||||
const fieldDisplay: FieldDisplay = {
|
||||
display: { numeric: 50, text: '50', color: 'blue' },
|
||||
field: { min: 10, max: 90 },
|
||||
view: undefined,
|
||||
colIndex: 0,
|
||||
rowIndex: 0,
|
||||
name: 'test',
|
||||
getLinks: () => [],
|
||||
hasLinks: false,
|
||||
};
|
||||
|
||||
const [min, max] = getFieldConfigMinMax(fieldDisplay);
|
||||
expect(min).toBe(10);
|
||||
expect(max).toBe(90);
|
||||
});
|
||||
|
||||
it('should return default min and max when not defined in field config', () => {
|
||||
const fieldDisplay: FieldDisplay = {
|
||||
display: { numeric: 50, text: '50', color: 'blue' },
|
||||
field: {},
|
||||
view: undefined,
|
||||
colIndex: 0,
|
||||
rowIndex: 0,
|
||||
name: 'test',
|
||||
getLinks: () => [],
|
||||
hasLinks: false,
|
||||
};
|
||||
|
||||
const [min, max] = getFieldConfigMinMax(fieldDisplay);
|
||||
expect(min).toBe(0);
|
||||
expect(max).toBe(100);
|
||||
});
|
||||
});
|
||||
function calc(overrides: Partial<RadialGaugeProps & { barIndex: number }> = {}) {
|
||||
return calculateDimensions(
|
||||
overrides.width ?? 200,
|
||||
overrides.height ?? 200,
|
||||
overrides.shape === 'gauge' ? 110 : 360,
|
||||
overrides.glowBar ?? false,
|
||||
overrides.roundedBars ?? false,
|
||||
overrides.barWidthFactor ?? 0.4,
|
||||
overrides.barIndex ?? 0,
|
||||
overrides.thresholdsBar ?? false,
|
||||
overrides.showScaleLabels ?? false
|
||||
);
|
||||
}
|
||||
|
||||
describe('calculateDimensions', () => {
|
||||
function calc(overrides: Partial<RadialGaugeProps & { barIndex: number }> = {}) {
|
||||
return calculateDimensions(
|
||||
overrides.width ?? 200,
|
||||
overrides.height ?? 200,
|
||||
overrides.shape === 'gauge' ? 110 : 360,
|
||||
overrides.glowBar ?? false,
|
||||
overrides.roundedBars ?? false,
|
||||
overrides.barWidthFactor ?? 0.4,
|
||||
overrides.barIndex ?? 0,
|
||||
overrides.thresholdsBar ?? false,
|
||||
overrides.showScaleLabels ?? false
|
||||
);
|
||||
}
|
||||
|
||||
it('should calculate basic dimensions for a square gauge', () => {
|
||||
const result = calc();
|
||||
|
||||
@@ -281,84 +194,4 @@ describe('RadialGauge utils', () => {
|
||||
expect(result.angle).toBe(240);
|
||||
});
|
||||
});
|
||||
|
||||
describe('drawRadialArcPath', () => {
|
||||
const defaultDims: RadialGaugeDimensions = Object.freeze({
|
||||
centerX: 100,
|
||||
centerY: 100,
|
||||
radius: 80,
|
||||
barWidth: 20,
|
||||
margin: 0,
|
||||
barIndex: 0,
|
||||
thresholdsBarWidth: 0,
|
||||
thresholdsBarSpacing: 0,
|
||||
thresholdsBarRadius: 0,
|
||||
scaleLabelsFontSize: 0,
|
||||
scaleLabelsSpacing: 0,
|
||||
scaleLabelsRadius: 0,
|
||||
gaugeBottomY: 0,
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ description: 'quarter arc', startAngle: 0, endAngle: 90 },
|
||||
{ description: 'half arc', startAngle: 0, endAngle: 180 },
|
||||
{ description: 'three quarter arc', startAngle: 0, endAngle: 270 },
|
||||
{ description: 'rounded bars', startAngle: 0, endAngle: 270, roundedBars: true },
|
||||
{ description: 'wide bar width', startAngle: 0, endAngle: 180, dimensions: { barWidth: 50 } },
|
||||
{ description: 'narrow bar width', startAngle: 0, endAngle: 180, dimensions: { barWidth: 5 } },
|
||||
{ description: 'narrow radius', startAngle: 0, endAngle: 180, dimensions: { radius: 50 } },
|
||||
{
|
||||
description: 'center x and y',
|
||||
startAngle: 0,
|
||||
endAngle: 360,
|
||||
roundedBars: true,
|
||||
dimensions: { centerX: 150, centerY: 200 },
|
||||
},
|
||||
])(`should draw correct path for $description`, ({ startAngle, endAngle, dimensions, roundedBars }) => {
|
||||
const path = drawRadialArcPath(startAngle, endAngle, { ...defaultDims, ...dimensions }, roundedBars);
|
||||
expect(path).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should adjust 360deg or greater arcs to avoid SVG rendering issues', () => {
|
||||
expect(drawRadialArcPath(0, 360, defaultDims)).toEqual(drawRadialArcPath(0, 359.99, defaultDims));
|
||||
expect(drawRadialArcPath(0, 380, defaultDims)).toEqual(drawRadialArcPath(0, 380, defaultDims));
|
||||
});
|
||||
|
||||
it('should return empty string if inner radius collapses to zero or below', () => {
|
||||
const smallRadiusDims = { ...defaultDims, radius: 5, barWidth: 20 };
|
||||
expect(drawRadialArcPath(0, 180, smallRadiusDims)).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAngleBetweenSegments', () => {
|
||||
it('should calculate angle between segments based on spacing and count', () => {
|
||||
expect(getAngleBetweenSegments(2, 10, 360)).toBe(48);
|
||||
expect(getAngleBetweenSegments(5, 15, 180)).toBe(40);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOptimalSegmentCount', () => {
|
||||
it('should adjust segment count based on dimensions and spacing', () => {
|
||||
const dimensions: RadialGaugeDimensions = {
|
||||
centerX: 100,
|
||||
centerY: 100,
|
||||
radius: 80,
|
||||
barWidth: 20,
|
||||
margin: 0,
|
||||
barIndex: 0,
|
||||
thresholdsBarWidth: 0,
|
||||
thresholdsBarSpacing: 0,
|
||||
thresholdsBarRadius: 0,
|
||||
scaleLabelsFontSize: 0,
|
||||
scaleLabelsSpacing: 0,
|
||||
scaleLabelsRadius: 0,
|
||||
gaugeBottomY: 0,
|
||||
};
|
||||
|
||||
expect(getOptimalSegmentCount(dimensions, 2, 10, 360)).toBe(8);
|
||||
expect(getOptimalSegmentCount(dimensions, 1, 5, 360)).toBe(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,38 +1,11 @@
|
||||
import { FieldDisplay, getDisplayProcessor } from '@grafana/data';
|
||||
import { FieldDisplay } from '@grafana/data';
|
||||
|
||||
import { RadialGaugeDimensions } from './types';
|
||||
|
||||
export function getFieldDisplayProcessor(displayValue: FieldDisplay) {
|
||||
if (displayValue.view && displayValue.colIndex != null) {
|
||||
const dp = displayValue.view.getFieldDisplayProcessor(displayValue.colIndex);
|
||||
if (dp) {
|
||||
return dp;
|
||||
}
|
||||
}
|
||||
|
||||
return getDisplayProcessor();
|
||||
}
|
||||
|
||||
export function getFieldConfigMinMax(fieldDisplay: FieldDisplay) {
|
||||
export function getValueAngleForValue(fieldDisplay: FieldDisplay, startAngle: number, endAngle: number) {
|
||||
const angleRange = (360 % (startAngle === 0 ? 1 : startAngle)) + endAngle;
|
||||
const min = fieldDisplay.field.min ?? 0;
|
||||
const max = fieldDisplay.field.max ?? 100;
|
||||
return [min, max];
|
||||
}
|
||||
|
||||
export function getValuePercentageForValue(fieldDisplay: FieldDisplay, value = fieldDisplay.display.numeric) {
|
||||
const [min, max] = getFieldConfigMinMax(fieldDisplay);
|
||||
return (value - min) / (max - min);
|
||||
}
|
||||
|
||||
export function getValueAngleForValue(
|
||||
fieldDisplay: FieldDisplay,
|
||||
startAngle: number,
|
||||
endAngle: number,
|
||||
value = fieldDisplay.display.numeric
|
||||
) {
|
||||
const angleRange = (360 % (startAngle === 0 ? 1 : startAngle)) + endAngle;
|
||||
|
||||
let angle = getValuePercentageForValue(fieldDisplay, value) * angleRange;
|
||||
let angle = ((fieldDisplay.display.numeric - min) / (max - min)) * angleRange;
|
||||
|
||||
if (angle > angleRange) {
|
||||
angle = angleRange;
|
||||
@@ -53,19 +26,24 @@ export function toRad(angle: number) {
|
||||
return ((angle - 90) * Math.PI) / 180;
|
||||
}
|
||||
|
||||
/**
|
||||
* returns the calculated dimensions for the radial gauge
|
||||
* @param width
|
||||
* @param height
|
||||
* @param endAngle
|
||||
* @param glow
|
||||
* @param roundedBars
|
||||
* @param barWidthFactor
|
||||
* @param barIndex
|
||||
* @param thresholdBar
|
||||
* @param showScaleLabels
|
||||
* @returns {RadialGaugeDimensions}
|
||||
*/
|
||||
export interface GaugeDimensions {
|
||||
margin: number;
|
||||
radius: number;
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
barWidth: number;
|
||||
endAngle?: number;
|
||||
barIndex: number;
|
||||
thresholdsBarRadius: number;
|
||||
thresholdsBarWidth: number;
|
||||
thresholdsBarSpacing: number;
|
||||
showScaleLabels?: boolean;
|
||||
scaleLabelsFontSize: number;
|
||||
scaleLabelsSpacing: number;
|
||||
scaleLabelsRadius: number;
|
||||
gaugeBottomY: number;
|
||||
}
|
||||
|
||||
export function calculateDimensions(
|
||||
width: number,
|
||||
height: number,
|
||||
@@ -76,7 +54,7 @@ export function calculateDimensions(
|
||||
barIndex: number,
|
||||
thresholdBar?: boolean,
|
||||
showScaleLabels?: boolean
|
||||
): RadialGaugeDimensions {
|
||||
): GaugeDimensions {
|
||||
const yMaxAngle = endAngle > 180 ? 180 : endAngle;
|
||||
let margin = 0;
|
||||
|
||||
@@ -119,7 +97,6 @@ export function calculateDimensions(
|
||||
maxRadiusW -= labelsSize;
|
||||
maxRadiusH -= labelsSize;
|
||||
|
||||
// FIXME: needs coverage
|
||||
// For gauges the max label needs a bit more vertical space so that it does not get clipped
|
||||
if (maxRadiusIsLimitedByHeight && endAngle < 180) {
|
||||
const amount = outerRadius * 0.07;
|
||||
@@ -178,105 +155,3 @@ export function toCartesian(centerX: number, centerY: number, radius: number, an
|
||||
y: centerY + radius * Math.sin(radian),
|
||||
};
|
||||
}
|
||||
|
||||
export function drawRadialArcPath(
|
||||
startAngle: number,
|
||||
endAngle: number,
|
||||
dimensions: RadialGaugeDimensions,
|
||||
roundedBars?: boolean
|
||||
): string {
|
||||
const { radius, centerX, centerY, barWidth } = dimensions;
|
||||
|
||||
// For some reason a 100% full arc cannot be rendered
|
||||
if (endAngle >= 360) {
|
||||
endAngle = 359.99;
|
||||
}
|
||||
|
||||
const startRadians = toRad(startAngle);
|
||||
const endRadians = toRad(startAngle + endAngle);
|
||||
|
||||
const largeArc = endAngle > 180 ? 1 : 0;
|
||||
|
||||
const outerR = radius + barWidth / 2;
|
||||
const innerR = Math.max(0, radius - barWidth / 2);
|
||||
if (innerR <= 0) {
|
||||
return ''; // cannot draw arc with 0 inner radius
|
||||
}
|
||||
|
||||
// get points for both an inner and outer arc. we draw
|
||||
// the arc entirely with a path's fill instead of using stroke
|
||||
// so that it can be used as a clip-path.
|
||||
const ox1 = centerX + outerR * Math.cos(startRadians);
|
||||
const oy1 = centerY + outerR * Math.sin(startRadians);
|
||||
const ox2 = centerX + outerR * Math.cos(endRadians);
|
||||
const oy2 = centerY + outerR * Math.sin(endRadians);
|
||||
|
||||
const ix1 = centerX + innerR * Math.cos(startRadians);
|
||||
const iy1 = centerY + innerR * Math.sin(startRadians);
|
||||
const ix2 = centerX + innerR * Math.cos(endRadians);
|
||||
const iy2 = centerY + innerR * Math.sin(endRadians);
|
||||
|
||||
// calculate the cap width in case we're drawing rounded bars
|
||||
const capR = barWidth / 2;
|
||||
|
||||
const pathParts = [
|
||||
// start at outer start
|
||||
'M',
|
||||
ox1,
|
||||
oy1,
|
||||
// outer arc from start to end (clockwise)
|
||||
'A',
|
||||
outerR,
|
||||
outerR,
|
||||
0,
|
||||
largeArc,
|
||||
1,
|
||||
ox2,
|
||||
oy2,
|
||||
];
|
||||
|
||||
if (roundedBars) {
|
||||
// rounded end cap: small arc connecting outer end to inner end
|
||||
pathParts.push('A', capR, capR, 0, 0, 1, ix2, iy2);
|
||||
} else {
|
||||
// straight line to inner end (square butt)
|
||||
pathParts.push('L', ix2, iy2);
|
||||
}
|
||||
|
||||
// inner arc from end back to start (counter-clockwise)
|
||||
pathParts.push('A', innerR, innerR, 0, largeArc, 0, ix1, iy1);
|
||||
|
||||
if (roundedBars) {
|
||||
// rounded start cap: small arc connecting inner start back to outer start
|
||||
pathParts.push('A', capR, capR, 0, 0, 1, ox1, oy1);
|
||||
} else {
|
||||
// straight line back to outer start (square butt)
|
||||
pathParts.push('L', ox1, oy1);
|
||||
}
|
||||
|
||||
pathParts.push('Z');
|
||||
|
||||
return pathParts.join(' ');
|
||||
}
|
||||
|
||||
export function getAngleBetweenSegments(segmentSpacing: number, segmentCount: number, range: number) {
|
||||
// Max spacing is 8 degrees between segments
|
||||
// Changing this constant could be considered a breaking change
|
||||
const maxAngleBetweenSegments = Math.max(range / 1.5 / segmentCount, 2);
|
||||
return segmentSpacing * maxAngleBetweenSegments;
|
||||
}
|
||||
|
||||
export function getOptimalSegmentCount(
|
||||
dimensions: RadialGaugeDimensions,
|
||||
segmentSpacing: number,
|
||||
segmentCount: number,
|
||||
range: number
|
||||
) {
|
||||
const angleBetweenSegments = getAngleBetweenSegments(segmentSpacing, segmentCount, range);
|
||||
|
||||
const innerRadius = dimensions.radius - dimensions.barWidth / 2;
|
||||
const circumference = Math.PI * innerRadius * 2 * (range / 360);
|
||||
const maxSegments = Math.floor(circumference / (angleBetweenSegments + 3));
|
||||
|
||||
return Math.min(maxSegments, segmentCount);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { isPlainObject } from 'lodash';
|
||||
import { useCallback } from 'react';
|
||||
import * as React from 'react';
|
||||
|
||||
@@ -64,18 +63,7 @@ export function CellActions({
|
||||
tooltip={t('grafana-ui.table.cell-inspect', 'Inspect value')}
|
||||
onClick={() => {
|
||||
if (setInspectCell) {
|
||||
let mode = TableCellInspectorMode.text;
|
||||
let inspectValue = cell.value;
|
||||
try {
|
||||
const parsed = typeof inspectValue === 'string' ? JSON.parse(inspectValue) : inspectValue;
|
||||
if (Array.isArray(parsed) || isPlainObject(parsed)) {
|
||||
inspectValue = JSON.stringify(parsed, null, 2);
|
||||
mode = TableCellInspectorMode.code;
|
||||
}
|
||||
} catch {
|
||||
// do nothing
|
||||
}
|
||||
setInspectCell({ value: inspectValue, mode });
|
||||
setInspectCell({ value: cell.value, mode: previewMode });
|
||||
}
|
||||
}}
|
||||
{...commonButtonProps}
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { VizLegendTable } from './VizLegendTable';
|
||||
import { VizLegendItem } from './types';
|
||||
|
||||
describe('VizLegendTable', () => {
|
||||
const mockItems: VizLegendItem[] = [
|
||||
{ label: 'Series 1', color: 'red', yAxis: 1 },
|
||||
{ label: 'Series 2', color: 'blue', yAxis: 1 },
|
||||
{ label: 'Series 3', color: 'green', yAxis: 1 },
|
||||
];
|
||||
|
||||
it('renders without crashing', () => {
|
||||
const { container } = render(<VizLegendTable items={mockItems} placement="bottom" />);
|
||||
expect(container.querySelector('table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders all items', () => {
|
||||
render(<VizLegendTable items={mockItems} placement="bottom" />);
|
||||
expect(screen.getByText('Series 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Series 2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Series 3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders table headers when items have display values', () => {
|
||||
const itemsWithStats: VizLegendItem[] = [
|
||||
{
|
||||
label: 'Series 1',
|
||||
color: 'red',
|
||||
yAxis: 1,
|
||||
getDisplayValues: () => [
|
||||
{ numeric: 100, text: '100', title: 'Max' },
|
||||
{ numeric: 50, text: '50', title: 'Min' },
|
||||
],
|
||||
},
|
||||
];
|
||||
render(<VizLegendTable items={itemsWithStats} placement="bottom" />);
|
||||
expect(screen.getByText('Max')).toBeInTheDocument();
|
||||
expect(screen.getByText('Min')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders sort icon when sorted', () => {
|
||||
const { container } = render(
|
||||
<VizLegendTable items={mockItems} placement="bottom" sortBy="Name" sortDesc={false} />
|
||||
);
|
||||
expect(container.querySelector('svg')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onToggleSort when header is clicked', () => {
|
||||
const onToggleSort = jest.fn();
|
||||
render(<VizLegendTable items={mockItems} placement="bottom" onToggleSort={onToggleSort} isSortable={true} />);
|
||||
const header = screen.getByText('Name');
|
||||
header.click();
|
||||
expect(onToggleSort).toHaveBeenCalledWith('Name');
|
||||
});
|
||||
|
||||
it('does not call onToggleSort when not sortable', () => {
|
||||
const onToggleSort = jest.fn();
|
||||
render(<VizLegendTable items={mockItems} placement="bottom" onToggleSort={onToggleSort} isSortable={false} />);
|
||||
const header = screen.getByText('Name');
|
||||
header.click();
|
||||
expect(onToggleSort).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders with long labels', () => {
|
||||
const itemsWithLongLabels: VizLegendItem[] = [
|
||||
{
|
||||
label: 'This is a very long series name that should be scrollable within its table cell',
|
||||
color: 'red',
|
||||
yAxis: 1,
|
||||
},
|
||||
];
|
||||
render(<VizLegendTable items={itemsWithLongLabels} placement="bottom" />);
|
||||
expect(
|
||||
screen.getByText('This is a very long series name that should be scrollable within its table cell')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -119,6 +119,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
table: css({
|
||||
width: '100%',
|
||||
'th:first-child': {
|
||||
width: '100%',
|
||||
borderBottom: `1px solid ${theme.colors.border.weak}`,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { LegendTableItem } from './VizLegendTableItem';
|
||||
import { VizLegendItem } from './types';
|
||||
|
||||
describe('LegendTableItem', () => {
|
||||
const mockItem: VizLegendItem = {
|
||||
label: 'Series 1',
|
||||
color: 'red',
|
||||
yAxis: 1,
|
||||
};
|
||||
|
||||
it('renders without crashing', () => {
|
||||
const { container } = render(
|
||||
<table>
|
||||
<tbody>
|
||||
<LegendTableItem item={mockItem} />
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(container.querySelector('tr')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders label text', () => {
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<LegendTableItem item={mockItem} />
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(screen.getByText('Series 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with long label text', () => {
|
||||
const longLabelItem: VizLegendItem = {
|
||||
...mockItem,
|
||||
label: 'This is a very long series name that should be scrollable in the table cell',
|
||||
};
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<LegendTableItem item={longLabelItem} />
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(
|
||||
screen.getByText('This is a very long series name that should be scrollable in the table cell')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders stat values when provided', () => {
|
||||
const itemWithStats: VizLegendItem = {
|
||||
...mockItem,
|
||||
getDisplayValues: () => [
|
||||
{ numeric: 100, text: '100', title: 'Max' },
|
||||
{ numeric: 50, text: '50', title: 'Min' },
|
||||
],
|
||||
};
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<LegendTableItem item={itemWithStats} />
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(screen.getByText('100')).toBeInTheDocument();
|
||||
expect(screen.getByText('50')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders right y-axis indicator when yAxis is 2', () => {
|
||||
const rightAxisItem: VizLegendItem = {
|
||||
...mockItem,
|
||||
yAxis: 2,
|
||||
};
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<LegendTableItem item={rightAxisItem} />
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(screen.getByText('(right y-axis)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onLabelClick when label is clicked', () => {
|
||||
const onLabelClick = jest.fn();
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<LegendTableItem item={mockItem} onLabelClick={onLabelClick} />
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
const button = screen.getByRole('button');
|
||||
button.click();
|
||||
expect(onLabelClick).toHaveBeenCalledWith(mockItem, expect.any(Object));
|
||||
});
|
||||
|
||||
it('does not call onClick when readonly', () => {
|
||||
const onLabelClick = jest.fn();
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<LegendTableItem item={mockItem} onLabelClick={onLabelClick} readonly={true} />
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -69,7 +69,7 @@ export const LegendTableItem = ({
|
||||
|
||||
return (
|
||||
<tr className={cx(styles.row, className)}>
|
||||
<td className={styles.labelCell}>
|
||||
<td>
|
||||
<span className={styles.itemWrapper}>
|
||||
<VizLegendSeriesIcon
|
||||
color={item.color}
|
||||
@@ -77,26 +77,24 @@ export const LegendTableItem = ({
|
||||
readonly={readonly}
|
||||
lineStyle={item.lineStyle}
|
||||
/>
|
||||
<div className={styles.labelCellInner}>
|
||||
<button
|
||||
disabled={readonly}
|
||||
type="button"
|
||||
title={item.label}
|
||||
onBlur={onMouseOut}
|
||||
onFocus={onMouseOver}
|
||||
onMouseOver={onMouseOver}
|
||||
onMouseOut={onMouseOut}
|
||||
onClick={!readonly ? onClick : undefined}
|
||||
className={cx(styles.label, item.disabled && styles.labelDisabled)}
|
||||
>
|
||||
{item.label}{' '}
|
||||
{item.yAxis === 2 && (
|
||||
<span className={styles.yAxisLabel}>
|
||||
<Trans i18nKey="grafana-ui.viz-legend.right-axis-indicator">(right y-axis)</Trans>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
disabled={readonly}
|
||||
type="button"
|
||||
title={item.label}
|
||||
onBlur={onMouseOut}
|
||||
onFocus={onMouseOver}
|
||||
onMouseOver={onMouseOver}
|
||||
onMouseOut={onMouseOut}
|
||||
onClick={!readonly ? onClick : undefined}
|
||||
className={cx(styles.label, item.disabled && styles.labelDisabled)}
|
||||
>
|
||||
{item.label}{' '}
|
||||
{item.yAxis === 2 && (
|
||||
<span className={styles.yAxisLabel}>
|
||||
<Trans i18nKey="grafana-ui.viz-legend.right-axis-indicator">(right y-axis)</Trans>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</span>
|
||||
</td>
|
||||
{item.getDisplayValues &&
|
||||
@@ -130,27 +128,6 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
background: rowHoverBg,
|
||||
},
|
||||
}),
|
||||
labelCell: css({
|
||||
label: 'LegendLabelCell',
|
||||
maxWidth: 0,
|
||||
width: '100%',
|
||||
}),
|
||||
labelCellInner: css({
|
||||
label: 'LegendLabelCellInner',
|
||||
display: 'block',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
overflowX: 'auto',
|
||||
overflowY: 'hidden',
|
||||
paddingRight: theme.spacing(3),
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none',
|
||||
maskImage: `linear-gradient(to right, black calc(100% - ${theme.spacing(3)}), transparent 100%)`,
|
||||
WebkitMaskImage: `linear-gradient(to right, black calc(100% - ${theme.spacing(3)}), transparent 100%)`,
|
||||
'&::-webkit-scrollbar': {
|
||||
display: 'none',
|
||||
},
|
||||
}),
|
||||
label: css({
|
||||
label: 'LegendLabel',
|
||||
whiteSpace: 'nowrap',
|
||||
@@ -158,6 +135,9 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
border: 'none',
|
||||
fontSize: 'inherit',
|
||||
padding: 0,
|
||||
maxWidth: '600px',
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
userSelect: 'text',
|
||||
}),
|
||||
labelDisabled: css({
|
||||
|
||||
@@ -29,8 +29,7 @@ func ToFolderErrorResponse(err error) response.Response {
|
||||
errors.Is(err, dashboards.ErrDashboardTypeMismatch) ||
|
||||
errors.Is(err, dashboards.ErrDashboardInvalidUid) ||
|
||||
errors.Is(err, dashboards.ErrDashboardUidTooLong) ||
|
||||
errors.Is(err, folder.ErrFolderCannotBeParentOfItself) ||
|
||||
errors.Is(err, folder.ErrMaximumDepthReached) {
|
||||
errors.Is(err, folder.ErrFolderCannotBeParentOfItself) {
|
||||
return response.Error(http.StatusBadRequest, err.Error(), nil)
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ func TestToFolderErrorResponse(t *testing.T) {
|
||||
{
|
||||
name: "maximum depth reached",
|
||||
input: folder.ErrMaximumDepthReached.Errorf("Maximum nested folder depth reached"),
|
||||
want: response.Error(http.StatusBadRequest, "[folder.maximum-depth-reached] Maximum nested folder depth reached", nil),
|
||||
want: response.Err(folder.ErrMaximumDepthReached.Errorf("Maximum nested folder depth reached")),
|
||||
},
|
||||
{
|
||||
name: "bad request errors",
|
||||
|
||||
@@ -214,7 +214,7 @@ func (hs *HTTPServer) MoveFolder(c *contextmodel.ReqContext) response.Response {
|
||||
cmd.SignedInUser = c.SignedInUser
|
||||
theFolder, err := hs.folderService.Move(c.Req.Context(), &cmd)
|
||||
if err != nil {
|
||||
return apierrors.ToFolderErrorResponse(err)
|
||||
return response.ErrOrFallback(http.StatusInternalServerError, "move folder failed", err)
|
||||
}
|
||||
|
||||
folderDTO, err := hs.newToFolderDto(c, theFolder)
|
||||
|
||||
@@ -178,7 +178,7 @@ func setupFromConfig(cfg *setting.Cfg, registry prometheus.Registerer) (controll
|
||||
APIPath: "/apis",
|
||||
Host: url,
|
||||
WrapTransport: transport.WrapperFunc(func(rt http.RoundTripper) http.RoundTripper {
|
||||
return authrt.NewRoundTripper(tokenExchangeClient, rt, group, authrt.ExtraAudience(provisioning.GROUP))
|
||||
return authrt.NewRoundTripper(tokenExchangeClient, rt, group)
|
||||
}),
|
||||
Transport: &http.Transport{
|
||||
MaxConnsPerHost: 100,
|
||||
|
||||
@@ -54,7 +54,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/dashboardsnapshots"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
"github.com/grafana/grafana/pkg/services/libraryelements"
|
||||
"github.com/grafana/grafana/pkg/services/librarypanels"
|
||||
"github.com/grafana/grafana/pkg/services/live"
|
||||
@@ -389,12 +388,8 @@ func (b *DashboardsAPIBuilder) validateCreate(ctx context.Context, a admission.A
|
||||
return fmt.Errorf("error getting requester: %w", err)
|
||||
}
|
||||
|
||||
if a.IsDryRun() {
|
||||
return nil // do not check folder or quota
|
||||
}
|
||||
|
||||
// Validate folder existence if specified
|
||||
if !folder.IsRootFolder(accessor.GetFolder()) {
|
||||
if !a.IsDryRun() && accessor.GetFolder() != "" {
|
||||
folder, err := b.validateFolderExists(ctx, accessor.GetFolder(), id.GetOrgID())
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -406,7 +401,7 @@ func (b *DashboardsAPIBuilder) validateCreate(ctx context.Context, a admission.A
|
||||
}
|
||||
|
||||
// Validate quota
|
||||
if !b.isStandalone {
|
||||
if !b.isStandalone && !a.IsDryRun() {
|
||||
params := "a.ScopeParameters{}
|
||||
params.OrgID = id.GetOrgID()
|
||||
internalId, err := id.GetInternalID()
|
||||
|
||||
@@ -356,7 +356,7 @@ func (b *FolderAPIBuilder) Validate(ctx context.Context, a admission.Attributes,
|
||||
if !ok {
|
||||
return fmt.Errorf("obj is not folders.Folder")
|
||||
}
|
||||
return validateOnUpdate(ctx, f, old, b.storage, b.parents, b.searcher, folder.MaxNestedFolderDepth)
|
||||
return validateOnUpdate(ctx, f, old, b.storage, b.parents, folder.MaxNestedFolderDepth)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -376,10 +376,6 @@ func TestFolderAPIBuilder_Validate_Update(t *testing.T) {
|
||||
m.On("Get", mock.Anything, "new-parent", mock.Anything).Return(
|
||||
&folders.Folder{},
|
||||
nil).Once()
|
||||
// also retrieves old parent for depth difference calculation
|
||||
m.On("Get", mock.Anything, "valid-parent", mock.Anything).Return(
|
||||
&folders.Folder{},
|
||||
nil).Once()
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"slices"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/selection"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
|
||||
folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
|
||||
@@ -14,7 +13,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
@@ -46,12 +44,12 @@ func validateOnCreate(ctx context.Context, f *folders.Folder, getter parentsGett
|
||||
return dashboards.ErrFolderTitleEmpty
|
||||
}
|
||||
|
||||
switch meta.GetFolder() {
|
||||
case "", folder.GeneralFolderUID:
|
||||
parentName := meta.GetFolder()
|
||||
if parentName == "" {
|
||||
return nil // OK, we do not need to validate the tree
|
||||
case folder.SharedWithMeFolderUID:
|
||||
return fmt.Errorf("can not save shared with me")
|
||||
case f.Name:
|
||||
}
|
||||
|
||||
if parentName == f.Name {
|
||||
return folder.ErrFolderCannotBeParentOfItself
|
||||
}
|
||||
|
||||
@@ -75,7 +73,6 @@ func validateOnUpdate(ctx context.Context,
|
||||
old *folders.Folder,
|
||||
getter rest.Getter,
|
||||
parents parentsGetter,
|
||||
searcher resourcepb.ResourceIndexClient,
|
||||
maxDepth int,
|
||||
) error {
|
||||
folderObj, err := utils.MetaAccessor(obj)
|
||||
@@ -98,19 +95,14 @@ func validateOnUpdate(ctx context.Context,
|
||||
// Validate the move operation
|
||||
newParent := folderObj.GetFolder()
|
||||
|
||||
switch newParent {
|
||||
// If we move to root, we don't need to validate the depth, because the folder already existed
|
||||
// before and wasn't too deep. This move will make it more shallow.
|
||||
//
|
||||
// We also don't need to validate circular references because the root folder cannot have a parent.
|
||||
case "", folder.GeneralFolderUID:
|
||||
return nil // OK, we do not need to validate the tree
|
||||
case folder.SharedWithMeFolderUID:
|
||||
return fmt.Errorf("can not save shared with me")
|
||||
case accesscontrol.K6FolderUID:
|
||||
// If we move to root, we don't need to validate the depth.
|
||||
if newParent == folder.RootFolderUID {
|
||||
return nil
|
||||
}
|
||||
|
||||
// folder cannot be moved to a k6 folder
|
||||
if newParent == accesscontrol.K6FolderUID {
|
||||
return fmt.Errorf("k6 project may not be moved")
|
||||
case folderObj.GetName():
|
||||
return folder.ErrFolderCannotBeParentOfItself
|
||||
}
|
||||
|
||||
parentObj, err := getter.Get(ctx, newParent, &metav1.GetOptions{})
|
||||
@@ -121,6 +113,9 @@ func validateOnUpdate(ctx context.Context,
|
||||
if !ok {
|
||||
return fmt.Errorf("expected folder, found %T", parentObj)
|
||||
}
|
||||
|
||||
//FIXME: until we have a way to represent the tree, we can only
|
||||
// look at folder parents to check how deep the new folder tree will be
|
||||
info, err := parents(ctx, parent)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -134,162 +129,13 @@ func validateOnUpdate(ctx context.Context,
|
||||
}
|
||||
}
|
||||
|
||||
// if by moving a folder we exceed the max depth just from its parents + itself, return an error
|
||||
// if by moving a folder we exceed the max depth, return an error
|
||||
if len(info.Items) > maxDepth+1 {
|
||||
return folder.ErrMaximumDepthReached.Errorf("maximum folder depth reached")
|
||||
}
|
||||
// To try to save some computation, get the parents of the old parent (this is typically cheaper
|
||||
// than looking at the children of the folder). If the old parent has more parents or the same
|
||||
// number of parents as the new parent, we can return early, because we know the folder had to be
|
||||
// safe from the creation validation. If we cannot access the older parent, we will continue to check the children.
|
||||
if canSkipChildrenCheck(ctx, oldFolder, getter, parents, len(info.Items)) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Now comes the more expensive part: we need to check if moving this folder will cause
|
||||
// any descendant folders to exceed the max depth.
|
||||
//
|
||||
// Calculate the maximum allowed subtree depth after the move.
|
||||
allowedDepth := (maxDepth + 1) - len(info.Items)
|
||||
if allowedDepth <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return checkSubtreeDepth(ctx, searcher, obj.Namespace, obj.Name, allowedDepth, maxDepth)
|
||||
}
|
||||
|
||||
// canSkipChildrenCheck determines if we can skip the expensive children depth check.
|
||||
// If the old parent depth is >= the new parent depth, the folder was already valid
|
||||
// and this move won't make descendants exceed max depth.
|
||||
func canSkipChildrenCheck(ctx context.Context, oldFolder utils.GrafanaMetaAccessor, getter rest.Getter, parents parentsGetter, newParentDepth int) bool {
|
||||
if oldFolder.GetFolder() == folder.RootFolderUID {
|
||||
return false
|
||||
}
|
||||
|
||||
oldParentObj, err := getter.Get(ctx, oldFolder.GetFolder(), &metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
oldParent, ok := oldParentObj.(*folders.Folder)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
oldInfo, err := parents(ctx, oldParent)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
oldParentDepth := len(oldInfo.Items)
|
||||
levelDifference := newParentDepth - oldParentDepth
|
||||
return levelDifference <= 0
|
||||
}
|
||||
|
||||
// checkSubtreeDepth uses a hybrid DFS+batching approach:
|
||||
// 1. fetches one page of children for the current folder(s)
|
||||
// 2. batches all those children into one request to get their children
|
||||
// 3. continues depth-first (batching still) until max depth or violation
|
||||
// 4. only fetches more siblings after fully exploring current batch
|
||||
func checkSubtreeDepth(ctx context.Context, searcher resourcepb.ResourceIndexClient, namespace string, folderUID string, remainingDepth int, maxDepth int) error {
|
||||
if remainingDepth <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start with the folder being moved
|
||||
return checkSubtreeDepthBatched(ctx, searcher, namespace, []string{folderUID}, remainingDepth, maxDepth)
|
||||
}
|
||||
|
||||
// checkSubtreeDepthBatched checks depth for a batch of folders at the same level
|
||||
func checkSubtreeDepthBatched(ctx context.Context, searcher resourcepb.ResourceIndexClient, namespace string, parentUIDs []string, remainingDepth int, maxDepth int) error {
|
||||
if remainingDepth <= 0 || len(parentUIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
const pageSize int64 = 1000
|
||||
var offset int64
|
||||
totalPages := 0
|
||||
hasMore := true
|
||||
|
||||
// Using an upper limit to ensure no infinite loops can happen
|
||||
for hasMore && totalPages < 1000 {
|
||||
totalPages++
|
||||
|
||||
var err error
|
||||
var children []string
|
||||
children, hasMore, err = getChildrenBatch(ctx, searcher, namespace, parentUIDs, pageSize, offset)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get children: %w", err)
|
||||
}
|
||||
|
||||
if len(children) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// if we are at the last allowed depth and children exist, we will hit the max
|
||||
if remainingDepth == 1 {
|
||||
return folder.ErrMaximumDepthReached.Errorf("maximum folder depth %d would be exceeded after move", maxDepth)
|
||||
}
|
||||
|
||||
if err := checkSubtreeDepthBatched(ctx, searcher, namespace, children, remainingDepth-1, maxDepth); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !hasMore {
|
||||
return nil
|
||||
}
|
||||
|
||||
offset += pageSize
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getChildrenBatch fetches children for multiple parents
|
||||
func getChildrenBatch(ctx context.Context, searcher resourcepb.ResourceIndexClient, namespace string, parentUIDs []string, limit int64, offset int64) ([]string, bool, error) {
|
||||
if len(parentUIDs) == 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
resp, err := searcher.Search(ctx, &resourcepb.ResourceSearchRequest{
|
||||
Options: &resourcepb.ListOptions{
|
||||
Key: &resourcepb.ResourceKey{
|
||||
Namespace: namespace,
|
||||
Group: folders.FolderResourceInfo.GroupVersionResource().Group,
|
||||
Resource: folders.FolderResourceInfo.GroupVersionResource().Resource,
|
||||
},
|
||||
Fields: []*resourcepb.Requirement{{
|
||||
Key: resource.SEARCH_FIELD_FOLDER,
|
||||
Operator: string(selection.In),
|
||||
Values: parentUIDs,
|
||||
}},
|
||||
},
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("failed to search folders: %w", err)
|
||||
}
|
||||
|
||||
if resp.Error != nil {
|
||||
return nil, false, fmt.Errorf("search error: %s", resp.Error.Message)
|
||||
}
|
||||
|
||||
if resp.Results == nil || len(resp.Results.Rows) == 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
children := make([]string, 0, len(resp.Results.Rows))
|
||||
for _, row := range resp.Results.Rows {
|
||||
if row.Key != nil {
|
||||
children = append(children, row.Key.Name)
|
||||
}
|
||||
}
|
||||
|
||||
hasMore := resp.Results.NextPageToken != ""
|
||||
return children, hasMore, nil
|
||||
}
|
||||
|
||||
func validateOnDelete(ctx context.Context,
|
||||
f *folders.Folder,
|
||||
searcher resourcepb.ResourceIndexClient,
|
||||
|
||||
@@ -282,7 +282,6 @@ func TestValidateUpdate(t *testing.T) {
|
||||
old *folders.Folder
|
||||
parents *folders.FolderInfoList
|
||||
parentsError error
|
||||
allFolders []folders.Folder
|
||||
expectedErr string
|
||||
maxDepth int // defaults to 5 unless set
|
||||
}{
|
||||
@@ -455,74 +454,6 @@ func TestValidateUpdate(t *testing.T) {
|
||||
},
|
||||
expectedErr: "cannot move folder under its own descendant",
|
||||
},
|
||||
{
|
||||
name: "error when moving folder from root to level2 with children exceeds max depth",
|
||||
folder: &folders.Folder{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "folderWithChildren",
|
||||
Annotations: map[string]string{
|
||||
utils.AnnoKeyFolder: "level2",
|
||||
},
|
||||
},
|
||||
Spec: folders.FolderSpec{
|
||||
Title: "folder with children",
|
||||
},
|
||||
},
|
||||
old: &folders.Folder{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "folderWithChildren",
|
||||
},
|
||||
Spec: folders.FolderSpec{
|
||||
Title: "folder with children",
|
||||
},
|
||||
},
|
||||
parents: &folders.FolderInfoList{
|
||||
Items: []folders.FolderInfo{
|
||||
{Name: "level2", Parent: "level1"},
|
||||
{Name: "level1", Parent: folder.GeneralFolderUID},
|
||||
{Name: folder.GeneralFolderUID},
|
||||
},
|
||||
},
|
||||
allFolders: []folders.Folder{
|
||||
{ObjectMeta: metav1.ObjectMeta{Name: "child1", Annotations: map[string]string{utils.AnnoKeyFolder: "folderWithChildren"}}},
|
||||
{ObjectMeta: metav1.ObjectMeta{Name: "grandchild1", Annotations: map[string]string{utils.AnnoKeyFolder: "child1"}}},
|
||||
},
|
||||
maxDepth: 4,
|
||||
expectedErr: "[folder.maximum-depth-reached]",
|
||||
},
|
||||
{
|
||||
name: "can move folder from root level to level1 with children when within max depth",
|
||||
folder: &folders.Folder{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "folderWithChildren",
|
||||
Annotations: map[string]string{
|
||||
utils.AnnoKeyFolder: "level1",
|
||||
},
|
||||
},
|
||||
Spec: folders.FolderSpec{
|
||||
Title: "folder with children",
|
||||
},
|
||||
},
|
||||
old: &folders.Folder{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "folderWithChildren",
|
||||
},
|
||||
Spec: folders.FolderSpec{
|
||||
Title: "folder with children",
|
||||
},
|
||||
},
|
||||
parents: &folders.FolderInfoList{
|
||||
Items: []folders.FolderInfo{
|
||||
{Name: "level1", Parent: folder.GeneralFolderUID},
|
||||
{Name: folder.GeneralFolderUID},
|
||||
},
|
||||
},
|
||||
allFolders: []folders.Folder{
|
||||
{ObjectMeta: metav1.ObjectMeta{Name: "child1", Annotations: map[string]string{utils.AnnoKeyFolder: "folderWithChildren"}}},
|
||||
{ObjectMeta: metav1.ObjectMeta{Name: "grandchild1", Annotations: map[string]string{utils.AnnoKeyFolder: "child1"}}},
|
||||
},
|
||||
maxDepth: 4,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -543,17 +474,11 @@ func TestValidateUpdate(t *testing.T) {
|
||||
}, nil).Maybe()
|
||||
}
|
||||
}
|
||||
for i := range tt.allFolders {
|
||||
f := tt.allFolders[i]
|
||||
m.On("Get", context.Background(), f.Name, &metav1.GetOptions{}).Return(&f, nil).Maybe()
|
||||
}
|
||||
|
||||
err := validateOnUpdate(context.Background(), tt.folder, tt.old, m,
|
||||
func(ctx context.Context, folder *folders.Folder) (*folders.FolderInfoList, error) {
|
||||
return tt.parents, tt.parentsError
|
||||
},
|
||||
&mockSearchClient{folders: tt.allFolders},
|
||||
maxDepth)
|
||||
}, maxDepth)
|
||||
|
||||
if tt.expectedErr == "" {
|
||||
require.NoError(t, err)
|
||||
@@ -768,7 +693,8 @@ type mockSearchClient struct {
|
||||
stats *resourcepb.ResourceStatsResponse
|
||||
statsErr error
|
||||
|
||||
folders []folders.Folder
|
||||
search *resourcepb.ResourceSearchResponse
|
||||
searchErr error
|
||||
}
|
||||
|
||||
// GetStats implements resourcepb.ResourceIndexClient.
|
||||
@@ -777,37 +703,8 @@ func (m *mockSearchClient) GetStats(ctx context.Context, in *resourcepb.Resource
|
||||
}
|
||||
|
||||
// Search implements resourcepb.ResourceIndexClient.
|
||||
func (m *mockSearchClient) Search(ctx context.Context, req *resourcepb.ResourceSearchRequest, opts ...grpc.CallOption) (*resourcepb.ResourceSearchResponse, error) {
|
||||
// get the list of parents from the search request
|
||||
parentSet := make(map[string]bool)
|
||||
if req.Options != nil && req.Options.Fields != nil {
|
||||
for _, field := range req.Options.Fields {
|
||||
if field.Key == "folder" && field.Operator == "in" {
|
||||
for _, v := range field.Values {
|
||||
parentSet[v] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// find children that match the parent filter
|
||||
var rows []*resourcepb.ResourceTableRow
|
||||
for i := range m.folders {
|
||||
meta, err := utils.MetaAccessor(&m.folders[i])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
parentUID := meta.GetFolder()
|
||||
if parentSet[parentUID] {
|
||||
rows = append(rows, &resourcepb.ResourceTableRow{
|
||||
Key: &resourcepb.ResourceKey{Name: m.folders[i].Name},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return &resourcepb.ResourceSearchResponse{
|
||||
Results: &resourcepb.ResourceTable{Rows: rows},
|
||||
}, nil
|
||||
func (m *mockSearchClient) Search(ctx context.Context, in *resourcepb.ResourceSearchRequest, opts ...grpc.CallOption) (*resourcepb.ResourceSearchResponse, error) {
|
||||
return m.search, m.searchErr
|
||||
}
|
||||
|
||||
// RebuildIndexes implements resourcepb.ResourceIndexClient.
|
||||
|
||||
@@ -12,12 +12,6 @@ const (
|
||||
ActionProvisioningRepositoriesRead = "provisioning.repositories:read" // GET + LIST.
|
||||
ActionProvisioningRepositoriesDelete = "provisioning.repositories:delete" // DELETE.
|
||||
|
||||
// Connections
|
||||
ActionProvisioningConnectionsCreate = "provisioning.connections:create" // CREATE.
|
||||
ActionProvisioningConnectionsWrite = "provisioning.connections:write" // UPDATE.
|
||||
ActionProvisioningConnectionsRead = "provisioning.connections:read" // GET + LIST.
|
||||
ActionProvisioningConnectionsDelete = "provisioning.connections:delete" // DELETE.
|
||||
|
||||
// Jobs
|
||||
ActionProvisioningJobsCreate = "provisioning.jobs:create" // CREATE.
|
||||
ActionProvisioningJobsWrite = "provisioning.jobs:write" // UPDATE.
|
||||
@@ -26,12 +20,6 @@ const (
|
||||
|
||||
// Historic Jobs
|
||||
ActionProvisioningHistoricJobsRead = "provisioning.historicjobs:read" // GET + LIST.
|
||||
|
||||
// Settings (read-only, needed by multiple UI pages)
|
||||
ActionProvisioningSettingsRead = "provisioning.settings:read" // GET + LIST.
|
||||
|
||||
// Stats (read-only, admin-only)
|
||||
ActionProvisioningStatsRead = "provisioning.stats:read" // GET + LIST.
|
||||
)
|
||||
|
||||
func registerAccessControlRoles(service accesscontrol.Service) error {
|
||||
@@ -75,46 +63,6 @@ func registerAccessControlRoles(service accesscontrol.Service) error {
|
||||
Grants: []string{string(org.RoleAdmin)},
|
||||
}
|
||||
|
||||
// Connections
|
||||
connectionsReader := accesscontrol.RoleRegistration{
|
||||
Role: accesscontrol.RoleDTO{
|
||||
Name: "fixed:provisioning.connections:reader",
|
||||
DisplayName: "Connections Reader",
|
||||
Description: "Read and list provisioning connections.",
|
||||
Group: "Provisioning",
|
||||
Permissions: []accesscontrol.Permission{
|
||||
{
|
||||
Action: ActionProvisioningConnectionsRead,
|
||||
},
|
||||
},
|
||||
},
|
||||
Grants: []string{string(org.RoleAdmin)},
|
||||
}
|
||||
|
||||
connectionsWriter := accesscontrol.RoleRegistration{
|
||||
Role: accesscontrol.RoleDTO{
|
||||
Name: "fixed:provisioning.connections:writer",
|
||||
DisplayName: "Connections Writer",
|
||||
Description: "Create, update and delete provisioning connections.",
|
||||
Group: "Provisioning",
|
||||
Permissions: []accesscontrol.Permission{
|
||||
{
|
||||
Action: ActionProvisioningConnectionsCreate,
|
||||
},
|
||||
{
|
||||
Action: ActionProvisioningConnectionsRead,
|
||||
},
|
||||
{
|
||||
Action: ActionProvisioningConnectionsWrite,
|
||||
},
|
||||
{
|
||||
Action: ActionProvisioningConnectionsDelete,
|
||||
},
|
||||
},
|
||||
},
|
||||
Grants: []string{string(org.RoleAdmin)},
|
||||
}
|
||||
|
||||
// Jobs
|
||||
jobsReader := accesscontrol.RoleRegistration{
|
||||
Role: accesscontrol.RoleDTO{
|
||||
@@ -171,47 +119,11 @@ func registerAccessControlRoles(service accesscontrol.Service) error {
|
||||
Grants: []string{string(org.RoleAdmin)},
|
||||
}
|
||||
|
||||
// Settings - granted to Viewer (accessible by all logged-in users)
|
||||
settingsReader := accesscontrol.RoleRegistration{
|
||||
Role: accesscontrol.RoleDTO{
|
||||
Name: "fixed:provisioning.settings:reader",
|
||||
DisplayName: "Settings Reader",
|
||||
Description: "Read provisioning settings.",
|
||||
Group: "Provisioning",
|
||||
Permissions: []accesscontrol.Permission{
|
||||
{
|
||||
Action: ActionProvisioningSettingsRead,
|
||||
},
|
||||
},
|
||||
},
|
||||
Grants: []string{string(org.RoleViewer)},
|
||||
}
|
||||
|
||||
// Stats - granted to Admin only
|
||||
statsReader := accesscontrol.RoleRegistration{
|
||||
Role: accesscontrol.RoleDTO{
|
||||
Name: "fixed:provisioning.stats:reader",
|
||||
DisplayName: "Stats Reader",
|
||||
Description: "Read provisioning stats.",
|
||||
Group: "Provisioning",
|
||||
Permissions: []accesscontrol.Permission{
|
||||
{
|
||||
Action: ActionProvisioningStatsRead,
|
||||
},
|
||||
},
|
||||
},
|
||||
Grants: []string{string(org.RoleAdmin)},
|
||||
}
|
||||
|
||||
return service.DeclareFixedRoles(
|
||||
repositoriesReader,
|
||||
repositoriesWriter,
|
||||
connectionsReader,
|
||||
connectionsWriter,
|
||||
jobsReader,
|
||||
jobsWriter,
|
||||
historicJobsReader,
|
||||
settingsReader,
|
||||
statsReader,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,10 +13,9 @@ import (
|
||||
authlib "github.com/grafana/authlib/types"
|
||||
"github.com/grafana/grafana-app-sdk/logging"
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/auth"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/safepath"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
|
||||
)
|
||||
|
||||
@@ -27,12 +26,12 @@ const (
|
||||
|
||||
type filesConnector struct {
|
||||
getter RepoGetter
|
||||
access auth.AccessChecker
|
||||
access authlib.AccessChecker
|
||||
parsers resources.ParserFactory
|
||||
clients resources.ClientFactory
|
||||
}
|
||||
|
||||
func NewFilesConnector(getter RepoGetter, parsers resources.ParserFactory, clients resources.ClientFactory, access auth.AccessChecker) *filesConnector {
|
||||
func NewFilesConnector(getter RepoGetter, parsers resources.ParserFactory, clients resources.ClientFactory, access authlib.AccessChecker) *filesConnector {
|
||||
return &filesConnector{getter: getter, parsers: parsers, clients: clients, access: access}
|
||||
}
|
||||
|
||||
@@ -75,233 +74,179 @@ func (c *filesConnector) Connect(ctx context.Context, name string, opts runtime.
|
||||
ctx = logging.Context(ctx, logger)
|
||||
|
||||
return WithTimeout(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
c.handleRequest(ctx, name, r, responder, logger)
|
||||
repo, err := c.getRepo(ctx, r.Method, name)
|
||||
if err != nil {
|
||||
logger.Debug("failed to find repository", "error", err)
|
||||
responder.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
readWriter, ok := repo.(repository.ReaderWriter)
|
||||
if !ok {
|
||||
responder.Error(apierrors.NewBadRequest("repository does not support read-writing"))
|
||||
return
|
||||
}
|
||||
|
||||
parser, err := c.parsers.GetParser(ctx, readWriter)
|
||||
if err != nil {
|
||||
responder.Error(fmt.Errorf("failed to get parser: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
clients, err := c.clients.Clients(ctx, repo.Config().Namespace)
|
||||
if err != nil {
|
||||
responder.Error(fmt.Errorf("failed to get clients: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
folderClient, err := clients.Folder(ctx)
|
||||
if err != nil {
|
||||
responder.Error(fmt.Errorf("failed to get folder client: %w", err))
|
||||
return
|
||||
}
|
||||
folders := resources.NewFolderManager(readWriter, folderClient, resources.NewEmptyFolderTree())
|
||||
dualReadWriter := resources.NewDualReadWriter(readWriter, parser, folders, c.access)
|
||||
query := r.URL.Query()
|
||||
opts := resources.DualWriteOptions{
|
||||
Ref: query.Get("ref"),
|
||||
Message: query.Get("message"),
|
||||
SkipDryRun: query.Get("skipDryRun") == "true",
|
||||
OriginalPath: query.Get("originalPath"),
|
||||
Branch: repo.Config().Branch(),
|
||||
}
|
||||
logger := logger.With("url", r.URL.Path, "ref", opts.Ref, "message", opts.Message)
|
||||
ctx := logging.Context(r.Context(), logger)
|
||||
|
||||
opts.Path, err = pathAfterPrefix(r.URL.Path, fmt.Sprintf("/%s/files", name))
|
||||
if err != nil {
|
||||
responder.Error(apierrors.NewBadRequest(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if err := resources.IsPathSupported(opts.Path); err != nil {
|
||||
responder.Error(apierrors.NewBadRequest(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
isDir := safepath.IsDir(opts.Path)
|
||||
if r.Method == http.MethodGet && isDir {
|
||||
files, err := c.listFolderFiles(ctx, opts.Path, opts.Ref, readWriter)
|
||||
if err != nil {
|
||||
responder.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
responder.Object(http.StatusOK, files)
|
||||
return
|
||||
}
|
||||
|
||||
if opts.Path == "" {
|
||||
responder.Error(apierrors.NewBadRequest("missing request path"))
|
||||
return
|
||||
}
|
||||
|
||||
var obj *provisioning.ResourceWrapper
|
||||
code := http.StatusOK
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
resource, err := dualReadWriter.Read(ctx, opts.Path, opts.Ref)
|
||||
if err != nil {
|
||||
respondWithError(responder, err)
|
||||
return
|
||||
}
|
||||
obj = resource.AsResourceWrapper()
|
||||
case http.MethodPost:
|
||||
// Check if this is a move operation first (originalPath query parameter is present)
|
||||
if opts.OriginalPath != "" {
|
||||
// For move operations, only read body for file moves (not directory moves)
|
||||
if !isDir {
|
||||
opts.Data, err = readBody(r, filesMaxBodySize)
|
||||
if err != nil {
|
||||
responder.Error(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
resource, err := dualReadWriter.MoveResource(ctx, opts)
|
||||
if err != nil {
|
||||
respondWithError(responder, err)
|
||||
return
|
||||
}
|
||||
obj = resource.AsResourceWrapper()
|
||||
} else if isDir {
|
||||
obj, err = dualReadWriter.CreateFolder(ctx, opts)
|
||||
} else {
|
||||
opts.Data, err = readBody(r, filesMaxBodySize)
|
||||
if err != nil {
|
||||
responder.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var resource *resources.ParsedResource
|
||||
resource, err = dualReadWriter.CreateResource(ctx, opts)
|
||||
if err != nil {
|
||||
respondWithError(responder, err)
|
||||
return
|
||||
}
|
||||
obj = resource.AsResourceWrapper()
|
||||
}
|
||||
case http.MethodPut:
|
||||
// TODO: document in API specification
|
||||
if isDir {
|
||||
err = apierrors.NewMethodNotSupported(provisioning.RepositoryResourceInfo.GroupResource(), r.Method)
|
||||
} else {
|
||||
opts.Data, err = readBody(r, filesMaxBodySize)
|
||||
if err != nil {
|
||||
responder.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
resource, err := dualReadWriter.UpdateResource(ctx, opts)
|
||||
if err != nil {
|
||||
respondWithError(responder, err)
|
||||
return
|
||||
}
|
||||
obj = resource.AsResourceWrapper()
|
||||
}
|
||||
case http.MethodDelete:
|
||||
resource, err := dualReadWriter.Delete(ctx, opts)
|
||||
if err != nil {
|
||||
respondWithError(responder, err)
|
||||
return
|
||||
}
|
||||
obj = resource.AsResourceWrapper()
|
||||
default:
|
||||
err = apierrors.NewMethodNotSupported(provisioning.RepositoryResourceInfo.GroupResource(), r.Method)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logger.Debug("got an error after processing request", "error", err)
|
||||
responder.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(obj.Errors) > 0 {
|
||||
code = http.StatusPartialContent
|
||||
}
|
||||
|
||||
logger.Debug("request resulted in valid object", "object", obj)
|
||||
responder.Object(code, obj)
|
||||
}), 30*time.Second), nil
|
||||
}
|
||||
|
||||
// handleRequest processes the HTTP request for files operations.
|
||||
func (c *filesConnector) handleRequest(ctx context.Context, name string, r *http.Request, responder rest.Responder, logger logging.Logger) {
|
||||
repo, err := c.getRepo(ctx, r.Method, name)
|
||||
if err != nil {
|
||||
logger.Debug("failed to find repository", "error", err)
|
||||
responder.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
readWriter, ok := repo.(repository.ReaderWriter)
|
||||
if !ok {
|
||||
responder.Error(apierrors.NewBadRequest("repository does not support read-writing"))
|
||||
return
|
||||
}
|
||||
|
||||
dualReadWriter, err := c.createDualReadWriter(ctx, repo, readWriter)
|
||||
if err != nil {
|
||||
responder.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
opts, err := c.parseRequestOptions(r, name, repo)
|
||||
if err != nil {
|
||||
responder.Error(apierrors.NewBadRequest(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
logger = logger.With("url", r.URL.Path, "ref", opts.Ref, "message", opts.Message)
|
||||
ctx = logging.Context(r.Context(), logger)
|
||||
|
||||
// Handle directory listing separately
|
||||
isDir := safepath.IsDir(opts.Path)
|
||||
if r.Method == http.MethodGet && isDir {
|
||||
c.handleDirectoryListing(ctx, name, opts, readWriter, responder)
|
||||
return
|
||||
}
|
||||
|
||||
if opts.Path == "" {
|
||||
responder.Error(apierrors.NewBadRequest("missing request path"))
|
||||
return
|
||||
}
|
||||
|
||||
obj, err := c.handleMethodRequest(ctx, r, opts, isDir, dualReadWriter)
|
||||
if err != nil {
|
||||
logger.Debug("got an error after processing request", "error", err)
|
||||
respondWithError(responder, err)
|
||||
return
|
||||
}
|
||||
|
||||
code := http.StatusOK
|
||||
if len(obj.Errors) > 0 {
|
||||
code = http.StatusPartialContent
|
||||
}
|
||||
|
||||
logger.Debug("request resulted in valid object", "object", obj)
|
||||
responder.Object(code, obj)
|
||||
}
|
||||
|
||||
// createDualReadWriter sets up the dual read writer with all required dependencies.
|
||||
func (c *filesConnector) createDualReadWriter(ctx context.Context, repo repository.Repository, readWriter repository.ReaderWriter) (*resources.DualReadWriter, error) {
|
||||
parser, err := c.parsers.GetParser(ctx, readWriter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get parser: %w", err)
|
||||
}
|
||||
|
||||
clients, err := c.clients.Clients(ctx, repo.Config().Namespace)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get clients: %w", err)
|
||||
}
|
||||
|
||||
folderClient, err := clients.Folder(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get folder client: %w", err)
|
||||
}
|
||||
|
||||
folders := resources.NewFolderManager(readWriter, folderClient, resources.NewEmptyFolderTree())
|
||||
return resources.NewDualReadWriter(readWriter, parser, folders, c.access), nil
|
||||
}
|
||||
|
||||
// parseRequestOptions extracts options from the HTTP request.
|
||||
func (c *filesConnector) parseRequestOptions(r *http.Request, name string, repo repository.Repository) (resources.DualWriteOptions, error) {
|
||||
query := r.URL.Query()
|
||||
opts := resources.DualWriteOptions{
|
||||
Ref: query.Get("ref"),
|
||||
Message: query.Get("message"),
|
||||
SkipDryRun: query.Get("skipDryRun") == "true",
|
||||
OriginalPath: query.Get("originalPath"),
|
||||
Branch: repo.Config().Branch(),
|
||||
}
|
||||
|
||||
path, err := pathAfterPrefix(r.URL.Path, fmt.Sprintf("/%s/files", name))
|
||||
if err != nil {
|
||||
return opts, err
|
||||
}
|
||||
opts.Path = path
|
||||
|
||||
if err := resources.IsPathSupported(opts.Path); err != nil {
|
||||
return opts, err
|
||||
}
|
||||
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
// handleDirectoryListing handles GET requests for directory listing.
|
||||
func (c *filesConnector) handleDirectoryListing(ctx context.Context, name string, opts resources.DualWriteOptions, readWriter repository.ReaderWriter, responder rest.Responder) {
|
||||
if err := c.authorizeListFiles(ctx, name); err != nil {
|
||||
responder.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
files, err := c.listFolderFiles(ctx, opts.Path, opts.Ref, readWriter)
|
||||
if err != nil {
|
||||
responder.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
responder.Object(http.StatusOK, files)
|
||||
}
|
||||
|
||||
// handleMethodRequest routes the request to the appropriate handler based on HTTP method.
|
||||
func (c *filesConnector) handleMethodRequest(ctx context.Context, r *http.Request, opts resources.DualWriteOptions, isDir bool, dualReadWriter *resources.DualReadWriter) (*provisioning.ResourceWrapper, error) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
return c.handleGet(ctx, opts, dualReadWriter)
|
||||
case http.MethodPost:
|
||||
return c.handlePost(ctx, r, opts, isDir, dualReadWriter)
|
||||
case http.MethodPut:
|
||||
return c.handlePut(ctx, r, opts, isDir, dualReadWriter)
|
||||
case http.MethodDelete:
|
||||
return c.handleDelete(ctx, opts, dualReadWriter)
|
||||
default:
|
||||
return nil, apierrors.NewMethodNotSupported(provisioning.RepositoryResourceInfo.GroupResource(), r.Method)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *filesConnector) handleGet(ctx context.Context, opts resources.DualWriteOptions, dualReadWriter *resources.DualReadWriter) (*provisioning.ResourceWrapper, error) {
|
||||
resource, err := dualReadWriter.Read(ctx, opts.Path, opts.Ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resource.AsResourceWrapper(), nil
|
||||
}
|
||||
|
||||
func (c *filesConnector) handlePost(ctx context.Context, r *http.Request, opts resources.DualWriteOptions, isDir bool, dualReadWriter *resources.DualReadWriter) (*provisioning.ResourceWrapper, error) {
|
||||
// Check if this is a move operation (originalPath query parameter is present)
|
||||
if opts.OriginalPath != "" {
|
||||
return c.handleMove(ctx, r, opts, isDir, dualReadWriter)
|
||||
}
|
||||
|
||||
if isDir {
|
||||
return dualReadWriter.CreateFolder(ctx, opts)
|
||||
}
|
||||
|
||||
data, err := readBody(r, filesMaxBodySize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts.Data = data
|
||||
|
||||
resource, err := dualReadWriter.CreateResource(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resource.AsResourceWrapper(), nil
|
||||
}
|
||||
|
||||
func (c *filesConnector) handleMove(ctx context.Context, r *http.Request, opts resources.DualWriteOptions, isDir bool, dualReadWriter *resources.DualReadWriter) (*provisioning.ResourceWrapper, error) {
|
||||
// For move operations, only read body for file moves (not directory moves)
|
||||
if !isDir {
|
||||
data, err := readBody(r, filesMaxBodySize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts.Data = data
|
||||
}
|
||||
|
||||
resource, err := dualReadWriter.MoveResource(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resource.AsResourceWrapper(), nil
|
||||
}
|
||||
|
||||
func (c *filesConnector) handlePut(ctx context.Context, r *http.Request, opts resources.DualWriteOptions, isDir bool, dualReadWriter *resources.DualReadWriter) (*provisioning.ResourceWrapper, error) {
|
||||
if isDir {
|
||||
return nil, apierrors.NewMethodNotSupported(provisioning.RepositoryResourceInfo.GroupResource(), r.Method)
|
||||
}
|
||||
|
||||
data, err := readBody(r, filesMaxBodySize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts.Data = data
|
||||
|
||||
resource, err := dualReadWriter.UpdateResource(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resource.AsResourceWrapper(), nil
|
||||
}
|
||||
|
||||
func (c *filesConnector) handleDelete(ctx context.Context, opts resources.DualWriteOptions, dualReadWriter *resources.DualReadWriter) (*provisioning.ResourceWrapper, error) {
|
||||
resource, err := dualReadWriter.Delete(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resource.AsResourceWrapper(), nil
|
||||
}
|
||||
|
||||
// authorizeListFiles checks if the user has repositories:read permission for listing files.
|
||||
// The access checker handles AccessPolicy identities, namespace resolution, and role-based fallback internally.
|
||||
func (c *filesConnector) authorizeListFiles(ctx context.Context, repoName string) error {
|
||||
return c.access.Check(ctx, authlib.CheckRequest{
|
||||
Verb: utils.VerbGet,
|
||||
Group: provisioning.GROUP,
|
||||
Resource: provisioning.RepositoryResourceInfo.GetName(),
|
||||
Name: repoName,
|
||||
}, "")
|
||||
}
|
||||
|
||||
// listFolderFiles returns a list of files in a folder.
|
||||
// Authorization is checked via authorizeListFiles before calling this function.
|
||||
// listFolderFiles returns a list of files in a folder
|
||||
func (c *filesConnector) listFolderFiles(ctx context.Context, filePath string, ref string, readWriter repository.ReaderWriter) (*provisioning.FileList, error) {
|
||||
id, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("missing auth info in context")
|
||||
}
|
||||
|
||||
// TODO: replace with access check on the repo itself
|
||||
if !id.GetOrgRole().Includes(identity.RoleAdmin) {
|
||||
return nil, apierrors.NewForbidden(resources.DashboardResource.GroupResource(), "",
|
||||
fmt.Errorf("requires admin role"))
|
||||
}
|
||||
|
||||
// TODO: Implement folder navigation
|
||||
if len(filePath) > 0 {
|
||||
return nil, apierrors.NewBadRequest("folder navigation not yet supported")
|
||||
|
||||
@@ -29,7 +29,6 @@ import (
|
||||
"github.com/grafana/grafana-app-sdk/logging"
|
||||
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/auth"
|
||||
connectionvalidation "github.com/grafana/grafana/apps/provisioning/pkg/connection"
|
||||
appcontroller "github.com/grafana/grafana/apps/provisioning/pkg/controller"
|
||||
clientset "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned"
|
||||
@@ -112,10 +111,7 @@ type APIBuilder struct {
|
||||
unified resource.ResourceClient
|
||||
repoFactory repository.Factory
|
||||
client client.ProvisioningV0alpha1Interface
|
||||
access auth.AccessChecker
|
||||
accessWithAdmin auth.AccessChecker
|
||||
accessWithEditor auth.AccessChecker
|
||||
accessWithViewer auth.AccessChecker
|
||||
access authlib.AccessChecker
|
||||
statusPatcher *appcontroller.RepositoryStatusPatcher
|
||||
healthChecker *controller.HealthChecker
|
||||
validator repository.RepositoryValidator
|
||||
@@ -162,14 +158,6 @@ func NewAPIBuilder(
|
||||
parsers := resources.NewParserFactory(clients)
|
||||
resourceLister := resources.NewResourceListerForMigrations(unified)
|
||||
|
||||
// Create access checker based on mode
|
||||
var accessChecker auth.AccessChecker
|
||||
if useExclusivelyAccessCheckerForAuthz {
|
||||
accessChecker = auth.NewTokenAccessChecker(access)
|
||||
} else {
|
||||
accessChecker = auth.NewSessionAccessChecker(access)
|
||||
}
|
||||
|
||||
b := &APIBuilder{
|
||||
onlyApiServer: onlyApiServer,
|
||||
tracer: tracer,
|
||||
@@ -182,10 +170,7 @@ func NewAPIBuilder(
|
||||
resourceLister: resourceLister,
|
||||
dashboardAccess: dashboardAccess,
|
||||
unified: unified,
|
||||
access: accessChecker,
|
||||
accessWithAdmin: accessChecker.WithFallbackRole(identity.RoleAdmin),
|
||||
accessWithEditor: accessChecker.WithFallbackRole(identity.RoleEditor),
|
||||
accessWithViewer: accessChecker.WithFallbackRole(identity.RoleViewer),
|
||||
access: access,
|
||||
jobHistoryConfig: jobHistoryConfig,
|
||||
extraWorkers: extraWorkers,
|
||||
restConfigGetter: restConfigGetter,
|
||||
@@ -313,178 +298,112 @@ func (b *APIBuilder) GetAuthorizer() authorizer.Authorizer {
|
||||
}
|
||||
}
|
||||
|
||||
return b.authorizeResource(ctx, a)
|
||||
info, ok := authlib.AuthInfoFrom(ctx)
|
||||
// when running as standalone API server, the identity type may not always match TypeAccessPolicy
|
||||
// so we allow it to use the access checker if there is any auth info available
|
||||
if ok && (authlib.IsIdentityType(info.GetIdentityType(), authlib.TypeAccessPolicy) || b.useExclusivelyAccessCheckerForAuthz) {
|
||||
res, err := b.access.Check(ctx, info, authlib.CheckRequest{
|
||||
Verb: a.GetVerb(),
|
||||
Group: a.GetAPIGroup(),
|
||||
Resource: a.GetResource(),
|
||||
Name: a.GetName(),
|
||||
Namespace: a.GetNamespace(),
|
||||
Subresource: a.GetSubresource(),
|
||||
Path: a.GetPath(),
|
||||
}, "")
|
||||
if err != nil {
|
||||
return authorizer.DecisionDeny, "failed to perform authorization", err
|
||||
}
|
||||
|
||||
if !res.Allowed {
|
||||
return authorizer.DecisionDeny, "permission denied", nil
|
||||
}
|
||||
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
|
||||
id, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return authorizer.DecisionDeny, "failed to find requester", err
|
||||
}
|
||||
|
||||
return b.authorizeResource(ctx, a, id)
|
||||
})
|
||||
}
|
||||
|
||||
// authorizeResource handles authorization for different resources.
|
||||
// Uses fine-grained permissions defined in accesscontrol.go:
|
||||
//
|
||||
// Repositories:
|
||||
// - CRUD: repositories:create/read/write/delete
|
||||
// - Subresources: files (any auth), refs (editor), resources/history/status (admin)
|
||||
// - Test: repositories:write
|
||||
// - Jobs subresource: jobs:create/read
|
||||
//
|
||||
// Connections:
|
||||
// - CRUD: connections:create/read/write/delete
|
||||
// - Status: connections:read
|
||||
//
|
||||
// Jobs:
|
||||
// - CRUD: jobs:create/read/write/delete
|
||||
//
|
||||
// Historic Jobs:
|
||||
// - Read-only: historicjobs:read
|
||||
//
|
||||
// Settings:
|
||||
// - settings:read - granted to Viewer (all logged-in users)
|
||||
//
|
||||
// Stats:
|
||||
// - stats:read - granted to Admin only
|
||||
func (b *APIBuilder) authorizeResource(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) {
|
||||
// Different routes may need different permissions.
|
||||
// * Reading and modifying a repository's configuration requires administrator privileges.
|
||||
// * Reading a repository's limited configuration (/stats & /settings) requires viewer privileges.
|
||||
// * Reading a repository's files requires viewer privileges.
|
||||
// * Reading a repository's refs requires viewer privileges.
|
||||
// * Editing a repository's files requires editor privileges.
|
||||
// * Syncing a repository requires editor privileges.
|
||||
// * Exporting a repository requires administrator privileges.
|
||||
// * Migrating a repository requires administrator privileges.
|
||||
// * Testing a repository configuration requires administrator privileges.
|
||||
// * Viewing a repository's history requires editor privileges.
|
||||
func (b *APIBuilder) authorizeResource(ctx context.Context, a authorizer.Attributes, id identity.Requester) (authorizer.Decision, string, error) {
|
||||
switch a.GetResource() {
|
||||
case provisioning.RepositoryResourceInfo.GetName():
|
||||
return b.authorizeRepositorySubresource(ctx, a)
|
||||
case provisioning.ConnectionResourceInfo.GetName():
|
||||
return b.authorizeConnectionSubresource(ctx, a)
|
||||
case provisioning.JobResourceInfo.GetName():
|
||||
return toAuthorizerDecision(b.accessWithEditor.Check(ctx, authlib.CheckRequest{
|
||||
Verb: a.GetVerb(),
|
||||
Group: provisioning.GROUP,
|
||||
Resource: provisioning.JobResourceInfo.GetName(),
|
||||
Name: a.GetName(),
|
||||
Namespace: a.GetNamespace(),
|
||||
}, ""))
|
||||
case provisioning.HistoricJobResourceInfo.GetName():
|
||||
// Historic jobs are read-only and admin-only (not editor)
|
||||
return toAuthorizerDecision(b.accessWithAdmin.Check(ctx, authlib.CheckRequest{
|
||||
Verb: a.GetVerb(),
|
||||
Group: provisioning.GROUP,
|
||||
Resource: provisioning.HistoricJobResourceInfo.GetName(),
|
||||
Name: a.GetName(),
|
||||
Namespace: a.GetNamespace(),
|
||||
}, ""))
|
||||
case "settings":
|
||||
// Settings are read-only and accessible by all logged-in users (Viewer role)
|
||||
return toAuthorizerDecision(b.accessWithViewer.Check(ctx, authlib.CheckRequest{
|
||||
Verb: a.GetVerb(),
|
||||
Group: provisioning.GROUP,
|
||||
Resource: "settings",
|
||||
Namespace: a.GetNamespace(),
|
||||
}, ""))
|
||||
return b.authorizeRepositorySubresource(a, id)
|
||||
case "stats":
|
||||
// Stats are read-only and admin-only
|
||||
return toAuthorizerDecision(b.accessWithAdmin.Check(ctx, authlib.CheckRequest{
|
||||
Verb: a.GetVerb(),
|
||||
Group: provisioning.GROUP,
|
||||
Resource: "stats",
|
||||
Namespace: a.GetNamespace(),
|
||||
}, ""))
|
||||
return b.authorizeStats(id)
|
||||
case "settings":
|
||||
return b.authorizeSettings(id)
|
||||
case provisioning.JobResourceInfo.GetName(), provisioning.HistoricJobResourceInfo.GetName():
|
||||
return b.authorizeJobs(id)
|
||||
case provisioning.ConnectionResourceInfo.GetName():
|
||||
return b.authorizeConnectionSubresource(a, id)
|
||||
default:
|
||||
return b.authorizeDefault(ctx)
|
||||
return b.authorizeDefault(id)
|
||||
}
|
||||
}
|
||||
|
||||
// authorizeRepositorySubresource handles authorization for repository subresources.
|
||||
// Uses the access checker with verb-based authorization.
|
||||
func (b *APIBuilder) authorizeRepositorySubresource(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) {
|
||||
func (b *APIBuilder) authorizeRepositorySubresource(a authorizer.Attributes, id identity.Requester) (authorizer.Decision, string, error) {
|
||||
// TODO: Support more fine-grained permissions than the basic roles. Especially on Enterprise.
|
||||
switch a.GetSubresource() {
|
||||
// Repository CRUD - use access checker with the actual verb
|
||||
case "":
|
||||
return toAuthorizerDecision(b.accessWithAdmin.Check(ctx, authlib.CheckRequest{
|
||||
Verb: a.GetVerb(),
|
||||
Group: provisioning.GROUP,
|
||||
Resource: provisioning.RepositoryResourceInfo.GetName(),
|
||||
Name: a.GetName(),
|
||||
Namespace: a.GetNamespace(),
|
||||
}, ""))
|
||||
case "", "test":
|
||||
// Doing something with the repository itself.
|
||||
if id.GetOrgRole().Includes(identity.RoleAdmin) {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
return authorizer.DecisionDeny, "admin role is required", nil
|
||||
|
||||
// Test requires write permission (testing before save)
|
||||
case "test":
|
||||
return toAuthorizerDecision(b.accessWithAdmin.Check(ctx, authlib.CheckRequest{
|
||||
Verb: apiutils.VerbUpdate,
|
||||
Group: provisioning.GROUP,
|
||||
Resource: provisioning.RepositoryResourceInfo.GetName(),
|
||||
Name: a.GetName(),
|
||||
Namespace: a.GetNamespace(),
|
||||
}, ""))
|
||||
case "jobs":
|
||||
// Posting jobs requires editor privileges (for syncing).
|
||||
if id.GetOrgRole().Includes(identity.RoleAdmin) || id.GetOrgRole().Includes(identity.RoleEditor) {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
return authorizer.DecisionDeny, "editor role is required", nil
|
||||
|
||||
case "refs":
|
||||
// This is strictly a read operation. It is handy on the frontend for viewers.
|
||||
if id.GetOrgRole().Includes(identity.RoleViewer) {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
return authorizer.DecisionDeny, "viewer role is required", nil
|
||||
|
||||
// Files subresource: allow any authenticated user at route level.
|
||||
// Directory listing checks repositories:read in the connector.
|
||||
// Individual file operations are authorized by DualReadWriter based on the actual resource.
|
||||
case "files":
|
||||
// Access to files is controlled by the AccessClient
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
|
||||
// refs subresource - editors need to see branches to push changes
|
||||
case "refs":
|
||||
return toAuthorizerDecision(b.accessWithEditor.Check(ctx, authlib.CheckRequest{
|
||||
Verb: apiutils.VerbGet,
|
||||
Group: provisioning.GROUP,
|
||||
Resource: provisioning.RepositoryResourceInfo.GetName(),
|
||||
Name: a.GetName(),
|
||||
Namespace: a.GetNamespace(),
|
||||
}, ""))
|
||||
|
||||
// Read-only subresources: resources, history, status (admin only)
|
||||
case "resources", "history", "status":
|
||||
return toAuthorizerDecision(b.accessWithAdmin.Check(ctx, authlib.CheckRequest{
|
||||
Verb: apiutils.VerbGet,
|
||||
Group: provisioning.GROUP,
|
||||
Resource: provisioning.RepositoryResourceInfo.GetName(),
|
||||
Name: a.GetName(),
|
||||
Namespace: a.GetNamespace(),
|
||||
}, ""))
|
||||
|
||||
// Jobs subresource - check jobs permissions with the verb (editors can manage jobs)
|
||||
case "jobs":
|
||||
return toAuthorizerDecision(b.accessWithEditor.Check(ctx, authlib.CheckRequest{
|
||||
Verb: a.GetVerb(),
|
||||
Group: provisioning.GROUP,
|
||||
Resource: provisioning.JobResourceInfo.GetName(),
|
||||
Namespace: a.GetNamespace(),
|
||||
}, ""))
|
||||
|
||||
default:
|
||||
id, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return authorizer.DecisionDeny, "failed to find requester", err
|
||||
}
|
||||
if id.GetIsGrafanaAdmin() {
|
||||
case "resources", "sync", "history":
|
||||
// These are strictly read operations.
|
||||
// Sync can also be somewhat destructive, but it's expected to be fine to import changes.
|
||||
if id.GetOrgRole().Includes(identity.RoleEditor) {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
return authorizer.DecisionDeny, "unmapped subresource defaults to no access", nil
|
||||
}
|
||||
}
|
||||
return authorizer.DecisionDeny, "editor role is required", nil
|
||||
|
||||
// authorizeConnectionSubresource handles authorization for connection subresources.
|
||||
// Uses the access checker with verb-based authorization.
|
||||
func (b *APIBuilder) authorizeConnectionSubresource(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) {
|
||||
switch a.GetSubresource() {
|
||||
// Connection CRUD - use access checker with the actual verb
|
||||
case "":
|
||||
return toAuthorizerDecision(b.accessWithAdmin.Check(ctx, authlib.CheckRequest{
|
||||
Verb: a.GetVerb(),
|
||||
Group: provisioning.GROUP,
|
||||
Resource: provisioning.ConnectionResourceInfo.GetName(),
|
||||
Name: a.GetName(),
|
||||
Namespace: a.GetNamespace(),
|
||||
}, ""))
|
||||
|
||||
// Status is read-only
|
||||
case "status":
|
||||
return toAuthorizerDecision(b.accessWithAdmin.Check(ctx, authlib.CheckRequest{
|
||||
Verb: apiutils.VerbGet,
|
||||
Group: provisioning.GROUP,
|
||||
Resource: provisioning.ConnectionResourceInfo.GetName(),
|
||||
Name: a.GetName(),
|
||||
Namespace: a.GetNamespace(),
|
||||
}, ""))
|
||||
if id.GetOrgRole().Includes(identity.RoleViewer) && a.GetVerb() == apiutils.VerbGet {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
return authorizer.DecisionDeny, "users cannot update the status of a repository", nil
|
||||
|
||||
default:
|
||||
id, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return authorizer.DecisionDeny, "failed to find requester", err
|
||||
}
|
||||
if id.GetIsGrafanaAdmin() {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
@@ -492,24 +411,57 @@ func (b *APIBuilder) authorizeConnectionSubresource(ctx context.Context, a autho
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Authorization helpers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// toAuthorizerDecision converts an access check error to an authorizer decision tuple.
|
||||
func toAuthorizerDecision(err error) (authorizer.Decision, string, error) {
|
||||
if err != nil {
|
||||
return authorizer.DecisionDeny, err.Error(), nil
|
||||
// authorizeStats handles authorization for stats resource.
|
||||
func (b *APIBuilder) authorizeStats(id identity.Requester) (authorizer.Decision, string, error) {
|
||||
// This can leak information one shouldn't necessarily have access to.
|
||||
if id.GetOrgRole().Includes(identity.RoleAdmin) {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
return authorizer.DecisionDeny, "admin role is required", nil
|
||||
}
|
||||
|
||||
// authorizeSettings handles authorization for settings resource.
|
||||
func (b *APIBuilder) authorizeSettings(id identity.Requester) (authorizer.Decision, string, error) {
|
||||
// This is strictly a read operation. It is handy on the frontend for viewers.
|
||||
if id.GetOrgRole().Includes(identity.RoleViewer) {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
return authorizer.DecisionDeny, "viewer role is required", nil
|
||||
}
|
||||
|
||||
// authorizeJobs handles authorization for job resources.
|
||||
func (b *APIBuilder) authorizeJobs(id identity.Requester) (authorizer.Decision, string, error) {
|
||||
// Jobs are shown on the configuration page.
|
||||
if id.GetOrgRole().Includes(identity.RoleAdmin) {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
return authorizer.DecisionDeny, "admin role is required", nil
|
||||
}
|
||||
|
||||
// authorizeRepositorySubresource handles authorization for connections subresources.
|
||||
func (b *APIBuilder) authorizeConnectionSubresource(a authorizer.Attributes, id identity.Requester) (authorizer.Decision, string, error) {
|
||||
switch a.GetSubresource() {
|
||||
case "":
|
||||
// Doing something with the connection itself.
|
||||
if id.GetOrgRole().Includes(identity.RoleAdmin) {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
return authorizer.DecisionDeny, "admin role is required", nil
|
||||
case "status":
|
||||
if id.GetOrgRole().Includes(identity.RoleViewer) && a.GetVerb() == apiutils.VerbGet {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
return authorizer.DecisionDeny, "users cannot update the status of a connection", nil
|
||||
default:
|
||||
if id.GetIsGrafanaAdmin() {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
return authorizer.DecisionDeny, "unmapped subresource defaults to no access", nil
|
||||
}
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
|
||||
// authorizeDefault handles authorization for unmapped resources.
|
||||
func (b *APIBuilder) authorizeDefault(ctx context.Context) (authorizer.Decision, string, error) {
|
||||
id, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return authorizer.DecisionDeny, "failed to find requester", err
|
||||
}
|
||||
func (b *APIBuilder) authorizeDefault(id identity.Requester) (authorizer.Decision, string, error) {
|
||||
// We haven't bothered with this kind yet.
|
||||
if id.GetIsGrafanaAdmin() {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
@@ -606,7 +558,7 @@ func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupI
|
||||
|
||||
// TODO: Add some logic so that the connectors can registered themselves and we don't have logic all over the place
|
||||
storage[provisioning.RepositoryResourceInfo.StoragePath("test")] = NewTestConnector(b, repository.NewRepositoryTesterWithExistingChecker(repository.NewSimpleRepositoryTester(b.validator), b.VerifyAgainstExistingRepositories))
|
||||
storage[provisioning.RepositoryResourceInfo.StoragePath("files")] = NewFilesConnector(b, b.parsers, b.clients, b.accessWithAdmin)
|
||||
storage[provisioning.RepositoryResourceInfo.StoragePath("files")] = NewFilesConnector(b, b.parsers, b.clients, b.access)
|
||||
storage[provisioning.RepositoryResourceInfo.StoragePath("refs")] = NewRefsConnector(b)
|
||||
storage[provisioning.RepositoryResourceInfo.StoragePath("resources")] = &listConnector{
|
||||
getter: b,
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
authlib "github.com/grafana/authlib/types"
|
||||
"github.com/grafana/grafana-app-sdk/logging"
|
||||
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/auth"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
|
||||
"github.com/grafana/grafana/apps/provisioning/pkg/safepath"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
|
||||
@@ -33,7 +32,7 @@ type DualReadWriter struct {
|
||||
repo repository.ReaderWriter
|
||||
parser Parser
|
||||
folders *FolderManager
|
||||
access auth.AccessChecker
|
||||
access authlib.AccessChecker
|
||||
}
|
||||
|
||||
type DualWriteOptions struct {
|
||||
@@ -49,7 +48,7 @@ type DualWriteOptions struct {
|
||||
Branch string // Configured default branch
|
||||
}
|
||||
|
||||
func NewDualReadWriter(repo repository.ReaderWriter, parser Parser, folders *FolderManager, access auth.AccessChecker) *DualReadWriter {
|
||||
func NewDualReadWriter(repo repository.ReaderWriter, parser Parser, folders *FolderManager, access authlib.AccessChecker) *DualReadWriter {
|
||||
return &DualReadWriter{repo: repo, parser: parser, folders: folders, access: access}
|
||||
}
|
||||
|
||||
@@ -493,6 +492,11 @@ func (r *DualReadWriter) moveFile(ctx context.Context, opts DualWriteOptions) (*
|
||||
}
|
||||
|
||||
func (r *DualReadWriter) authorize(ctx context.Context, parsed *ParsedResource, verb string) error {
|
||||
id, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return apierrors.NewUnauthorized(err.Error())
|
||||
}
|
||||
|
||||
var name string
|
||||
if parsed.Existing != nil {
|
||||
name = parsed.Existing.GetName()
|
||||
@@ -500,15 +504,27 @@ func (r *DualReadWriter) authorize(ctx context.Context, parsed *ParsedResource,
|
||||
name = parsed.Obj.GetName()
|
||||
}
|
||||
|
||||
return r.access.Check(ctx, authlib.CheckRequest{
|
||||
Group: parsed.GVR.Group,
|
||||
Resource: parsed.GVR.Resource,
|
||||
Name: name,
|
||||
Verb: verb,
|
||||
rsp, err := r.access.Check(ctx, id, authlib.CheckRequest{
|
||||
Group: parsed.GVR.Group,
|
||||
Resource: parsed.GVR.Resource,
|
||||
Namespace: id.GetNamespace(),
|
||||
Name: name,
|
||||
Verb: verb,
|
||||
}, parsed.Meta.GetFolder())
|
||||
if err != nil || !rsp.Allowed {
|
||||
return apierrors.NewForbidden(parsed.GVR.GroupResource(), parsed.Obj.GetName(),
|
||||
fmt.Errorf("no access to perform %s on the resource", verb))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *DualReadWriter) authorizeCreateFolder(ctx context.Context, path string) error {
|
||||
id, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return apierrors.NewUnauthorized(err.Error())
|
||||
}
|
||||
|
||||
// Determine parent folder from path
|
||||
parentFolder := ""
|
||||
if path != "" {
|
||||
@@ -521,12 +537,19 @@ func (r *DualReadWriter) authorizeCreateFolder(ctx context.Context, path string)
|
||||
}
|
||||
|
||||
// For folder create operations, use empty name to check parent folder permissions
|
||||
return r.access.Check(ctx, authlib.CheckRequest{
|
||||
Group: FolderResource.Group,
|
||||
Resource: FolderResource.Resource,
|
||||
Name: "", // Empty name for create operations
|
||||
Verb: utils.VerbCreate,
|
||||
rsp, err := r.access.Check(ctx, id, authlib.CheckRequest{
|
||||
Group: FolderResource.Group,
|
||||
Resource: FolderResource.Resource,
|
||||
Namespace: id.GetNamespace(),
|
||||
Name: "", // Empty name for create operations
|
||||
Verb: utils.VerbCreate,
|
||||
}, parentFolder)
|
||||
if err != nil || !rsp.Allowed {
|
||||
return apierrors.NewForbidden(FolderResource.GroupResource(), path,
|
||||
fmt.Errorf("no access to create folder in parent folder '%s'", parentFolder))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *DualReadWriter) deleteFolder(ctx context.Context, opts DualWriteOptions) (*ParsedResource, error) {
|
||||
|
||||
@@ -24,38 +24,26 @@ func GetAuthorizer() authorizer.Authorizer {
|
||||
return authorizer.DecisionDeny, "valid user is required", err
|
||||
}
|
||||
|
||||
// check if is admin
|
||||
if u.GetIsGrafanaAdmin() {
|
||||
return authorizer.DecisionAllow, "isGrafanaAdmin", nil
|
||||
}
|
||||
|
||||
// Auth handling for LogsDrilldownDefaults resource
|
||||
if attr.GetResource() == "logsdrilldowndefaults" {
|
||||
// Allow list and get for everyone
|
||||
if attr.GetVerb() == "list" || attr.GetVerb() == "get" {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
// Only allow admins to update (create, update, patch, delete)
|
||||
if u.GetIsGrafanaAdmin() {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
// Deny all other operations for non-admins
|
||||
return authorizer.DecisionDeny, "admin access required", nil
|
||||
}
|
||||
|
||||
p := u.GetPermissions()
|
||||
|
||||
// Auth handling for Logs Drilldown default columns
|
||||
if attr.GetResource() == "logsdrilldowndefaultcolumns" {
|
||||
// Allow get for all users
|
||||
if attr.GetVerb() == "get" {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
// require plugins:write permissions for other operations
|
||||
_, ok := p[accesscontrol.PluginRolePrefix+"write"]
|
||||
if ok {
|
||||
return authorizer.DecisionAllow, "user has plugins:write", nil
|
||||
} else {
|
||||
return authorizer.DecisionDeny, "user missing plugins:write", nil
|
||||
}
|
||||
// check if is admin
|
||||
if u.GetIsGrafanaAdmin() {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
|
||||
p := u.GetPermissions()
|
||||
if len(p) == 0 {
|
||||
return authorizer.DecisionDeny, "no permissions", nil
|
||||
}
|
||||
|
||||
@@ -279,11 +279,8 @@ func NewMapperRegistry() MapperRegistry {
|
||||
},
|
||||
"provisioning.grafana.app": {
|
||||
"repositories": newResourceTranslation("provisioning.repositories", "uid", false, skipScopeOnAllVerbs),
|
||||
"connections": newResourceTranslation("provisioning.connections", "uid", false, skipScopeOnAllVerbs),
|
||||
"jobs": newResourceTranslation("provisioning.jobs", "uid", false, skipScopeOnAllVerbs),
|
||||
"historicjobs": newResourceTranslation("provisioning.historicjobs", "uid", false, skipScopeOnAllVerbs),
|
||||
"settings": newResourceTranslation("provisioning.settings", "", false, skipScopeOnAllVerbs),
|
||||
"stats": newResourceTranslation("provisioning.stats", "", false, skipScopeOnAllVerbs),
|
||||
},
|
||||
"secret.grafana.app": {
|
||||
"securevalues": newResourceTranslation("secret.securevalues", "uid", false, nil),
|
||||
|
||||
@@ -2344,18 +2344,13 @@ func (dr *DashboardServiceImpl) unstructuredToLegacyDashboardWithUsers(item *uns
|
||||
dashVersion := obj.GetGeneration()
|
||||
spec["version"] = dashVersion
|
||||
|
||||
folderUID := obj.GetFolder()
|
||||
if folderUID == folder.GeneralFolderUID {
|
||||
folderUID = "" // empty in legacy API
|
||||
}
|
||||
|
||||
title, _, _ := unstructured.NestedString(spec, "title")
|
||||
out := dashboards.Dashboard{
|
||||
OrgID: orgID,
|
||||
ID: obj.GetDeprecatedInternalID(), // nolint:staticcheck
|
||||
UID: uid,
|
||||
Slug: slugify.Slugify(title),
|
||||
FolderUID: folderUID,
|
||||
FolderUID: obj.GetFolder(),
|
||||
Version: int(dashVersion),
|
||||
Data: simplejson.NewFromAny(spec),
|
||||
APIVersion: strings.TrimPrefix(item.GetAPIVersion(), dashboardv0.GROUP+"/"),
|
||||
|
||||
@@ -885,13 +885,6 @@ var (
|
||||
Owner: grafanaAlertingSquad,
|
||||
FrontendOnly: true,
|
||||
},
|
||||
{
|
||||
Name: "alertingSavedSearches",
|
||||
Description: "Enables saved searches for alert rules list",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaAlertingSquad,
|
||||
FrontendOnly: true,
|
||||
},
|
||||
{
|
||||
Name: "alertingDisableSendAlertsExternal",
|
||||
Description: "Disables the ability to send alerts to an external Alertmanager datasource.",
|
||||
|
||||
1
pkg/services/featuremgmt/toggles_gen.csv
generated
1
pkg/services/featuremgmt/toggles_gen.csv
generated
@@ -122,7 +122,6 @@ 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
|
||||
preserveDashboardStateWhenNavigating,experimental,@grafana/dashboards-squad,false,false,false
|
||||
alertingCentralAlertHistory,experimental,@grafana/alerting-squad,false,false,false
|
||||
|
||||
|
13
pkg/services/featuremgmt/toggles_gen.json
generated
13
pkg/services/featuremgmt/toggles_gen.json
generated
@@ -498,19 +498,6 @@
|
||||
"codeowner": "@grafana/alerting-squad"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "alertingSavedSearches",
|
||||
"resourceVersion": "1765453147546",
|
||||
"creationTimestamp": "2025-12-11T11:39:07Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables saved searches for alert rules list",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/alerting-squad",
|
||||
"frontend": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "alertingTriage",
|
||||
|
||||
@@ -32,7 +32,7 @@ func convertUnstructuredToFolder(item *unstructured.Unstructured, identifiers ma
|
||||
|
||||
uid := meta.GetName()
|
||||
url := ""
|
||||
if !folder.IsRootFolder(uid) {
|
||||
if uid != folder.RootFolder.UID {
|
||||
slug := slugify.Slugify(title)
|
||||
url = dashboards.GetFolderURL(uid, slug)
|
||||
}
|
||||
@@ -62,18 +62,13 @@ func convertUnstructuredToFolder(item *unstructured.Unstructured, identifiers ma
|
||||
}
|
||||
}
|
||||
|
||||
parent := meta.GetFolder()
|
||||
if folder.IsRootFolder(parent) {
|
||||
parent = ""
|
||||
}
|
||||
|
||||
manager, _ := meta.GetManagerProperties()
|
||||
return &folder.Folder{
|
||||
UID: uid,
|
||||
Title: title,
|
||||
Description: description,
|
||||
ID: meta.GetDeprecatedInternalID(), // nolint:staticcheck
|
||||
ParentUID: parent,
|
||||
ParentUID: meta.GetFolder(),
|
||||
Version: int(meta.GetGeneration()),
|
||||
ManagedBy: manager.Kind,
|
||||
|
||||
|
||||
@@ -1283,10 +1283,6 @@ func (s *Service) buildSaveDashboardCommand(ctx context.Context, dto *dashboards
|
||||
return nil, dashboards.ErrDashboardFolderNameExists
|
||||
}
|
||||
|
||||
if dash.FolderUID == folder.GeneralFolderUID {
|
||||
dash.FolderUID = "" // general is the same as root
|
||||
}
|
||||
|
||||
if dash.FolderUID != "" {
|
||||
if _, err := s.dashboardFolderStore.GetFolderByUID(ctx, dash.OrgID, dash.FolderUID); err != nil {
|
||||
return nil, err
|
||||
@@ -1384,7 +1380,7 @@ func SplitFullpath(s string) []string {
|
||||
func (s *Service) nestedFolderCreate(ctx context.Context, cmd *folder.CreateFolderCommand) (*folder.Folder, error) {
|
||||
ctx, span := s.tracer.Start(ctx, "folder.nestedFolderCreate")
|
||||
defer span.End()
|
||||
if !folder.IsRootFolder(cmd.ParentUID) {
|
||||
if cmd.ParentUID != "" {
|
||||
if err := s.validateParent(ctx, cmd.OrgID, cmd.ParentUID, cmd.UID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -726,6 +726,19 @@ func (s *Service) moveOnApiServer(ctx context.Context, cmd *folder.MoveFolderCom
|
||||
return nil, folder.ErrBadRequest.Errorf("k6 project may not be moved")
|
||||
}
|
||||
|
||||
f, err := s.unifiedStore.Get(ctx, folder.GetFolderQuery{
|
||||
UID: &cmd.UID,
|
||||
OrgID: cmd.OrgID,
|
||||
SignedInUser: cmd.SignedInUser,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if f != nil && f.ParentUID == accesscontrol.K6FolderUID {
|
||||
return nil, folder.ErrBadRequest.Errorf("k6 project may not be moved")
|
||||
}
|
||||
|
||||
// Check that the user is allowed to move the folder to the destination folder
|
||||
hasAccess, evalErr := s.canMoveViaApiServer(ctx, cmd)
|
||||
if evalErr != nil {
|
||||
@@ -735,7 +748,30 @@ func (s *Service) moveOnApiServer(ctx context.Context, cmd *folder.MoveFolderCom
|
||||
return nil, dashboards.ErrFolderAccessDenied
|
||||
}
|
||||
|
||||
f, err := s.unifiedStore.Update(ctx, folder.UpdateFolderCommand{
|
||||
// here we get the folder, we need to get the height of current folder
|
||||
// and the depth of the new parent folder, the sum can't bypass 8
|
||||
folderHeight, err := s.unifiedStore.GetHeight(ctx, cmd.UID, cmd.OrgID, &cmd.NewParentUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parents, err := s.unifiedStore.GetParents(ctx, folder.GetParentsQuery{UID: cmd.NewParentUID, OrgID: cmd.OrgID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// height of the folder that is being moved + this current folder itself + depth of the NewParent folder should be less than or equal MaxNestedFolderDepth
|
||||
if folderHeight+len(parents)+1 > folder.MaxNestedFolderDepth {
|
||||
return nil, folder.ErrMaximumDepthReached.Errorf("failed to move folder")
|
||||
}
|
||||
|
||||
for _, parent := range parents {
|
||||
// if the current folder is already a parent of newparent, we should return error
|
||||
if parent.UID == cmd.UID {
|
||||
return nil, folder.ErrCircularReference.Errorf("failed to move folder")
|
||||
}
|
||||
}
|
||||
|
||||
f, err = s.unifiedStore.Update(ctx, folder.UpdateFolderCommand{
|
||||
UID: cmd.UID,
|
||||
OrgID: cmd.OrgID,
|
||||
NewParentUID: &cmd.NewParentUID,
|
||||
|
||||
@@ -180,7 +180,7 @@ func (ss *FolderUnifiedStoreImpl) GetParents(ctx context.Context, q folder.GetPa
|
||||
hits := []*folder.Folder{}
|
||||
|
||||
parentUID := q.UID
|
||||
for !folder.IsRootFolder(parentUID) {
|
||||
for parentUID != "" {
|
||||
folder, err := ss.Get(ctx, folder.GetFolderQuery{UID: &parentUID, OrgID: q.OrgID})
|
||||
if err != nil {
|
||||
if apierrors.IsForbidden(err) {
|
||||
|
||||
@@ -31,10 +31,6 @@ const (
|
||||
SharedWithMeFolderUID = "sharedwithme"
|
||||
)
|
||||
|
||||
func IsRootFolder(f string) bool {
|
||||
return f == "" || f == GeneralFolderUID
|
||||
}
|
||||
|
||||
var ErrFolderNotFound = errutil.NotFound("folder.notFound")
|
||||
|
||||
type Folder struct {
|
||||
|
||||
@@ -76,20 +76,6 @@ func (v *objectForStorage) finish(ctx context.Context, err error, secrets secret
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Storage) verifyFolder(obj utils.GrafanaMetaAccessor) error {
|
||||
if s.opts.EnableFolderSupport {
|
||||
if obj.GetFolder() == "" {
|
||||
// return apierrors.NewBadRequest("missing folder annotation")
|
||||
// TODO?: should this be optionally be done in a mutation webhook?
|
||||
// ???? obj.SetFolder(folder.GeneralFolderUID) // always enter something
|
||||
return nil
|
||||
}
|
||||
} else if obj.GetFolder() != "" {
|
||||
return apierrors.NewBadRequest("folders not supported in this resource")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Called on create
|
||||
func (s *Storage) prepareObjectForStorage(ctx context.Context, newObject runtime.Object) (objectForStorage, error) {
|
||||
v := objectForStorage{}
|
||||
@@ -117,9 +103,6 @@ func (s *Storage) prepareObjectForStorage(ctx context.Context, newObject runtime
|
||||
if s.opts.MaximumNameLength > 0 && len(obj.GetName()) > s.opts.MaximumNameLength {
|
||||
return v, apierrors.NewBadRequest(fmt.Sprintf("name exceeds maximum length (%d)", s.opts.MaximumNameLength))
|
||||
}
|
||||
if err = s.verifyFolder(obj); err != nil {
|
||||
return v, err
|
||||
}
|
||||
|
||||
v.grantPermissions = obj.GetAnnotation(utils.AnnoKeyGrantPermissions)
|
||||
if v.grantPermissions != "" {
|
||||
@@ -210,15 +193,17 @@ func (s *Storage) prepareObjectForUpdate(ctx context.Context, updateObject runti
|
||||
obj.SetDeprecatedInternalID(previousInternalID) // nolint:staticcheck
|
||||
}
|
||||
|
||||
if err = prepareSecureValues(ctx, s.opts.SecureValues, obj, previous, &v); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = s.verifyFolder(obj); err != nil {
|
||||
err = prepareSecureValues(ctx, s.opts.SecureValues, obj, previous, &v)
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
|
||||
// Check if we should bump the generation
|
||||
if obj.GetFolder() != previous.GetFolder() {
|
||||
if !s.opts.EnableFolderSupport {
|
||||
return v, apierrors.NewBadRequest(fmt.Sprintf("folders are not supported for: %s", s.gr.String()))
|
||||
}
|
||||
// TODO: check that we can move the folder?
|
||||
v.hasChanged = true
|
||||
} else if obj.GetDeletionTimestamp() != nil && previous.GetDeletionTimestamp() == nil {
|
||||
v.hasChanged = true // bump generation when deleted
|
||||
|
||||
@@ -209,13 +209,6 @@ func (s *Storage) convertToObject(ctx context.Context, data []byte, obj runtime.
|
||||
_, span := tracer.Start(ctx, "apistore.Storage.convertToObject")
|
||||
defer span.End()
|
||||
obj, _, err := s.codec.Decode(data, nil, obj)
|
||||
// TODO!!! Replace empty folder with "general" on read (this was not a requirement early on)
|
||||
// if s.opts.EnableFolderSupport {
|
||||
// m, _ := utils.MetaAccessor(obj)
|
||||
// if m != nil && m.GetFolder() == "" {
|
||||
// m.SetFolder(folder.GeneralFolderUID)
|
||||
// }
|
||||
// }
|
||||
return obj, err
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
DELETE
|
||||
FROM {{ .TableName }}
|
||||
WHERE {{ .Ident "key_path" }} IN (
|
||||
{{ range $id, $key_path := .KeyPaths }}
|
||||
{{ if ne $id 0 }}, {{ end }}{{ $.Arg $key_path }}
|
||||
{{ end }}
|
||||
);
|
||||
@@ -1,5 +0,0 @@
|
||||
DELETE FROM {{ .Ident "resource" }}
|
||||
WHERE {{ .Ident "group" }} = {{ .Arg .Group }}
|
||||
AND {{ .Ident "resource" }} = {{ .Arg .Resource }}
|
||||
AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }}
|
||||
AND {{ .Ident "name" }} = {{ .Arg .Name }};
|
||||
@@ -1,21 +0,0 @@
|
||||
INSERT INTO {{ .Ident .TableName }}
|
||||
(
|
||||
{{ .Ident "guid" }},
|
||||
{{ .Ident "key_path" }},
|
||||
{{ .Ident "value" }},
|
||||
{{ .Ident "group" }},
|
||||
{{ .Ident "resource" }},
|
||||
{{ .Ident "namespace" }},
|
||||
{{ .Ident "name" }},
|
||||
{{ .Ident "action" }}
|
||||
)
|
||||
VALUES (
|
||||
{{ .Arg .GUID }},
|
||||
{{ .Arg .KeyPath }},
|
||||
COALESCE({{ .Arg .Value }}, ""),
|
||||
{{ .Arg .Group }},
|
||||
{{ .Arg .Resource }},
|
||||
{{ .Arg .Namespace }},
|
||||
{{ .Arg .Name }},
|
||||
{{ .Arg .Action }}
|
||||
);
|
||||
@@ -1,31 +0,0 @@
|
||||
INSERT INTO {{ .Ident "resource" }}
|
||||
(
|
||||
{{ .Ident "value" }},
|
||||
{{ .Ident "guid" }},
|
||||
{{ .Ident "group" }},
|
||||
{{ .Ident "resource" }},
|
||||
{{ .Ident "namespace" }},
|
||||
{{ .Ident "name" }},
|
||||
{{ .Ident "action" }},
|
||||
{{ .Ident "folder" }},
|
||||
{{ .Ident "previous_resource_version" }}
|
||||
)
|
||||
VALUES (
|
||||
COALESCE({{ .Arg .Value }}, ""),
|
||||
{{ .Arg .GUID }},
|
||||
{{ .Arg .Group }},
|
||||
{{ .Arg .Resource }},
|
||||
{{ .Arg .Namespace }},
|
||||
{{ .Arg .Name }},
|
||||
{{ .Arg .Action }},
|
||||
{{ .Arg .Folder }},
|
||||
CASE WHEN {{ .Arg .Action }} = 1 THEN 0 ELSE (
|
||||
SELECT {{ .Ident "resource_version" }}
|
||||
FROM {{ .Ident "resource" }}
|
||||
WHERE {{ .Ident "group" }} = {{ .Arg .Group }}
|
||||
AND {{ .Ident "resource" }} = {{ .Arg .Resource }}
|
||||
AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }}
|
||||
AND {{ .Ident "name" }} = {{ .Arg .Name }}
|
||||
ORDER BY {{ .Ident "resource_version" }} DESC LIMIT 1
|
||||
) END
|
||||
);
|
||||
@@ -1,44 +0,0 @@
|
||||
INSERT INTO {{ .Ident "resource_history" }}
|
||||
(
|
||||
{{ .Ident "value" }},
|
||||
{{ .Ident "guid" }},
|
||||
{{ .Ident "group" }},
|
||||
{{ .Ident "resource" }},
|
||||
{{ .Ident "namespace" }},
|
||||
{{ .Ident "name" }},
|
||||
{{ .Ident "action" }},
|
||||
{{ .Ident "folder" }},
|
||||
{{ .Ident "previous_resource_version" }},
|
||||
{{ .Ident "generation" }}
|
||||
)
|
||||
VALUES (
|
||||
COALESCE({{ .Arg .Value }}, ""),
|
||||
{{ .Arg .GUID }},
|
||||
{{ .Arg .Group }},
|
||||
{{ .Arg .Resource }},
|
||||
{{ .Arg .Namespace }},
|
||||
{{ .Arg .Name }},
|
||||
{{ .Arg .Action }},
|
||||
{{ .Arg .Folder }},
|
||||
CASE WHEN {{ .Arg .Action }} = 1 THEN 0 ELSE (
|
||||
SELECT {{ .Ident "resource_version" }}
|
||||
FROM {{ .Ident "resource_history" }}
|
||||
WHERE {{ .Ident "group" }} = {{ .Arg .Group }}
|
||||
AND {{ .Ident "resource" }} = {{ .Arg .Resource }}
|
||||
AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }}
|
||||
AND {{ .Ident "name" }} = {{ .Arg .Name }}
|
||||
ORDER BY {{ .Ident "resource_version" }} DESC LIMIT 1
|
||||
) END,
|
||||
CASE
|
||||
WHEN {{ .Arg .Action }} = 1 THEN 1
|
||||
WHEN {{ .Arg .Action }} = 3 THEN 0
|
||||
ELSE 1 + (
|
||||
SELECT COUNT(1)
|
||||
FROM {{ .Ident "resource_history" }}
|
||||
WHERE {{ .Ident "group" }} = {{ .Arg .Group }}
|
||||
AND {{ .Ident "resource" }} = {{ .Arg .Resource }}
|
||||
AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }}
|
||||
AND {{ .Ident "name" }} = {{ .Arg .Name }}
|
||||
)
|
||||
END
|
||||
);
|
||||
@@ -1,15 +0,0 @@
|
||||
INSERT INTO {{ .Ident .TableName }}
|
||||
(
|
||||
{{ .Ident "key_path" }},
|
||||
{{ .Ident "value" }}
|
||||
)
|
||||
VALUES (
|
||||
{{ .Arg .KeyPath }},
|
||||
COALESCE({{ .Arg .Value }}, "")
|
||||
)
|
||||
{{- if eq .DialectName "mysql" }}
|
||||
ON DUPLICATE KEY UPDATE {{ .Ident "value" }} = {{ .Arg .Value }}
|
||||
{{- else }}
|
||||
ON CONFLICT ({{ .Ident "key_path" }}) DO UPDATE SET {{ .Ident "value" }} = {{ .Arg .Value }}
|
||||
{{- end }}
|
||||
;
|
||||
@@ -1,3 +0,0 @@
|
||||
UPDATE {{ .Ident .TableName }}
|
||||
SET {{ .Ident "value" }} = {{ .Arg .Value }}
|
||||
WHERE {{ .Ident "key_path" }} = {{ .Arg .KeyPath }};
|
||||
@@ -1,9 +0,0 @@
|
||||
UPDATE {{ .Ident "resource" }}
|
||||
SET
|
||||
{{ .Ident "value" }} = {{ .Arg .Value }},
|
||||
{{ .Ident "action" }} = {{ .Arg .Action }},
|
||||
{{ .Ident "folder" }} = {{ .Arg .Folder }}
|
||||
WHERE {{ .Ident "group" }} = {{ .Arg .Group }}
|
||||
AND {{ .Ident "resource" }} = {{ .Arg .Resource }}
|
||||
AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }}
|
||||
AND {{ .Ident "name" }} = {{ .Arg .Name }};
|
||||
@@ -49,9 +49,6 @@ type DataKey struct {
|
||||
ResourceVersion int64
|
||||
Action DataAction
|
||||
Folder string
|
||||
|
||||
// needed to maintain backwards compatibility with unified/sql
|
||||
GUID string
|
||||
}
|
||||
|
||||
// GroupResource represents a unique group/resource combination
|
||||
@@ -64,12 +61,6 @@ func (k DataKey) String() string {
|
||||
return fmt.Sprintf("%s/%s/%s/%s/%d~%s~%s", k.Group, k.Resource, k.Namespace, k.Name, k.ResourceVersion, k.Action, k.Folder)
|
||||
}
|
||||
|
||||
// Temporary while we need to support unified/sql/backend compatibility
|
||||
// Remove once we stop using RvManager in storage_backend.go
|
||||
func (k DataKey) StringWithGUID() string {
|
||||
return fmt.Sprintf("%s/%s/%s/%s/%d~%s~%s~%s", k.Group, k.Resource, k.Namespace, k.Name, k.ResourceVersion, k.Action, k.Folder, k.GUID)
|
||||
}
|
||||
|
||||
func (k DataKey) Equals(other DataKey) bool {
|
||||
return k.Group == other.Group && k.Resource == other.Resource && k.Namespace == other.Namespace && k.Name == other.Name && k.ResourceVersion == other.ResourceVersion && k.Action == other.Action && k.Folder == other.Folder
|
||||
}
|
||||
@@ -525,13 +516,7 @@ func (d *dataStore) Save(ctx context.Context, key DataKey, value io.Reader) erro
|
||||
return fmt.Errorf("invalid data key: %w", err)
|
||||
}
|
||||
|
||||
var writer io.WriteCloser
|
||||
var err error
|
||||
if key.GUID != "" {
|
||||
writer, err = d.kv.Save(ctx, dataSection, key.StringWithGUID())
|
||||
} else {
|
||||
writer, err = d.kv.Save(ctx, dataSection, key.String())
|
||||
}
|
||||
writer, err := d.kv.Save(ctx, dataSection, key.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -598,33 +583,6 @@ func ParseKey(key string) (DataKey, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Temporary while we need to support unified/sql/backend compatibility
|
||||
// Remove once we stop using RvManager in storage_backend.go
|
||||
func ParseKeyWithGUID(key string) (DataKey, error) {
|
||||
parts := strings.Split(key, "/")
|
||||
if len(parts) != 5 {
|
||||
return DataKey{}, fmt.Errorf("invalid key: %s", key)
|
||||
}
|
||||
rvActionFolderGUIDParts := strings.Split(parts[4], "~")
|
||||
if len(rvActionFolderGUIDParts) != 4 {
|
||||
return DataKey{}, fmt.Errorf("invalid key: %s", key)
|
||||
}
|
||||
rv, err := strconv.ParseInt(rvActionFolderGUIDParts[0], 10, 64)
|
||||
if err != nil {
|
||||
return DataKey{}, fmt.Errorf("invalid resource version '%s' in key %s: %w", rvActionFolderGUIDParts[0], key, err)
|
||||
}
|
||||
return DataKey{
|
||||
Group: parts[0],
|
||||
Resource: parts[1],
|
||||
Namespace: parts[2],
|
||||
Name: parts[3],
|
||||
ResourceVersion: rv,
|
||||
Action: DataAction(rvActionFolderGUIDParts[1]),
|
||||
Folder: rvActionFolderGUIDParts[2],
|
||||
GUID: rvActionFolderGUIDParts[3],
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SameResource checks if this key represents the same resource as another key.
|
||||
// It compares the identifying fields: Group, Resource, Namespace, and Name.
|
||||
// ResourceVersion, Action, and Folder are ignored as they don't identify the resource itself.
|
||||
|
||||
@@ -32,7 +32,6 @@ type EventKey struct {
|
||||
ResourceVersion int64
|
||||
Action DataAction
|
||||
Folder string
|
||||
GUID string
|
||||
}
|
||||
|
||||
func (k EventKey) String() string {
|
||||
|
||||
@@ -11,12 +11,9 @@ import (
|
||||
"iter"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/sql/db"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/sql/dbutil"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/sql/rvmanager"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate"
|
||||
)
|
||||
|
||||
@@ -37,18 +34,10 @@ func mustTemplate(filename string) *template.Template {
|
||||
|
||||
// Templates.
|
||||
var (
|
||||
sqlKVKeys = mustTemplate("sqlkv_keys.sql")
|
||||
sqlKVGet = mustTemplate("sqlkv_get.sql")
|
||||
sqlKVBatchGet = mustTemplate("sqlkv_batch_get.sql")
|
||||
sqlKVSaveEvent = mustTemplate("sqlkv_save_event.sql")
|
||||
sqlKVInsertData = mustTemplate("sqlkv_insert_datastore.sql")
|
||||
sqlKVUpdateData = mustTemplate("sqlkv_update_datastore.sql")
|
||||
sqlKVInsertLegacyResourceHistory = mustTemplate("sqlkv_insert_legacy_resource_history.sql")
|
||||
sqlKVInsertLegacyResource = mustTemplate("sqlkv_insert_legacy_resource.sql")
|
||||
sqlKVUpdateLegacyResource = mustTemplate("sqlkv_update_legacy_resource.sql")
|
||||
sqlKVDeleteLegacyResource = mustTemplate("sqlkv_delete_legacy_resource.sql")
|
||||
sqlKVDelete = mustTemplate("sqlkv_delete.sql")
|
||||
sqlKVBatchDelete = mustTemplate("sqlkv_batch_delete.sql")
|
||||
sqlKVKeys = mustTemplate("sqlkv_keys.sql")
|
||||
sqlKVGet = mustTemplate("sqlkv_get.sql")
|
||||
sqlKVBatchGet = mustTemplate("sqlkv_batch_get.sql")
|
||||
sqlKVDelete = mustTemplate("sqlkv_delete.sql")
|
||||
)
|
||||
|
||||
// sqlKVSection can be embedded in structs used when rendering query templates
|
||||
@@ -119,17 +108,17 @@ func (req sqlKVGetRequest) Results() ([]byte, error) {
|
||||
return req.Value, nil
|
||||
}
|
||||
|
||||
type sqlKVBatchRequest struct {
|
||||
type sqlKVBatchGetRequest struct {
|
||||
sqltemplate.SQLTemplate
|
||||
sqlKVSection
|
||||
Keys []string
|
||||
}
|
||||
|
||||
func (req sqlKVBatchRequest) Validate() error {
|
||||
func (req sqlKVBatchGetRequest) Validate() error {
|
||||
return req.sqlKVSection.Validate()
|
||||
}
|
||||
|
||||
func (req sqlKVBatchRequest) KeyPaths() []string {
|
||||
func (req sqlKVBatchGetRequest) KeyPaths() []string {
|
||||
result := make([]string, 0, len(req.Keys))
|
||||
for _, key := range req.Keys {
|
||||
result = append(result, req.Section+"/"+key)
|
||||
@@ -138,45 +127,6 @@ func (req sqlKVBatchRequest) KeyPaths() []string {
|
||||
return result
|
||||
}
|
||||
|
||||
type sqlKVSaveRequest struct {
|
||||
sqltemplate.SQLTemplate
|
||||
sqlKVSectionKey
|
||||
Value []byte
|
||||
|
||||
// old fields that can be removed once we prune resource_history
|
||||
GUID string
|
||||
Group string
|
||||
Resource string
|
||||
Namespace string
|
||||
Name string
|
||||
Action int64
|
||||
Folder string
|
||||
}
|
||||
|
||||
func (req sqlKVSaveRequest) Validate() error {
|
||||
return req.sqlKVSectionKey.Validate()
|
||||
}
|
||||
|
||||
type sqlKVLegacySaveRequest struct {
|
||||
sqltemplate.SQLTemplate
|
||||
Value []byte
|
||||
GUID string
|
||||
Group string
|
||||
Resource string
|
||||
Namespace string
|
||||
Name string
|
||||
Action int64
|
||||
Folder string
|
||||
}
|
||||
|
||||
func (req sqlKVLegacySaveRequest) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (req sqlKVLegacySaveRequest) Results() ([]byte, error) {
|
||||
return req.Value, nil
|
||||
}
|
||||
|
||||
type sqlKVKeysRequest struct {
|
||||
sqltemplate.SQLTemplate
|
||||
sqlKVSection
|
||||
@@ -300,7 +250,7 @@ func (k *sqlKV) BatchGet(ctx context.Context, section string, keys []string) ite
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := dbutil.QueryRows(ctx, k.db, sqlKVBatchGet, sqlKVBatchRequest{
|
||||
rows, err := dbutil.QueryRows(ctx, k.db, sqlKVBatchGet, sqlKVBatchGetRequest{
|
||||
SQLTemplate: sqltemplate.New(k.dialect),
|
||||
sqlKVSection: sqlKVSection{section},
|
||||
Keys: keys,
|
||||
@@ -334,187 +284,20 @@ func (k *sqlKV) BatchGet(ctx context.Context, section string, keys []string) ite
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: this function only exists to support the testing of the sqlkv implementation before
|
||||
// we have a proper implementation of `Save`.
|
||||
func (k *sqlKV) TestingSave(ctx context.Context, key string, value []byte) error {
|
||||
stmt := fmt.Sprintf(
|
||||
`INSERT INTO resource_events (key_path, value) VALUES (%s, %s)`,
|
||||
k.dialect.ArgPlaceholder(1), k.dialect.ArgPlaceholder(2),
|
||||
)
|
||||
|
||||
_, err := k.db.ExecContext(ctx, stmt, eventsSection+"/"+key, value)
|
||||
return err
|
||||
}
|
||||
|
||||
func (k *sqlKV) Save(ctx context.Context, section string, key string) (io.WriteCloser, error) {
|
||||
sectionKey := sqlKVSectionKey{sqlKVSection{section}, key}
|
||||
if err := sectionKey.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &sqlWriteCloser{
|
||||
kv: k,
|
||||
ctx: ctx,
|
||||
sectionKey: sectionKey,
|
||||
buf: &bytes.Buffer{},
|
||||
closed: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type sqlWriteCloser struct {
|
||||
kv *sqlKV
|
||||
ctx context.Context
|
||||
sectionKey sqlKVSectionKey
|
||||
buf *bytes.Buffer
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (w *sqlWriteCloser) Write(value []byte) (int, error) {
|
||||
if w.closed {
|
||||
return 0, errors.New("write to closed writer")
|
||||
}
|
||||
|
||||
return w.buf.Write(value)
|
||||
}
|
||||
|
||||
func (w *sqlWriteCloser) Close() error {
|
||||
if w.closed {
|
||||
return nil
|
||||
}
|
||||
|
||||
w.closed = true
|
||||
|
||||
// do regular kv save: simple key_path + value insert with conflict check.
|
||||
// can only do this on resource_events for now, until we drop the columns in resource_history
|
||||
if w.sectionKey.Section == eventsSection {
|
||||
_, err := dbutil.Exec(w.ctx, w.kv.db, sqlKVSaveEvent, sqlKVSaveRequest{
|
||||
SQLTemplate: sqltemplate.New(w.kv.dialect),
|
||||
sqlKVSectionKey: w.sectionKey,
|
||||
Value: w.buf.Bytes(),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// if storage_backend is running with an RvManager, it will inject a transaction into the context
|
||||
// used to keep backwards compatibility between sql-based kvstore and unified/sql/backend
|
||||
tx, ok := rvmanager.TxFromCtx(w.ctx)
|
||||
if !ok {
|
||||
// temporary save for dataStore without rvmanager
|
||||
// we can use the same template as the event one after we:
|
||||
// - move PK from GUID to key_path
|
||||
// - remove all unnecessary columns (or at least their NOT NULL constraints)
|
||||
_, err := w.kv.Get(w.ctx, w.sectionKey.Section, w.sectionKey.Key)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
_, err := dbutil.Exec(w.ctx, w.kv.db, sqlKVInsertData, sqlKVSaveRequest{
|
||||
SQLTemplate: sqltemplate.New(w.kv.dialect),
|
||||
sqlKVSectionKey: w.sectionKey,
|
||||
GUID: uuid.New().String(),
|
||||
Value: w.buf.Bytes(),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to insert to datastore: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get for save: %w", err)
|
||||
}
|
||||
|
||||
_, err = dbutil.Exec(w.ctx, w.kv.db, sqlKVUpdateData, sqlKVSaveRequest{
|
||||
SQLTemplate: sqltemplate.New(w.kv.dialect),
|
||||
sqlKVSectionKey: w.sectionKey,
|
||||
Value: w.buf.Bytes(),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update to datastore: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// special, temporary save that includes all the fields in resource_history that are not relevant for the kvstore,
|
||||
// as well as the resource table. This is only called if an RvManager was passed to storage_backend, as that
|
||||
// component will be responsible for populating the resource_version and key_path columns
|
||||
// note that we are not touching resource_version table, neither the resource_version columns or the key_path column
|
||||
// as the RvManager will be responsible for this
|
||||
dataKey, err := ParseKeyWithGUID(w.sectionKey.Key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse key: %w", err)
|
||||
}
|
||||
|
||||
var action int64
|
||||
switch dataKey.Action {
|
||||
case DataActionCreated:
|
||||
action = 1
|
||||
case DataActionUpdated:
|
||||
action = 2
|
||||
case DataActionDeleted:
|
||||
action = 3
|
||||
default:
|
||||
return fmt.Errorf("failed to parse key: %w", err)
|
||||
}
|
||||
|
||||
_, err = dbutil.Exec(w.ctx, tx, sqlKVInsertLegacyResourceHistory, sqlKVSaveRequest{
|
||||
SQLTemplate: sqltemplate.New(w.kv.dialect),
|
||||
sqlKVSectionKey: w.sectionKey,
|
||||
Value: w.buf.Bytes(),
|
||||
GUID: dataKey.GUID,
|
||||
Group: dataKey.Group,
|
||||
Resource: dataKey.Resource,
|
||||
Namespace: dataKey.Namespace,
|
||||
Name: dataKey.Name,
|
||||
Action: action,
|
||||
Folder: dataKey.Folder,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save to resource_history: %w", err)
|
||||
}
|
||||
|
||||
switch dataKey.Action {
|
||||
case DataActionCreated:
|
||||
_, err = dbutil.Exec(w.ctx, tx, sqlKVInsertLegacyResource, sqlKVLegacySaveRequest{
|
||||
SQLTemplate: sqltemplate.New(w.kv.dialect),
|
||||
Value: w.buf.Bytes(),
|
||||
GUID: dataKey.GUID,
|
||||
Group: dataKey.Group,
|
||||
Resource: dataKey.Resource,
|
||||
Namespace: dataKey.Namespace,
|
||||
Name: dataKey.Name,
|
||||
Action: action,
|
||||
Folder: dataKey.Folder,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to insert to resource: %w", err)
|
||||
}
|
||||
case DataActionUpdated:
|
||||
_, err = dbutil.Exec(w.ctx, tx, sqlKVUpdateLegacyResource, sqlKVLegacySaveRequest{
|
||||
SQLTemplate: sqltemplate.New(w.kv.dialect),
|
||||
Value: w.buf.Bytes(),
|
||||
Group: dataKey.Group,
|
||||
Resource: dataKey.Resource,
|
||||
Namespace: dataKey.Namespace,
|
||||
Name: dataKey.Name,
|
||||
Action: action,
|
||||
Folder: dataKey.Folder,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update resource: %w", err)
|
||||
}
|
||||
case DataActionDeleted:
|
||||
_, err = dbutil.Exec(w.ctx, tx, sqlKVDeleteLegacyResource, sqlKVLegacySaveRequest{
|
||||
SQLTemplate: sqltemplate.New(w.kv.dialect),
|
||||
Group: dataKey.Group,
|
||||
Resource: dataKey.Resource,
|
||||
Namespace: dataKey.Namespace,
|
||||
Name: dataKey.Name,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete from resource: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
panic("not implemented!")
|
||||
}
|
||||
|
||||
func (k *sqlKV) Delete(ctx context.Context, section string, key string) error {
|
||||
@@ -535,29 +318,15 @@ func (k *sqlKV) Delete(ctx context.Context, section string, key string) error {
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
// TODO reflect change to resource table
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (k *sqlKV) BatchDelete(ctx context.Context, section string, keys []string) error {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := dbutil.Exec(ctx, k.db, sqlKVBatchDelete, sqlKVBatchRequest{
|
||||
SQLTemplate: sqltemplate.New(k.dialect),
|
||||
sqlKVSection: sqlKVSection{section},
|
||||
Keys: keys,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to batch delete keys: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
panic("not implemented!")
|
||||
}
|
||||
|
||||
func (k *sqlKV) UnixTimestamp(ctx context.Context) (int64, error) {
|
||||
return time.Now().Unix(), nil
|
||||
panic("not implemented!")
|
||||
}
|
||||
|
||||
func closeRows[T any](rows db.Rows, yield func(T, error) bool) {
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/bwmarrin/snowflake"
|
||||
"github.com/google/uuid"
|
||||
"github.com/grafana/grafana-app-sdk/logging"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
@@ -23,8 +22,6 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/sql/db"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/sql/rvmanager"
|
||||
"github.com/grafana/grafana/pkg/util/debouncer"
|
||||
)
|
||||
|
||||
@@ -71,8 +68,6 @@ type kvStorageBackend struct {
|
||||
withExperimentalClusterScope bool
|
||||
//tracer trace.Tracer
|
||||
//reg prometheus.Registerer
|
||||
|
||||
rvManager *rvmanager.ResourceVersionManager
|
||||
}
|
||||
|
||||
var _ KVBackend = &kvStorageBackend{}
|
||||
@@ -90,10 +85,6 @@ type KVBackendOptions struct {
|
||||
EventPruningInterval time.Duration // How often to run the event pruning (default: 5 minutes)
|
||||
Tracer trace.Tracer // TODO add tracing
|
||||
Reg prometheus.Registerer // TODO add metrics
|
||||
|
||||
// Adding RvManager overrides the RV generated with snowflake in order to keep backwards compatibility with
|
||||
// unified/sql
|
||||
RvManager *rvmanager.ResourceVersionManager
|
||||
}
|
||||
|
||||
func NewKVStorageBackend(opts KVBackendOptions) (KVBackend, error) {
|
||||
@@ -128,7 +119,6 @@ func NewKVStorageBackend(opts KVBackendOptions) (KVBackend, error) {
|
||||
eventRetentionPeriod: eventRetentionPeriod,
|
||||
eventPruningInterval: eventPruningInterval,
|
||||
withExperimentalClusterScope: opts.WithExperimentalClusterScope,
|
||||
rvManager: opts.RvManager,
|
||||
}
|
||||
err = backend.initPruner(ctx)
|
||||
if err != nil {
|
||||
@@ -327,28 +317,9 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in
|
||||
Action: action,
|
||||
Folder: obj.GetFolder(),
|
||||
}
|
||||
|
||||
if k.rvManager != nil {
|
||||
dataKey.GUID = uuid.New().String()
|
||||
var err error
|
||||
rv, err = k.rvManager.ExecWithRV(ctx, event.Key, func(tx db.Tx) (string, error) {
|
||||
err := k.dataStore.Save(rvmanager.ContextWithTx(ctx, tx), dataKey, bytes.NewReader(event.Value))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to write data: %w", err)
|
||||
}
|
||||
|
||||
return dataKey.GUID, nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to write data: %w", err)
|
||||
}
|
||||
|
||||
dataKey.ResourceVersion = rv
|
||||
} else {
|
||||
err := k.dataStore.Save(ctx, dataKey, bytes.NewReader(event.Value))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to write data: %w", err)
|
||||
}
|
||||
err := k.dataStore.Save(ctx, dataKey, bytes.NewReader(event.Value))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to write data: %w", err)
|
||||
}
|
||||
|
||||
// Optimistic concurrency control to verify our write is the latest version
|
||||
@@ -369,22 +340,14 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in
|
||||
}
|
||||
|
||||
// Check if the RV we just wrote is the latest. If not, a concurrent write with higher RV happened
|
||||
if !rvmanager.IsRvEqual(latestKey.ResourceVersion, rv) {
|
||||
if latestKey.ResourceVersion != rv {
|
||||
// Delete the data we just wrote since it's not the latest
|
||||
// if we're running with rvManager, convert the ResourceVersion back to snowflake to delete
|
||||
if k.rvManager != nil {
|
||||
dataKey.ResourceVersion = rvmanager.SnowflakeFromRv(dataKey.ResourceVersion)
|
||||
}
|
||||
_ = k.dataStore.Delete(ctx, dataKey)
|
||||
return 0, fmt.Errorf("optimistic locking failed: concurrent modification detected")
|
||||
}
|
||||
|
||||
if !rvmanager.IsRvEqual(prevKey.ResourceVersion, event.PreviousRV) {
|
||||
if prevKey.ResourceVersion != event.PreviousRV {
|
||||
// Another concurrent write happened between our read and write
|
||||
// if we're running with rvManager, convert the ResourceVersion back to snowflake to delete
|
||||
if k.rvManager != nil {
|
||||
dataKey.ResourceVersion = rvmanager.SnowflakeFromRv(dataKey.ResourceVersion)
|
||||
}
|
||||
_ = k.dataStore.Delete(ctx, dataKey)
|
||||
return 0, fmt.Errorf("optimistic locking failed: resource was modified concurrently (expected previous RV %d, found %d)", event.PreviousRV, prevKey.ResourceVersion)
|
||||
}
|
||||
@@ -403,12 +366,8 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in
|
||||
}
|
||||
|
||||
// Check if the RV we just wrote is the latest. If not, a concurrent create with higher RV happened
|
||||
if !rvmanager.IsRvEqual(latestKey.ResourceVersion, rv) {
|
||||
if latestKey.ResourceVersion != rv {
|
||||
// Delete the data we just wrote since it's not the latest
|
||||
// if we're running with rvManager, convert the ResourceVersion back to snowflake to delete
|
||||
if k.rvManager != nil {
|
||||
dataKey.ResourceVersion = rvmanager.SnowflakeFromRv(dataKey.ResourceVersion)
|
||||
}
|
||||
_ = k.dataStore.Delete(ctx, dataKey)
|
||||
return 0, fmt.Errorf("optimistic locking failed: concurrent create detected")
|
||||
}
|
||||
@@ -416,10 +375,6 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in
|
||||
// Verify that the immediate predecessor is not a create
|
||||
if prevKey.Action == DataActionCreated {
|
||||
// Another concurrent create happened - delete our write and return error
|
||||
// if we're running with rvManager, convert the ResourceVersion back to snowflake to delete
|
||||
if k.rvManager != nil {
|
||||
dataKey.ResourceVersion = rvmanager.SnowflakeFromRv(dataKey.ResourceVersion)
|
||||
}
|
||||
_ = k.dataStore.Delete(ctx, dataKey)
|
||||
return 0, fmt.Errorf("optimistic locking failed: concurrent create detected")
|
||||
}
|
||||
@@ -436,7 +391,7 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in
|
||||
Folder: obj.GetFolder(),
|
||||
PreviousRV: event.PreviousRV,
|
||||
}
|
||||
err := k.eventStore.Save(ctx, eventData)
|
||||
err = k.eventStore.Save(ctx, eventData)
|
||||
if err != nil {
|
||||
// Clean up the data we wrote since event save failed
|
||||
_ = k.dataStore.Delete(ctx, dataKey)
|
||||
|
||||
@@ -21,19 +21,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const txKey contextKey = "rvmanager_db_tx"
|
||||
|
||||
func ContextWithTx(ctx context.Context, tx db.Tx) context.Context {
|
||||
return context.WithValue(ctx, txKey, tx)
|
||||
}
|
||||
|
||||
func TxFromCtx(ctx context.Context) (db.Tx, bool) {
|
||||
tx, ok := ctx.Value(txKey).(db.Tx)
|
||||
return tx, ok
|
||||
}
|
||||
|
||||
var tracer = otel.Tracer("github.com/grafana/grafana/pkg/storage/unified/sql/rvmanager")
|
||||
|
||||
var (
|
||||
@@ -307,7 +294,7 @@ func (m *ResourceVersionManager) execBatch(ctx context.Context, group, resource
|
||||
// Allocate the RVs
|
||||
for i, guid := range guids {
|
||||
guidToRV[guid] = rv
|
||||
guidToSnowflakeRV[guid] = SnowflakeFromRv(rv)
|
||||
guidToSnowflakeRV[guid] = snowflakeFromRv(rv)
|
||||
rvs[i] = rv
|
||||
rv++
|
||||
}
|
||||
@@ -366,20 +353,10 @@ func (m *ResourceVersionManager) execBatch(ctx context.Context, group, resource
|
||||
|
||||
// takes a unix microsecond rv and transforms into a snowflake format. The timestamp is converted from microsecond to
|
||||
// millisecond (the integer division) and the remainder is saved in the stepbits section. machine id is always 0
|
||||
func SnowflakeFromRv(rv int64) int64 {
|
||||
func snowflakeFromRv(rv int64) int64 {
|
||||
return (((rv / 1000) - snowflake.Epoch) << (snowflake.NodeBits + snowflake.StepBits)) + (rv % 1000)
|
||||
}
|
||||
|
||||
// helper utility to compare two RVs. The first RV must be in snowflake format. Will convert rv2 to snowflake and retry
|
||||
// if comparison fails
|
||||
func IsRvEqual(rv1, rv2 int64) bool {
|
||||
if rv1 == rv2 {
|
||||
return true
|
||||
}
|
||||
|
||||
return rv1 == SnowflakeFromRv(rv2)
|
||||
}
|
||||
|
||||
// Lock locks the resource version for the given key
|
||||
func (m *ResourceVersionManager) Lock(ctx context.Context, x db.ContextExecer, group, resource string) (nextRV int64, err error) {
|
||||
// 1. Lock the row and prevent concurrent updates until the transaction is committed
|
||||
|
||||
@@ -20,8 +20,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/sql/rvmanager"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate"
|
||||
)
|
||||
|
||||
type QOSEnqueueDequeuer interface {
|
||||
@@ -105,34 +103,11 @@ func NewResourceServer(opts ServerOptions) (resource.ResourceServer, error) {
|
||||
return nil, fmt.Errorf("error creating sqlkv: %s", err)
|
||||
}
|
||||
|
||||
kvBackendOpts := resource.KVBackendOptions{
|
||||
kvBackend, err := resource.NewKVStorageBackend(resource.KVBackendOptions{
|
||||
KvStore: sqlkv,
|
||||
Tracer: opts.Tracer,
|
||||
Reg: opts.Reg,
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
dbConn, err := eDB.Init(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error initializing DB: %w", err)
|
||||
}
|
||||
|
||||
dialect := sqltemplate.DialectForDriver(dbConn.DriverName())
|
||||
if dialect == nil {
|
||||
return nil, fmt.Errorf("unsupported database driver: %s", dbConn.DriverName())
|
||||
}
|
||||
|
||||
rvManager, err := rvmanager.NewResourceVersionManager(rvmanager.ResourceManagerOptions{
|
||||
Dialect: dialect,
|
||||
DB: dbConn,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create resource version manager: %w", err)
|
||||
}
|
||||
|
||||
// TODO add config to decide whether to pass RvManager or not
|
||||
kvBackendOpts.RvManager = rvManager
|
||||
kvBackend, err := resource.NewKVStorageBackend(kvBackendOpts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating kv backend: %s", err)
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -148,15 +146,14 @@ func runTestKVGet(t *testing.T, kv resource.KV, nsPrefix string) {
|
||||
|
||||
func runTestKVSave(t *testing.T, kv resource.KV, nsPrefix string) {
|
||||
ctx := testutil.NewTestContext(t, time.Now().Add(30*time.Second))
|
||||
nsPrefix += "-save"
|
||||
section := nsPrefix + "-save"
|
||||
|
||||
t.Run("save new key", func(t *testing.T) {
|
||||
newKey := namespacedKey(nsPrefix, "new-key")
|
||||
testValue := "new test value"
|
||||
saveKVHelper(t, kv, ctx, testSection, newKey, strings.NewReader(testValue))
|
||||
saveKVHelper(t, kv, ctx, section, "new-key", strings.NewReader(testValue))
|
||||
|
||||
// Verify it was saved
|
||||
reader, err := kv.Get(ctx, testSection, newKey)
|
||||
reader, err := kv.Get(ctx, section, "new-key")
|
||||
require.NoError(t, err)
|
||||
|
||||
value, err := io.ReadAll(reader)
|
||||
@@ -167,39 +164,15 @@ func runTestKVSave(t *testing.T, kv resource.KV, nsPrefix string) {
|
||||
})
|
||||
|
||||
t.Run("save overwrite existing key", func(t *testing.T) {
|
||||
overwriteKey := namespacedKey(nsPrefix, "overwrite-key")
|
||||
|
||||
// First save
|
||||
saveKVHelper(t, kv, ctx, testSection, overwriteKey, strings.NewReader("old value"))
|
||||
saveKVHelper(t, kv, ctx, section, "overwrite-key", strings.NewReader("old value"))
|
||||
|
||||
// Overwrite
|
||||
newValue := "new value"
|
||||
saveKVHelper(t, kv, ctx, testSection, overwriteKey, strings.NewReader(newValue))
|
||||
saveKVHelper(t, kv, ctx, section, "overwrite-key", strings.NewReader(newValue))
|
||||
|
||||
// Verify it was updated
|
||||
reader, err := kv.Get(ctx, testSection, overwriteKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
value, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, newValue, string(value))
|
||||
err = reader.Close()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("save overwrite existing key (datastore)", func(t *testing.T) {
|
||||
section := "unified/data"
|
||||
overwriteKey := namespacedKey(nsPrefix, "overwrite-key")
|
||||
|
||||
// First save
|
||||
saveKVHelper(t, kv, ctx, section, overwriteKey, strings.NewReader("old value"))
|
||||
|
||||
// Overwrite
|
||||
newValue := "new value"
|
||||
saveKVHelper(t, kv, ctx, section, overwriteKey, strings.NewReader(newValue))
|
||||
|
||||
// Verify it was updated
|
||||
reader, err := kv.Get(ctx, section, overwriteKey)
|
||||
reader, err := kv.Get(ctx, section, "overwrite-key")
|
||||
require.NoError(t, err)
|
||||
|
||||
value, err := io.ReadAll(reader)
|
||||
@@ -216,13 +189,11 @@ func runTestKVSave(t *testing.T, kv resource.KV, nsPrefix string) {
|
||||
})
|
||||
|
||||
t.Run("save binary data", func(t *testing.T) {
|
||||
binaryKey := namespacedKey(nsPrefix, "binary-key")
|
||||
|
||||
binaryData := []byte{0x00, 0x01, 0x02, 0x03, 0xFF, 0xFE, 0xFD}
|
||||
saveKVHelper(t, kv, ctx, testSection, binaryKey, bytes.NewReader(binaryData))
|
||||
saveKVHelper(t, kv, ctx, section, "binary-key", bytes.NewReader(binaryData))
|
||||
|
||||
// Verify binary data
|
||||
reader, err := kv.Get(ctx, testSection, binaryKey)
|
||||
reader, err := kv.Get(ctx, section, "binary-key")
|
||||
require.NoError(t, err)
|
||||
|
||||
value, err := io.ReadAll(reader)
|
||||
@@ -233,13 +204,11 @@ func runTestKVSave(t *testing.T, kv resource.KV, nsPrefix string) {
|
||||
})
|
||||
|
||||
t.Run("save key with no data", func(t *testing.T) {
|
||||
emptyKey := namespacedKey(nsPrefix, "empty-key")
|
||||
|
||||
// Save a key with empty data
|
||||
saveKVHelper(t, kv, ctx, testSection, emptyKey, strings.NewReader(""))
|
||||
saveKVHelper(t, kv, ctx, section, "empty-key", strings.NewReader(""))
|
||||
|
||||
// Verify it was saved with empty data
|
||||
reader, err := kv.Get(ctx, testSection, emptyKey)
|
||||
reader, err := kv.Get(ctx, section, "empty-key")
|
||||
require.NoError(t, err)
|
||||
|
||||
value, err := io.ReadAll(reader)
|
||||
@@ -505,134 +474,128 @@ func runTestKVKeysWithSort(t *testing.T, kv resource.KV, nsPrefix string) {
|
||||
|
||||
func runTestKVConcurrent(t *testing.T, kv resource.KV, nsPrefix string) {
|
||||
ctx := testutil.NewTestContext(t, time.Now().Add(60*time.Second))
|
||||
nsPrefix += "-concurrent"
|
||||
section := nsPrefix + "-concurrent"
|
||||
|
||||
// Test concurrent operations for both sections, as they have different behaviours
|
||||
// in the sqlkv implementation.
|
||||
for _, testSection := range []string{"unified/data", "unified/events"} {
|
||||
t.Run(testSection, func(t *testing.T) {
|
||||
t.Run("concurrent save and get operations", func(t *testing.T) {
|
||||
const numGoroutines = 10
|
||||
const numOperations = 20
|
||||
t.Run("concurrent save and get operations", func(t *testing.T) {
|
||||
const numGoroutines = 10
|
||||
const numOperations = 20
|
||||
|
||||
done := make(chan error, numGoroutines)
|
||||
done := make(chan error, numGoroutines)
|
||||
|
||||
for goroutineID := range numGoroutines {
|
||||
go func() {
|
||||
var err error
|
||||
defer func() { done <- err }()
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
go func(goroutineID int) {
|
||||
var err error
|
||||
defer func() { done <- err }()
|
||||
|
||||
for j := range numOperations {
|
||||
key := namespacedKey(nsPrefix, fmt.Sprintf("concurrent-key-%d-%d", goroutineID, j))
|
||||
value := fmt.Sprintf("concurrent-value-%d-%d", goroutineID, j)
|
||||
for j := 0; j < numOperations; j++ {
|
||||
key := fmt.Sprintf("concurrent-key-%d-%d", goroutineID, j)
|
||||
value := fmt.Sprintf("concurrent-value-%d-%d", goroutineID, j)
|
||||
|
||||
// Save
|
||||
writer, err := kv.Save(ctx, testSection, key)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
err := writer.Close()
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
_, err = io.Copy(writer, strings.NewReader(value))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = writer.Close()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Get immediately
|
||||
reader, err := kv.Get(ctx, testSection, key)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
readValue, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
err = reader.Close()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, value, string(readValue))
|
||||
}
|
||||
// Save
|
||||
writer, err := kv.Save(ctx, section, key)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
err := writer.Close()
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
}
|
||||
_, err = io.Copy(writer, strings.NewReader(value))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = writer.Close()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Wait for all goroutines to complete
|
||||
for range numGoroutines {
|
||||
err := <-done
|
||||
// Get immediately
|
||||
reader, err := kv.Get(ctx, section, key)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
readValue, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("concurrent save, delete, and list operations", func(t *testing.T) {
|
||||
const numGoroutines = 5
|
||||
done := make(chan error, numGoroutines)
|
||||
|
||||
for i := range numGoroutines {
|
||||
go func(goroutineID int) {
|
||||
var err error
|
||||
defer func() { done <- err }()
|
||||
|
||||
key := namespacedKey(nsPrefix, fmt.Sprintf("concurrent-ops-key-%d", goroutineID))
|
||||
value := fmt.Sprintf("concurrent-ops-value-%d", goroutineID)
|
||||
|
||||
// Save
|
||||
writer, err := kv.Save(ctx, testSection, key)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
err := writer.Close()
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
_, err = io.Copy(writer, strings.NewReader(value))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = writer.Close()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// List to verify it exists
|
||||
found := false
|
||||
for k, err := range kv.Keys(ctx, testSection, resource.ListOptions{}) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if k == key {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
err = fmt.Errorf("key %s not found in list", key)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete
|
||||
err = kv.Delete(ctx, testSection, key)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Verify it's deleted
|
||||
_, err = kv.Get(ctx, testSection, key)
|
||||
require.ErrorIs(t, resource.ErrNotFound, err)
|
||||
err = nil // Expected error, so clear it
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Wait for all goroutines to complete
|
||||
for range numGoroutines {
|
||||
err := <-done
|
||||
err = reader.Close()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, value, string(readValue))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Wait for all goroutines to complete
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
err := <-done
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("concurrent save, delete, and list operations", func(t *testing.T) {
|
||||
const numGoroutines = 5
|
||||
done := make(chan error, numGoroutines)
|
||||
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
go func(goroutineID int) {
|
||||
var err error
|
||||
defer func() { done <- err }()
|
||||
|
||||
key := fmt.Sprintf("concurrent-ops-key-%d", goroutineID)
|
||||
value := fmt.Sprintf("concurrent-ops-value-%d", goroutineID)
|
||||
|
||||
// Save
|
||||
writer, err := kv.Save(ctx, section, key)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
err := writer.Close()
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
_, err = io.Copy(writer, strings.NewReader(value))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = writer.Close()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// List to verify it exists
|
||||
found := false
|
||||
for k, err := range kv.Keys(ctx, section, resource.ListOptions{}) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if k == key {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
err = fmt.Errorf("key %s not found in list", key)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete
|
||||
err = kv.Delete(ctx, section, key)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Verify it's deleted
|
||||
_, err = kv.Get(ctx, section, key)
|
||||
require.ErrorIs(t, resource.ErrNotFound, err)
|
||||
err = nil // Expected error, so clear it
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Wait for all goroutines to complete
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
err := <-done
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func runTestKVUnixTimestamp(t *testing.T, kv resource.KV, nsPrefix string) {
|
||||
@@ -823,35 +786,35 @@ func runTestKVBatchGet(t *testing.T, kv resource.KV, nsPrefix string) {
|
||||
|
||||
func runTestKVBatchDelete(t *testing.T, kv resource.KV, nsPrefix string) {
|
||||
ctx := testutil.NewTestContext(t, time.Now().Add(30*time.Second))
|
||||
nsPrefix += "-batchdelete"
|
||||
section := nsPrefix + "-batchdelete"
|
||||
|
||||
t.Run("batch delete existing keys", func(t *testing.T) {
|
||||
// Setup test data
|
||||
testData := map[string]string{
|
||||
namespacedKey(nsPrefix, "key1"): "value1",
|
||||
namespacedKey(nsPrefix, "key2"): "value2",
|
||||
namespacedKey(nsPrefix, "key3"): "value3",
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
"key3": "value3",
|
||||
}
|
||||
|
||||
// Save test data
|
||||
for key, value := range testData {
|
||||
saveKVHelper(t, kv, ctx, testSection, key, strings.NewReader(value))
|
||||
saveKVHelper(t, kv, ctx, section, key, strings.NewReader(value))
|
||||
}
|
||||
|
||||
// Verify keys exist before deletion
|
||||
for key := range testData {
|
||||
_, err := kv.Get(ctx, testSection, key)
|
||||
_, err := kv.Get(ctx, section, key)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Batch delete all keys
|
||||
keys := slices.Collect(maps.Keys(testData))
|
||||
err := kv.BatchDelete(ctx, testSection, keys)
|
||||
keys := []string{"key1", "key2", "key3"}
|
||||
err := kv.BatchDelete(ctx, section, keys)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify all keys are deleted
|
||||
for _, key := range keys {
|
||||
_, err := kv.Get(ctx, testSection, key)
|
||||
_, err := kv.Get(ctx, section, key)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, resource.ErrNotFound, err)
|
||||
}
|
||||
@@ -859,40 +822,39 @@ func runTestKVBatchDelete(t *testing.T, kv resource.KV, nsPrefix string) {
|
||||
|
||||
t.Run("batch delete with non-existent keys", func(t *testing.T) {
|
||||
// Setup some test data
|
||||
key1, key2 := namespacedKey(nsPrefix, "existing-key-1"), namespacedKey(nsPrefix, "existing-key-2")
|
||||
saveKVHelper(t, kv, ctx, testSection, key1, strings.NewReader("value1"))
|
||||
saveKVHelper(t, kv, ctx, testSection, key2, strings.NewReader("value2"))
|
||||
saveKVHelper(t, kv, ctx, section, "existing-key-1", strings.NewReader("value1"))
|
||||
saveKVHelper(t, kv, ctx, section, "existing-key-2", strings.NewReader("value2"))
|
||||
|
||||
// Batch delete with mix of existing and non-existent keys
|
||||
keys := []string{key1, namespacedKey(nsPrefix, "non-existent-1"), key2, namespacedKey(nsPrefix, "non-existent-2")}
|
||||
err := kv.BatchDelete(ctx, testSection, keys)
|
||||
keys := []string{"existing-key-1", "non-existent-1", "existing-key-2", "non-existent-2"}
|
||||
err := kv.BatchDelete(ctx, section, keys)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify existing keys are deleted
|
||||
_, err = kv.Get(ctx, testSection, key1)
|
||||
require.Error(t, err)
|
||||
_, err = kv.Get(ctx, section, "existing-key-1")
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, resource.ErrNotFound, err)
|
||||
|
||||
_, err = kv.Get(ctx, testSection, key2)
|
||||
require.Error(t, err)
|
||||
_, err = kv.Get(ctx, section, "existing-key-2")
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, resource.ErrNotFound, err)
|
||||
})
|
||||
|
||||
t.Run("batch delete with all non-existent keys", func(t *testing.T) {
|
||||
// Batch delete keys that don't exist
|
||||
keys := namespacedKeys(nsPrefix, []string{"non-existent-1", "non-existent-2", "non-existent-3"})
|
||||
err := kv.BatchDelete(ctx, testSection, keys)
|
||||
keys := []string{"non-existent-1", "non-existent-2", "non-existent-3"}
|
||||
err := kv.BatchDelete(ctx, section, keys)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("batch delete with empty keys list", func(t *testing.T) {
|
||||
keys := []string{}
|
||||
err := kv.BatchDelete(ctx, testSection, keys)
|
||||
err := kv.BatchDelete(ctx, section, keys)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("batch delete with empty section", func(t *testing.T) {
|
||||
keys := namespacedKeys(nsPrefix, []string{"some-key"})
|
||||
keys := []string{"some-key"}
|
||||
err := kv.BatchDelete(ctx, "", keys)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "section is required")
|
||||
@@ -900,27 +862,27 @@ func runTestKVBatchDelete(t *testing.T, kv resource.KV, nsPrefix string) {
|
||||
|
||||
t.Run("batch delete preserves other keys", func(t *testing.T) {
|
||||
// Setup test data
|
||||
saveKVHelper(t, kv, ctx, testSection, namespacedKey(nsPrefix, "keep-key-1"), strings.NewReader("keep-value-1"))
|
||||
saveKVHelper(t, kv, ctx, testSection, namespacedKey(nsPrefix, "delete-key-1"), strings.NewReader("delete-value-1"))
|
||||
saveKVHelper(t, kv, ctx, testSection, namespacedKey(nsPrefix, "keep-key-2"), strings.NewReader("keep-value-2"))
|
||||
saveKVHelper(t, kv, ctx, testSection, namespacedKey(nsPrefix, "delete-key-2"), strings.NewReader("delete-value-2"))
|
||||
saveKVHelper(t, kv, ctx, section, "keep-key-1", strings.NewReader("keep-value-1"))
|
||||
saveKVHelper(t, kv, ctx, section, "delete-key-1", strings.NewReader("delete-value-1"))
|
||||
saveKVHelper(t, kv, ctx, section, "keep-key-2", strings.NewReader("keep-value-2"))
|
||||
saveKVHelper(t, kv, ctx, section, "delete-key-2", strings.NewReader("delete-value-2"))
|
||||
|
||||
// Batch delete specific keys
|
||||
keys := namespacedKeys(nsPrefix, []string{"delete-key-1", "delete-key-2"})
|
||||
err := kv.BatchDelete(ctx, testSection, keys)
|
||||
keys := []string{"delete-key-1", "delete-key-2"}
|
||||
err := kv.BatchDelete(ctx, section, keys)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify deleted keys are gone
|
||||
_, err = kv.Get(ctx, testSection, namespacedKey(nsPrefix, "delete-key-1"))
|
||||
_, err = kv.Get(ctx, section, "delete-key-1")
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, resource.ErrNotFound, err)
|
||||
|
||||
_, err = kv.Get(ctx, testSection, namespacedKey(nsPrefix, "delete-key-2"))
|
||||
_, err = kv.Get(ctx, section, "delete-key-2")
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, resource.ErrNotFound, err)
|
||||
|
||||
// Verify kept keys still exist
|
||||
reader, err := kv.Get(ctx, testSection, namespacedKey(nsPrefix, "keep-key-1"))
|
||||
reader, err := kv.Get(ctx, section, "keep-key-1")
|
||||
require.NoError(t, err)
|
||||
value, err := io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
@@ -928,7 +890,7 @@ func runTestKVBatchDelete(t *testing.T, kv resource.KV, nsPrefix string) {
|
||||
err = reader.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
reader, err = kv.Get(ctx, testSection, namespacedKey(nsPrefix, "keep-key-2"))
|
||||
reader, err = kv.Get(ctx, section, "keep-key-2")
|
||||
require.NoError(t, err)
|
||||
value, err = io.ReadAll(reader)
|
||||
require.NoError(t, err)
|
||||
@@ -942,6 +904,18 @@ func runTestKVBatchDelete(t *testing.T, kv resource.KV, nsPrefix string) {
|
||||
func saveKVHelper(t *testing.T, kv resource.KV, ctx context.Context, section, key string, value io.Reader) {
|
||||
t.Helper()
|
||||
|
||||
// TODO: remove this check once the sqlkv implementation supports `Save`.
|
||||
type testingSaver interface {
|
||||
TestingSave(context.Context, string, []byte) error
|
||||
}
|
||||
|
||||
if saver, ok := kv.(testingSaver); ok {
|
||||
blob, err := io.ReadAll(value)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, saver.TestingSave(ctx, key, blob))
|
||||
return
|
||||
}
|
||||
|
||||
writer, err := kv.Save(ctx, section, key)
|
||||
require.NoError(t, err)
|
||||
_, err = io.Copy(writer, value)
|
||||
|
||||
@@ -44,5 +44,13 @@ func TestSQLKV(t *testing.T) {
|
||||
kv, err := resource.NewSQLKV(eDB)
|
||||
require.NoError(t, err)
|
||||
return kv
|
||||
}, &KVTestOptions{NSPrefix: "sql-kv-test"})
|
||||
}, &KVTestOptions{
|
||||
NSPrefix: "sql-kv-test",
|
||||
SkipTests: map[string]bool{
|
||||
TestKVSave: true,
|
||||
TestKVConcurrent: true,
|
||||
TestKVUnixTimestamp: true,
|
||||
TestKVBatchDelete: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1166,13 +1166,13 @@ func TestIntegrationDashboardServicePermissions(t *testing.T) {
|
||||
|
||||
resp, err := postDashboard(t, grafanaListedAddr, "viewer", "viewer", dashboardPayload)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
err = resp.Body.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err = postDashboard(t, grafanaListedAddr, "editor", "editor", dashboardPayload)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
err = resp.Body.Close()
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/xlab/treeprint"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
@@ -378,7 +377,7 @@ func getFoldersFromLegacyAPISearch(t *testing.T, client *rest.RESTClient) *Folde
|
||||
result = client.Get().AbsPath("api", "folders", hit.UID).
|
||||
Do(context.Background()).
|
||||
StatusCode(&statusCode)
|
||||
assert.NoError(t, result.Error(), "getting folder access info (/api) uid:%s", hit.UID)
|
||||
require.NoError(t, result.Error(), "getting folder access info (/api)")
|
||||
require.Equal(t, int(http.StatusOK), statusCode)
|
||||
|
||||
body, err := result.Raw()
|
||||
@@ -395,7 +394,7 @@ func makeRoot(lookup map[string]*FolderView, name string) *FolderView {
|
||||
shared := &FolderView{} // when not found
|
||||
root := &FolderView{}
|
||||
for _, v := range lookup {
|
||||
if folder.IsRootFolder(v.Parent) { // general or empty
|
||||
if v.Parent == "" {
|
||||
root.Children = append(root.Children, v)
|
||||
} else {
|
||||
p, ok := lookup[v.Parent]
|
||||
@@ -446,7 +445,7 @@ func getFoldersFromDashboardV0Search(t *testing.T, client *rest.RESTClient, ns s
|
||||
folderV1.APIVersion, "namespaces", ns, "folders", hit.Name, "access").
|
||||
Do(context.Background()).
|
||||
StatusCode(&statusCode)
|
||||
require.NoError(t, result.Error(), "getting folder access info (/access) name:%s", hit.Name)
|
||||
require.NoError(t, result.Error(), "getting folder access info (/access)")
|
||||
require.Equal(t, int(http.StatusOK), statusCode)
|
||||
|
||||
body, err := result.Raw()
|
||||
|
||||
@@ -133,14 +133,10 @@ func TestIntegrationFoldersApp(t *testing.T) {
|
||||
}`, string(v1Disco))
|
||||
})
|
||||
|
||||
// test on all dual writer modes
|
||||
modes := []grafanarest.DualWriterMode{
|
||||
grafanarest.Mode0, // legacy only
|
||||
grafanarest.Mode2, // write both, read legacy
|
||||
grafanarest.Mode3, // write both, read unified
|
||||
grafanarest.Mode4,
|
||||
}
|
||||
for _, modeDw := range modes {
|
||||
// test on all dualwriter modes
|
||||
for mode := 0; mode <= 4; mode++ {
|
||||
modeDw := grafanarest.DualWriterMode(mode)
|
||||
|
||||
t.Run(fmt.Sprintf("with dual write (unified storage, mode %v)", modeDw), func(t *testing.T) {
|
||||
doFolderTests(t, apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
DisableDataMigrations: true,
|
||||
@@ -1261,13 +1257,11 @@ func TestIntegrationFoldersGetAPIEndpointK8S(t *testing.T) {
|
||||
requestToAnotherOrg: true,
|
||||
},
|
||||
}
|
||||
for _, modeDw := range []grafanarest.DualWriterMode{
|
||||
grafanarest.Mode0, // legacy only
|
||||
grafanarest.Mode2, // write both, read legacy
|
||||
grafanarest.Mode3, // write both, read unified
|
||||
grafanarest.Mode4,
|
||||
} {
|
||||
t.Run(fmt.Sprintf("Mode_%d", modeDw), func(t *testing.T) {
|
||||
|
||||
for mode := 0; mode <= 4; mode++ {
|
||||
t.Run(fmt.Sprintf("Mode_%d", mode), func(t *testing.T) {
|
||||
modeDw := grafanarest.DualWriterMode(mode)
|
||||
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
DisableDataMigrations: true,
|
||||
AppModeProduction: true,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user