Compare commits

...

147 Commits

Author SHA1 Message Date
Ryan McKinley 352ac8fc5b lint 2025-12-31 11:57:21 +03:00
Ryan McKinley 00917091d7 remove version from snapshot 2025-12-31 10:27:50 +03:00
Ryan McKinley d73e4a229f Merge remote-tracking branch 'origin/main' into ds-apiserver-schema-builder 2025-12-31 10:08:44 +03:00
grafana-pr-automation[bot] bba5c44dc4 I18n: Download translations from Crowdin (#115757)
New Crowdin translations by GitHub Action

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-31 00:42:54 +00:00
Paul Marbach 44e6ea3d8b Gauge: Fix issues found during bug bash (#115740)
* fix warning for VizRepeater styles

* Gauge: Update test dashboard to round two of the segment panels to whole numbers

* Gauge: E2E tests

* add test for sparklines

* Gauge: Change inner glow to be friendlier to our a11y tests

* remove unused CODEOWNER declaration

* expose text mode so that old displayName usage is somewhat preserved

* update migrations to use the value_and_text mode if displayName has a non-empty value

* more test cases

* update unit tests for fixture updates
2025-12-30 15:27:32 -05:00
Kristina Demeshchik 014d4758c6 Dashboards: Prevent row selection when clicking canvas add actions (#115580)
* event propogation issues

* Action items width

* prevent pointer up event
2025-12-30 12:27:38 -07:00
Sean Griffin 82b4ce0ece Redesign Empty Transformation Panel (#115648)
Co-authored-by: Alex Spencer <52186778+alexjonspencer1@users.noreply.github.com>
2025-12-30 16:46:29 +00:00
Paul Marbach 52698cf0da Sparkline: Restore to a function component (#115447)
* Sparkline: Restore to a function component

* fix whitespace lint issue
2025-12-30 10:55:40 -05:00
Haris Rozajac d291dfb35b Dashboard Conversion: Fix type assertion mismatch in data loss detection (#115749) 2025-12-30 08:51:46 -07:00
Andrew Hackmann 9c6feb8de5 Elasticsearch: Builder queries no longer execute in code mode (#115456)
* The builder query no longer runs if code mode query is empty. Remove checks for query being empty to run raw query.

* missed save

* prettier?

* Update public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.ts

Co-authored-by: Andreas Christou <andreas.christou@grafana.com>

---------

Co-authored-by: Andreas Christou <andreas.christou@grafana.com>
2025-12-30 15:37:19 +00:00
Ayush Kaithwas e7625186af Dashboards: Clear edit pane selection when entering panel edit (#115658)
* Clear selection on entering edit mode. Added test to verify selection is cleared when editing a panel.

* Update comment

---------

Co-authored-by: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com>
2025-12-30 07:35:43 -07:00
Ryan McKinley 96e3fdbfd5 add plugin iniformation 2025-12-18 10:42:50 +03:00
Ryan McKinley 68a65af091 Merge remote-tracking branch 'origin/main' into ds-apiserver-schema-builder 2025-12-18 10:31:57 +03:00
Ryan McKinley 82c045e501 Merge remote-tracking branch 'origin/main' into ds-apiserver-schema-builder 2025-12-17 13:42:34 +03:00
Ryan McKinley 80d806a822 merge main 2025-12-15 08:52:54 +03:00
Ryan McKinley 570146fb36 openapi 2025-12-09 15:39:01 +03:00
Ryan McKinley 61ec394f59 replace 2025-12-09 15:20:31 +03:00
Ryan McKinley ad0adf79bd fix test spec 2025-12-09 15:08:16 +03:00
Ryan McKinley 1f396581a6 with spec 2025-12-09 15:01:33 +03:00
Ryan McKinley 406502f351 merge main 2025-12-09 14:25:29 +03:00
Ryan McKinley 9648c0956f merge main 2025-11-17 13:44:20 +03:00
Ryan McKinley 008d373f7a fix lint 2025-10-26 22:03:58 +03:00
Ryan McKinley 3f1145fe3b merge main 2025-10-26 21:35:49 +03:00
Ryan McKinley 09c5311797 merge main 2025-10-26 21:01:10 +03:00
Ryan McKinley 6df663584c add conversion 2025-10-16 16:14:27 +03:00
Ryan McKinley 2614a917de Merge remote-tracking branch 'origin/main' into ds-apiserver-schema-builder 2025-10-16 16:08:13 +03:00
Ryan McKinley a1015f7a9f Merge remote-tracking branch 'origin/main' into ds-apiserver-schema-builder 2025-10-16 16:04:44 +03:00
Ryan McKinley f17765b70c add comment 2025-10-16 15:54:54 +03:00
Ryan McKinley 9a05906299 add operations test 2025-10-16 15:49:37 +03:00
Ryan McKinley 969ae75b08 update openapi specs 2025-10-16 15:20:30 +03:00
Ryan McKinley eeb6d105ed update openapi specs 2025-10-16 15:18:53 +03:00
Ryan McKinley b93fb964b7 Merge remote-tracking branch 'origin/main' into ds-apiserver-schema-builder 2025-10-16 14:56:32 +03:00
Ryan McKinley d1657d4684 merge main 2025-10-14 15:48:41 +03:00
Ryan McKinley 63374d29c0 merge main 2025-10-08 12:54:38 +03:00
Ryan McKinley 379aff5ff4 merge main 2025-08-28 22:36:57 +03:00
Ryan McKinley 8bf8c07878 Merge remote-tracking branch 'origin/main' into ds-apiserver-schema-builder 2025-08-28 22:31:03 +03:00
Ryan McKinley f7dc2f6e56 Merge remote-tracking branch 'origin/main' into ds-apiserver-schema-builder 2025-08-27 14:06:47 +03:00
Ryan McKinley 704f533846 generic apiserver 2025-08-27 12:51:17 +03:00
Ryan McKinley 82312c3418 Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs 2025-08-27 12:50:11 +03:00
Ryan McKinley 87a9f26997 lint 2025-08-27 10:07:45 +03:00
Ryan McKinley 1fe4415682 Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs 2025-08-27 10:04:41 +03:00
Ryan McKinley a629b70c1f Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs 2025-08-27 00:50:13 +03:00
Ryan McKinley 36fe8c6b61 Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs 2025-08-26 12:36:14 +03:00
Ryan McKinley e96dd1b12a comments 2025-08-26 12:21:54 +03:00
Ryan McKinley a4adcf8896 better comment 2025-08-26 12:09:23 +03:00
Ryan McKinley 1671a8644f add feature toggle 2025-08-26 11:31:02 +03:00
Ryan McKinley bfcf649e8b remove plugin 2025-08-26 11:21:10 +03:00
Ryan McKinley ebb4cfadff full coverage for convert 2025-08-26 10:56:36 +03:00
Ryan McKinley 29e9ae1918 Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs 2025-08-26 10:51:34 +03:00
Ryan McKinley 540eb6c862 Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs 2025-08-26 08:41:07 +03:00
Ryan McKinley 17b20fb464 make update-workspace 2025-08-22 20:58:45 +03:00
Ryan McKinley dc986afd68 fix build 2025-08-22 20:51:11 +03:00
Ryan McKinley eba83d8973 feedback 2025-08-22 20:44:19 +03:00
Ryan McKinley cf89fb2a13 Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs 2025-08-22 20:24:22 +03:00
Ryan McKinley 290e8a97f1 merge main 2025-08-22 20:05:53 +03:00
Ryan McKinley 0ed24434c2 merge main 2025-08-21 14:54:26 +03:00
Ryan McKinley cbfb1e15ed Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs 2025-08-21 14:50:30 +03:00
Ryan McKinley 47e379c8d9 Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs 2025-08-21 14:27:49 +03:00
Ryan McKinley 500b029b25 exclude false values 2025-08-20 11:37:45 +03:00
Ryan McKinley bce28b8663 unstructured spec 2025-08-20 10:39:14 +03:00
Ryan McKinley b0c9350580 unstructured spec 2025-08-20 10:38:52 +03:00
Ryan McKinley 76b4c687b0 update comments 2025-08-20 09:31:41 +03:00
Ryan McKinley 0d6f718255 Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs 2025-08-20 09:13:30 +03:00
Ryan McKinley 6b72ddb4d3 cleanup 2025-08-19 23:50:26 +03:00
Ryan McKinley 2f094fdcd9 exclude empty secure values 2025-08-19 23:41:13 +03:00
Ryan McKinley 6a0ce01d18 more comments 2025-08-19 23:21:43 +03:00
Ryan McKinley d00b8ab76d Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs 2025-08-19 23:17:31 +03:00
Ryan McKinley e577b8d0b8 rename ListDataSources 2025-08-19 22:50:50 +03:00
Ryan McKinley 2270d6cb22 remove dummy validation 2025-08-19 22:01:58 +03:00
Ryan McKinley c539be48d8 remove dummy validatio 2025-08-19 21:23:32 +03:00
Ryan McKinley 03a2153bd8 merge main 2025-08-19 20:31:38 +03:00
Ryan McKinley eb01a3e462 Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs 2025-08-19 20:25:01 +03:00
Ryan McKinley f6b6b62f5e update name comment 2025-08-19 13:03:02 +03:00
Ryan McKinley 5a2351387a Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs 2025-08-19 11:24:22 +03:00
Ryan McKinley 78d9829d3b Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs 2025-08-06 23:44:34 +03:00
Ryan McKinley d9e1adaa48 rename connections 2025-08-05 14:09:07 +03:00
Ryan McKinley 949b521ac7 Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs 2025-08-05 14:04:48 +03:00
Ryan McKinley 1d9572cdb2 Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs 2025-07-31 21:00:39 +02:00
Ryan McKinley f5e649183b do not expose OpenAPI unless it exists 2025-07-29 13:29:28 +02:00
Ryan McKinley 4b44a83802 Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs 2025-07-29 13:06:06 +02:00
Ryan McKinley 26dc101ecc Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs 2025-07-28 15:42:32 +02:00
Ryan McKinley ca2f2e8d9e Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs 2025-07-28 12:50:03 +02:00
Ryan McKinley 0e10d9cd16 merge main 2025-07-28 12:06:25 +02:00
Ryan McKinley ed9789179b Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs 2025-07-17 10:21:38 -07:00
Ryan McKinley 5890137232 fix unique name constraint 2025-07-11 12:52:23 -07:00
Ryan McKinley 471c4eb89d fix unique name constraint 2025-07-11 12:50:47 -07:00
Ryan McKinley 3ac63ea9c4 update comments 2025-07-11 12:08:12 -07:00
Ryan McKinley 7d2d38fd94 merge main 2025-07-11 09:16:23 -07:00
Ryan McKinley 80dda87868 Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs 2025-07-11 09:16:19 -07:00
Ryan McKinley 04a4fd7c61 Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs 2025-07-10 14:54:39 -07:00
Ryan McKinley 1d26b455fd use common secure values 2025-07-10 14:39:14 -07:00
Ryan McKinley 8f0109a1ee Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs 2025-07-10 14:29:04 -07:00
Ryan McKinley 798e9a32fc unstructured IsZero 2025-07-10 12:46:33 -07:00
Ryan McKinley f4001d7cdc Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs 2025-07-10 11:59:23 -07:00
Ryan McKinley 718c28438e avoid lint issue 2025-07-10 10:00:36 -07:00
Ryan McKinley b58b9144a5 Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs 2025-07-10 09:51:51 -07:00
Ryan McKinley 96830de552 Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs 2025-07-09 14:35:20 -07:00
Ryan McKinley 58c956eb19 revert depguard config changes 2025-07-08 18:03:57 -07:00
Ryan McKinley b1d8e9ca41 Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs 2025-07-08 18:00:28 -07:00
Ryan McKinley 4006a61964 update go.mod 2025-07-08 07:10:27 -07:00
Ryan McKinley 0a24935e45 depguard 2025-07-08 07:09:40 -07:00
Ryan McKinley f25c9b0e03 merge main 2025-07-08 06:29:45 -07:00
Ryan McKinley 5c478e98c4 Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs 2025-07-08 06:29:39 -07:00
Ryan McKinley ddc99a1dca Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs 2025-07-07 19:06:12 -07:00
Ryan McKinley e12d1ba6ca Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs 2025-07-07 13:07:20 -07:00
Ryan McKinley 420e070aea Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs 2025-07-07 10:38:32 -07:00
Ryan McKinley 732d4351de with hooks 2025-07-03 17:44:44 -07:00
Ryan McKinley f842bb7af7 merge main 2025-07-03 15:01:11 -07:00
Ryan McKinley a1e2ba6617 Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs 2025-07-03 14:58:45 -07:00
Ryan McKinley 1cade082fc now with routes for testdata 2025-07-03 14:43:12 -07:00
Ryan McKinley ea38c4ad5a custom jsonschema for testdata 2025-07-03 13:03:41 -07:00
Ryan McKinley 01926ab3c8 Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs 2025-07-03 10:30:38 -07:00
Ryan McKinley 84a5282ea2 fix test 2025-07-03 10:21:28 -07:00
Ryan McKinley 35d7bb880a Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs 2025-07-03 10:18:39 -07:00
Ryan McKinley 251b7b4b4e hardcode custom spec 2025-07-02 23:08:20 -07:00
Ryan McKinley 4e3197a58f fix spelling 2025-07-02 19:40:19 -07:00
Ryan McKinley 1a883d1167 Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs 2025-07-02 19:34:48 -07:00
Ryan McKinley cc2e96a558 update openapi specs 2025-07-02 14:58:34 -07:00
Ryan McKinley 6b2ebb2d65 lint 2025-07-02 14:43:37 -07:00
Ryan McKinley 7cc36672bb fix integration test 2025-07-02 14:41:52 -07:00
Ryan McKinley 5be096833a run codegen 2025-07-02 14:27:31 -07:00
Ryan McKinley 076d1a5ad5 revert go.mod changes 2025-07-02 13:53:22 -07:00
Ryan McKinley 775ed81b58 Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs 2025-07-02 13:37:12 -07:00
Ryan McKinley fb0aaa321e merge main 2025-07-02 13:33:33 -07:00
Ryan McKinley c1c1f3a85c merge main 2025-07-02 10:06:43 -07:00
Ryan McKinley c0f1a6423c Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs 2025-07-02 09:56:20 -07:00
Ryan McKinley a44fdee0ef merge upstream service changes 2025-07-01 17:00:04 -07:00
Ryan McKinley 97a089ef05 Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs 2025-07-01 16:53:45 -07:00
Ryan McKinley b2799f977f use config from secrets service 2025-07-01 13:44:47 -07:00
Ryan McKinley 2d4fd99e7a update openapi 2025-07-01 09:14:01 -07:00
Ryan McKinley c4842845e7 Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs 2025-06-30 13:47:39 -07:00
Ryan McKinley e9105e6303 Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs 2025-06-27 20:08:48 -07:00
Ryan McKinley 841e0bd5e5 update openapi 2025-06-27 16:29:22 -07:00
Ryan McKinley 5c80bc12f0 organize models 2025-06-27 16:03:59 -07:00
Ryan McKinley 60981e813b organize models 2025-06-27 16:03:24 -07:00
Ryan McKinley 94eea040e2 Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs 2025-06-27 15:15:34 -07:00
Ryan McKinley 6869160e97 merge main 2025-06-27 12:49:54 -07:00
Ryan McKinley 4dc6ad9257 use plural 2025-06-20 10:14:18 +03:00
Ryan McKinley b2c368e1cd use plural 2025-06-20 10:09:34 +03:00
Ryan McKinley 1ea3b5440c secure value update 2025-06-20 00:18:14 +03:00
Ryan McKinley 9bbe311bdf Merge remote-tracking branch 'origin/main' into ds-apiserver-with-configs 2025-06-20 00:18:05 +03:00
Ryan McKinley 5a338adf47 add basic tests 2025-06-19 23:42:39 +03:00
Ryan McKinley 1d218d01b2 wire up dual writer 2025-06-19 20:19:48 +03:00
Ryan McKinley ae6ba4ba44 fill stubs for cloud config 2025-06-19 20:05:36 +03:00
Ryan McKinley 90614fcb91 use readme 2025-06-19 17:57:05 +03:00
Ryan McKinley 1bedd399d6 with configs 2025-06-19 17:33:38 +03:00
Ryan McKinley 0bff610506 with configs 2025-06-19 17:27:40 +03:00
75 changed files with 1934 additions and 330 deletions
-1
View File
@@ -501,7 +501,6 @@ i18next.config.ts @grafana/grafana-frontend-platform
/e2e-playwright/various-suite/filter-annotations.spec.ts @grafana/dashboards-squad
/e2e-playwright/various-suite/frontend-sandbox-app.spec.ts @grafana/plugins-platform-frontend
/e2e-playwright/various-suite/frontend-sandbox-datasource.spec.ts @grafana/plugins-platform-frontend
/e2e-playwright/various-suite/gauge.spec.ts @grafana/dataviz-squad
/e2e-playwright/various-suite/grafana-datasource-random-walk.spec.ts @grafana/grafana-frontend-platform
/e2e-playwright/various-suite/graph-auto-migrate.spec.ts @grafana/dataviz-squad
/e2e-playwright/various-suite/inspect-drawer.spec.ts @grafana/dashboards-squad
@@ -180,12 +180,15 @@ func countAnnotationsV0V1(spec map[string]interface{}) int {
return 0
}
annotationList, ok := annotations["list"].([]interface{})
if !ok {
return 0
// Handle both []interface{} (from JSON unmarshaling) and []map[string]interface{} (from programmatic creation)
if annotationList, ok := annotations["list"].([]interface{}); ok {
return len(annotationList)
}
if annotationList, ok := annotations["list"].([]map[string]interface{}); ok {
return len(annotationList)
}
return len(annotationList)
return 0
}
// countLinksV0V1 counts dashboard links in v0alpha1 or v1beta1 dashboard spec
@@ -194,12 +197,15 @@ func countLinksV0V1(spec map[string]interface{}) int {
return 0
}
links, ok := spec["links"].([]interface{})
if !ok {
return 0
// Handle both []interface{} (from JSON unmarshaling) and []map[string]interface{} (from programmatic creation)
if links, ok := spec["links"].([]interface{}); ok {
return len(links)
}
if links, ok := spec["links"].([]map[string]interface{}); ok {
return len(links)
}
return len(links)
return 0
}
// countVariablesV0V1 counts template variables in v0alpha1 or v1beta1 dashboard spec
@@ -213,12 +219,15 @@ func countVariablesV0V1(spec map[string]interface{}) int {
return 0
}
variableList, ok := templating["list"].([]interface{})
if !ok {
return 0
// Handle both []interface{} (from JSON unmarshaling) and []map[string]interface{} (from programmatic creation)
if variableList, ok := templating["list"].([]interface{}); ok {
return len(variableList)
}
if variableList, ok := templating["list"].([]map[string]interface{}); ok {
return len(variableList)
}
return len(variableList)
return 0
}
// collectStatsV0V1 collects statistics from v0alpha1 or v1beta1 dashboard
@@ -628,6 +628,20 @@
}
],
"title": "Only nulls and no user set min \u0026 max",
"transformations": [
{
"id": "convertFieldType",
"options": {
"conversions": [
{
"destinationType": "number",
"targetField": "A-series"
}
],
"fields": {}
}
}
],
"type": "gauge"
},
{
@@ -1179,4 +1193,4 @@
"title": "Panel Tests - Gauge",
"uid": "_5rDmaQiz",
"weekStart": ""
}
}
@@ -1760,6 +1760,22 @@
"startValue": 0
}
],
"transformations": [
{
"id": "calculateField",
"options": {
"mode": "unary",
"reduce": {
"reducer": "sum"
},
"replaceFields": true,
"unary": {
"operator": "round",
"fieldName": "A-series"
}
}
}
],
"title": "Active gateways",
"type": "radialbar"
},
@@ -1843,6 +1859,22 @@
"startValue": 0
}
],
"transformations": [
{
"id": "calculateField",
"options": {
"mode": "unary",
"reduce": {
"reducer": "sum"
},
"replaceFields": true,
"unary": {
"operator": "round",
"fieldName": "A-series"
}
}
}
],
"title": "Active pods",
"type": "radialbar"
},
@@ -485,6 +485,7 @@
},
"id": 12,
"options": {
"displayName": "My gauge",
"minVizHeight": 75,
"minVizWidth": 75,
"orientation": "auto",
@@ -600,6 +600,20 @@
"stringInput": "null,null"
}
],
"transformations": [
{
"id": "convertFieldType",
"options": {
"fields": {},
"conversions": [
{
"targetField": "A-series",
"destinationType": "number"
}
]
}
}
],
"title": "Only nulls and no user set min & max",
"type": "gauge"
},
@@ -1718,6 +1718,22 @@
"startValue": 0
}
],
"transformations": [
{
"id": "calculateField",
"options": {
"mode": "unary",
"reduce": {
"reducer": "sum"
},
"replaceFields": true,
"unary": {
"operator": "round",
"fieldName": "A-series"
}
}
}
],
"title": "Active gateways",
"type": "radialbar"
},
@@ -1799,6 +1815,22 @@
"startValue": 0
}
],
"transformations": [
{
"id": "calculateField",
"options": {
"mode": "unary",
"reduce": {
"reducer": "sum"
},
"replaceFields": true,
"unary": {
"operator": "round",
"fieldName": "A-series"
}
}
}
],
"title": "Active pods",
"type": "radialbar"
},
@@ -474,6 +474,7 @@
},
"id": 12,
"options": {
"displayName": "My gauge",
"minVizHeight": 75,
"minVizWidth": 75,
"orientation": "auto",
+101
View File
@@ -0,0 +1,101 @@
import { test, expect } from '@grafana/plugin-e2e';
// this test requires a larger viewport so all gauge panels load properly
test.use({
featureToggles: { newGauge: true },
viewport: { width: 1280, height: 3000 },
});
const OLD_GAUGES_DASHBOARD_UID = '_5rDmaQiz';
const NEW_GAUGES_DASHBOARD_UID = 'panel-tests-gauge-new';
test.describe(
'Gauge Panel',
{
tag: ['@panels', '@gauge'],
},
() => {
test('successfully migrates all gauge panels', async ({ gotoDashboardPage, selectors }) => {
const dashboardPage = await gotoDashboardPage({ uid: OLD_GAUGES_DASHBOARD_UID });
// check that gauges are rendered
const gaugeElements = dashboardPage.getByGrafanaSelector(
selectors.components.Panels.Visualization.Gauge.Container
);
await expect(gaugeElements).toHaveCount(16);
// check that no panel errors exist
const errorInfo = dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.headerCornerInfo('error'));
await expect(errorInfo).toBeHidden();
});
test('renders new gauge panels', async ({ gotoDashboardPage, selectors }) => {
// open Panel Tests - Gauge
const dashboardPage = await gotoDashboardPage({ uid: NEW_GAUGES_DASHBOARD_UID });
// check that gauges are rendered
const gaugeElements = dashboardPage.getByGrafanaSelector(
selectors.components.Panels.Visualization.Gauge.Container
);
await expect(gaugeElements).toHaveCount(32);
// check that no panel errors exist
const errorInfo = dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.headerCornerInfo('error'));
await expect(errorInfo).toBeHidden();
});
test('renders sparklines in gauge panels', async ({ gotoDashboardPage, page }) => {
await gotoDashboardPage({
uid: NEW_GAUGES_DASHBOARD_UID,
queryParams: new URLSearchParams({ editPanel: '11' }),
});
await expect(page.locator('.uplot')).toHaveCount(5);
});
test('"no data"', async ({ gotoDashboardPage, selectors }) => {
const dashboardPage = await gotoDashboardPage({
uid: NEW_GAUGES_DASHBOARD_UID,
queryParams: new URLSearchParams({ editPanel: '36' }),
});
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Visualization.Gauge.Container),
'that the gauge does not appear'
).toBeHidden();
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.PanelDataErrorMessage),
'that the empty text appears'
).toHaveText('No data');
// update the "No value" option and see if the panel updates
const noValueOption = dashboardPage
.getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.fieldLabel('Standard options No value'))
.locator('input');
await noValueOption.fill('My empty value');
await noValueOption.blur();
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Visualization.Gauge.Container),
'that the empty text shows up in an empty gauge'
).toHaveText('My empty value');
// test the "no numeric fields" message on the next panel
const dashboardPage2 = await gotoDashboardPage({
uid: NEW_GAUGES_DASHBOARD_UID,
queryParams: new URLSearchParams({ editPanel: '37' }),
});
await expect(
dashboardPage2.getByGrafanaSelector(selectors.components.Panels.Visualization.Gauge.Container),
'that the gauge does not appear'
).toBeHidden();
await expect(
dashboardPage2.getByGrafanaSelector(selectors.components.Panels.Panel.PanelDataErrorMessage),
'that the empty text appears'
).toHaveText('Data is missing a number field');
});
}
);
@@ -1,27 +0,0 @@
import { test, expect } from '@grafana/plugin-e2e';
// this test requires a larger viewport so all gauge panels load properly
test.use({
viewport: { width: 1280, height: 1080 },
});
test.describe(
'Gauge Panel',
{
tag: ['@various'],
},
() => {
test('Gauge rendering e2e tests', async ({ gotoDashboardPage, selectors, page }) => {
// open Panel Tests - Gauge
const dashboardPage = await gotoDashboardPage({ uid: '_5rDmaQiz' });
// check that gauges are rendered
const gaugeElements = page.locator('.flot-base');
await expect(gaugeElements).toHaveCount(16);
// check that no panel errors exist
const errorInfo = dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.headerCornerInfo('error'));
await expect(errorInfo).toBeHidden();
});
}
);
@@ -535,6 +535,11 @@ export const versionedComponents = {
'12.3.0': 'data-testid viz-tooltip-wrapper',
},
},
Gauge: {
Container: {
'12.4.0': 'data-testid gauge container',
},
},
},
},
VizLegend: {
@@ -35,6 +35,7 @@ export interface Options extends common.SingleStatBaseOptions {
showThresholdLabels: boolean;
showThresholdMarkers: boolean;
sparkline?: boolean;
textMode?: ('auto' | 'value_and_name' | 'value' | 'name' | 'none');
}
export const defaultOptions: Partial<Options> = {
@@ -48,4 +49,5 @@ export const defaultOptions: Partial<Options> = {
showThresholdLabels: false,
showThresholdMarkers: true,
sparkline: true,
textMode: 'auto',
};
@@ -248,15 +248,17 @@ export function PanelChrome({
const onContentPointerDown = React.useCallback(
(evt: React.PointerEvent) => {
// Ignore clicks inside buttons, links, canvas and svg elments
// When selected, ignore clicks inside buttons, links, canvas and svg elments
// This does prevent a clicks inside a graphs from selecting panel as there is normal div above the canvas element that intercepts the click
if (evt.target instanceof Element && evt.target.closest('button,a,canvas,svg')) {
if (isSelected && evt.target instanceof Element && evt.target.closest('button,a,canvas,svg')) {
// Stop propagation otherwise row config editor will get selected
evt.stopPropagation();
return;
}
onSelect?.(evt);
},
[onSelect]
[isSelected, onSelect]
);
const headerContent = (
@@ -32,24 +32,6 @@ 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,
@@ -2,6 +2,7 @@ import { css, cx } from '@emotion/css';
import { useId } from 'react';
import { DisplayValueAlignmentFactors, FALLBACK_COLOR, FieldDisplay, GrafanaTheme2, TimeRange } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { t } from '@grafana/i18n';
import { useStyles2, useTheme2 } from '../../themes/ThemeContext';
@@ -275,7 +276,11 @@ export function RadialGauge(props: RadialGaugeProps) {
}
return (
<div className={styles.vizWrapper} style={{ width, height }}>
<div
data-testid={selectors.components.Panels.Visualization.Gauge.Container}
className={styles.vizWrapper}
style={{ width, height }}
>
{body}
</div>
);
@@ -1,4 +1,4 @@
import { GrafanaTheme2 } from '@grafana/data';
import { colorManipulator, GrafanaTheme2 } from '@grafana/data';
import { RadialGaugeDimensions } from './types';
@@ -25,13 +25,14 @@ export function GlowGradient({ id, barWidth }: GlowGradientProps) {
);
}
const CENTER_GLOW_OPACITY = 0.15;
const CENTER_GLOW_OPACITY = 0.25;
export function CenterGlowGradient({ gaugeId, color }: { gaugeId: string; color: string }) {
const transparentColor = colorManipulator.alpha(color, CENTER_GLOW_OPACITY);
return (
<radialGradient id={`circle-glow-${gaugeId}`} r="50%" fr="0%">
<stop offset="0%" stopColor={color} stopOpacity={CENTER_GLOW_OPACITY} />
<stop offset="90%" stopColor={color} stopOpacity={0} />
<stop offset="0%" stopColor={transparentColor} />
<stop offset="90%" stopColor={'#ffffff00'} />
</radialGradient>
);
}
@@ -44,13 +45,14 @@ export interface CenterGlowProps {
export function MiddleCircleGlow({ dimensions, gaugeId, color }: CenterGlowProps) {
const gradientId = `circle-glow-${gaugeId}`;
const transparentColor = color ? colorManipulator.alpha(color, CENTER_GLOW_OPACITY) : color;
return (
<>
<defs>
<radialGradient id={gradientId} r="50%" fr="0%">
<stop offset="0%" stopColor={color} stopOpacity={CENTER_GLOW_OPACITY} />
<stop offset="90%" stopColor={color} stopOpacity={0} />
<stop offset="0%" stopColor={transparentColor} />
<stop offset="90%" stopColor="#ffffff00" />
</radialGradient>
</defs>
<g>
@@ -86,9 +88,9 @@ export function SpotlightGradient({
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} />}
<stop offset="0%" stopColor="#ffffff00" />
<stop offset="95%" stopColor="#ffffff88" />
{roundedBars && <stop offset="100%" stopColor={roundedBars ? '#ffffffbb' : 'white'} />}
</linearGradient>
);
}
@@ -17,8 +17,9 @@ export interface SparklineProps extends Themeable2 {
showHighlights?: boolean;
}
export const SparklineFn: React.FC<SparklineProps> = memo((props) => {
export const Sparkline: React.FC<SparklineProps> = memo((props) => {
const { sparkline, config: fieldConfig, theme, width, height, showHighlights } = props;
const { frame: alignedDataFrame, warning } = prepareSeries(sparkline, theme, fieldConfig, showHighlights);
if (warning) {
return null;
@@ -30,14 +31,4 @@ export const SparklineFn: React.FC<SparklineProps> = memo((props) => {
return <UPlotChart data={data} config={configBuilder} width={width} height={height} />;
});
SparklineFn.displayName = 'Sparkline';
// we converted to function component above, but some apps extend Sparkline, so we need
// to keep exporting a class component until those apps are all rolled out.
// see https://github.com/grafana/app-observability-plugin/pull/2079
// eslint-disable-next-line react-prefer-function-component/react-prefer-function-component
export class Sparkline extends React.PureComponent<SparklineProps> {
render() {
return <SparklineFn {...this.props} />;
}
}
Sparkline.displayName = 'Sparkline';
@@ -167,7 +167,8 @@ export class VizRepeater<V, D = {}> extends PureComponent<PropsWithDefaults<V, D
const repeaterStyle: React.CSSProperties = {
display: 'flex',
overflow: `${minVizWidth ? 'auto' : 'hidden'} ${minVizHeight ? 'auto' : 'hidden'}`,
overflowX: `${minVizWidth ? 'auto' : 'hidden'}`,
overflowY: `${minVizHeight ? 'auto' : 'hidden'}`,
};
let vizHeight = height;
+44
View File
@@ -0,0 +1,44 @@
package v0alpha1
import (
"k8s.io/kube-openapi/pkg/spec3"
"k8s.io/kube-openapi/pkg/validation/spec"
)
// Optional extensions for an explicit datasource type
// NOTE: the properties from this structure will be populated by reading an app-sdk manifest.json
type DataSourceOpenAPIExtension struct {
// When specified, this will replace the default spec
DataSourceSpec *spec.Schema `json:"spec,omitempty"`
// Define which secure values are required
SecureValues []SecureValueInfo `json:"secureValues"`
// Examples added to the POST command
Examples map[string]*spec3.Example `json:"examples,omitempty"`
// Additional Schemas added to the response
Schemas map[string]*spec.Schema `json:"schemas,omitempty"`
// TODO: define query types dynamically here (currently hardcoded)
// Queries *queryV0.QueryTypeDefinitionList `json:"queries,omitempty"`
// Resource routes -- the paths exposed under:
// {group}/{version}/namespaces/{ns}/datasource/{name}/resource/{route}
Routes map[string]*spec3.Path `json:"routes,omitempty"`
// Proxy routes -- the paths exposed under:
// {group}/{version}/namespaces/{ns}/datasource/{name}/proxy/{proxy}
Proxy map[string]*spec3.Path `json:"proxy,omitempty"`
}
type SecureValueInfo struct {
// The key
Key string `json:"string"`
// Description
Description string `json:"description,omitempty"`
// Required secure values
Required bool `json:"required,omitempty"`
}
+18
View File
@@ -12,6 +12,7 @@ import (
"errors"
"fmt"
"log"
"reflect"
)
// returns the current implementation version
@@ -117,6 +118,23 @@ func (j *Json) Interface() any {
return j.data
}
// Check if the underlying data is empty
func (j *Json) IsEmpty() bool {
if j.data == nil {
return true
}
v := reflect.ValueOf(j.data)
switch v.Kind() {
case reflect.Slice, reflect.Array, reflect.Map, reflect.String:
if v.Len() == 0 {
return true
}
default:
return false
}
return false
}
// Encode returns its marshaled data as `[]byte`
func (j *Json) Encode() ([]byte, error) {
return j.MarshalJSON()
@@ -5,6 +5,7 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSimplejson(t *testing.T) {
@@ -272,3 +273,45 @@ func TestMustJson(t *testing.T) {
MustJson([]byte(`{`))
})
}
func TestEmpty(t *testing.T) {
testCases := []struct {
name string
input any
empty bool
}{
{
name: "empty map (any)",
input: map[string]any{},
empty: true,
}, {
name: "empty map (string)",
input: map[string]string{},
empty: true,
}, {
name: "empty array (any)",
input: []any{},
empty: true,
}, {
name: "empty array (string)",
input: []string{},
empty: true,
}, {
name: "empty string",
input: "",
empty: true,
}, {
name: "non empty string",
input: "hello",
}, {
name: "key value",
input: map[string]any{"key": "value"},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
js := NewFromAny(tc.input)
require.Equal(t, tc.empty, js.IsEmpty())
})
}
}
+19 -5
View File
@@ -55,19 +55,33 @@ func (r *converter) asDataSource(ds *datasources.DataSource) (*datasourceV0.Data
SetBasicAuthUser(ds.BasicAuthUser).
SetWithCredentials(ds.WithCredentials).
SetIsDefault(ds.IsDefault).
SetReadOnly(ds.ReadOnly).
SetJSONData(ds.JsonData)
SetReadOnly(ds.ReadOnly)
if ds.JsonData != nil && !ds.JsonData.IsEmpty() {
obj.Spec.SetJSONData(ds.JsonData.Interface())
}
rv := int64(0)
if !ds.Created.IsZero() {
obj.CreationTimestamp = metav1.NewTime(ds.Created)
rv = ds.Created.UnixMilli()
}
// Only mark updated if the times have actually changed
if !ds.Updated.IsZero() {
obj.ResourceVersion = fmt.Sprintf("%d", ds.Updated.UnixMilli())
obj.Annotations = map[string]string{
utils.AnnoKeyUpdatedTimestamp: ds.Updated.Format(time.RFC3339),
rv = ds.Updated.UnixMilli()
delta := rv - obj.CreationTimestamp.UnixMilli()
if delta > 1500 {
obj.Annotations = map[string]string{
utils.AnnoKeyUpdatedTimestamp: ds.Updated.UTC().Format(time.RFC3339),
}
}
}
if rv > 0 {
obj.ResourceVersion = strconv.FormatInt(rv, 10)
}
if ds.APIVersion != "" {
obj.APIVersion = fmt.Sprintf("%s/%s", r.group, ds.APIVersion)
}
@@ -0,0 +1,275 @@
package hardcoded
import (
"k8s.io/kube-openapi/pkg/spec3"
"k8s.io/kube-openapi/pkg/validation/spec"
datasourceV0 "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1"
)
func TestdataOpenAPIExtension() (*datasourceV0.DataSourceOpenAPIExtension, error) {
oas := &datasourceV0.DataSourceOpenAPIExtension{
SecureValues: []datasourceV0.SecureValueInfo{ // empty
// {
// Key: "aaa",
// Description: "describe aaa",
// Required: true,
// }, {
// Key: "bbb",
// Description: "describe bbb",
// },
},
Examples: map[string]*spec3.Example{
"": {
ExampleProps: spec3.ExampleProps{
Summary: "Empty testdata",
Value: map[string]any{
"apiVersion": "testdata.datasource.grafana.app/v0alpha1",
"kind": "DataSource",
"metadata": map[string]any{
"name": "my-testdata-datasource",
},
"spec": map[string]any{
"title": "My TestData Datasource",
},
},
},
},
"with-url": {
ExampleProps: spec3.ExampleProps{
Summary: "Testdata with URL (not used)",
Value: map[string]any{
"apiVersion": "testdata.datasource.grafana.app/v0alpha1",
"kind": "DataSource",
"metadata": map[string]any{
"name": "testdata-with-url",
},
"spec": map[string]any{
"title": "TestData with URL",
"url": "http://example.com",
},
},
},
},
},
}
// Dummy spec
p := &spec.Schema{} //SchemaProps: spec.SchemaProps{Type: []string{"object"}}}
p.Description = "Test data does not require any explicit configuration"
p.Required = []string{"title"}
p.AdditionalProperties = &spec.SchemaOrBool{Allows: false}
p.Properties = map[string]spec.Schema{
"title": *spec.StringProperty().WithDescription("display name"),
"url": *spec.StringProperty().WithDescription("not used"),
}
p.Example = map[string]any{
"url": "http://xxxx",
}
oas.DataSourceSpec = p
// Resource routes
// https://github.com/grafana/grafana/blob/main/pkg/tsdb/grafana-testdata-datasource/resource_handler.go#L20
unstructured := spec.MapProperty(nil)
unstructuredResponse := &spec3.Responses{
ResponsesProps: spec3.ResponsesProps{
Default: &spec3.Response{
ResponseProps: spec3.ResponseProps{
Content: map[string]*spec3.MediaType{
"application/json": {
MediaTypeProps: spec3.MediaTypeProps{
Schema: unstructured,
},
},
},
},
},
},
}
unstructuredRequest := &spec3.RequestBody{
RequestBodyProps: spec3.RequestBodyProps{
Content: map[string]*spec3.MediaType{
"application/json": {
MediaTypeProps: spec3.MediaTypeProps{
Schema: unstructured,
},
},
},
},
}
oas.Routes = map[string]*spec3.Path{
"": {
PathProps: spec3.PathProps{
Summary: "hello world",
Get: &spec3.Operation{
OperationProps: spec3.OperationProps{
Responses: &spec3.Responses{
ResponsesProps: spec3.ResponsesProps{
Default: &spec3.Response{
ResponseProps: spec3.ResponseProps{
Content: map[string]*spec3.MediaType{
"text/plain": {
MediaTypeProps: spec3.MediaTypeProps{
Schema: spec.StringProperty(),
},
},
},
},
},
},
},
},
},
},
},
"/scenarios": {
PathProps: spec3.PathProps{
Summary: "hello world",
Get: &spec3.Operation{
OperationProps: spec3.OperationProps{
Responses: unstructuredResponse,
},
},
},
},
"/stream": {
PathProps: spec3.PathProps{
Summary: "Get streaming response",
Get: &spec3.Operation{
OperationProps: spec3.OperationProps{
Parameters: []*spec3.Parameter{
{
ParameterProps: spec3.ParameterProps{
Name: "count",
In: "query",
Schema: spec.Int64Property(),
Description: "number of points that will be returned",
Example: 10,
},
},
{
ParameterProps: spec3.ParameterProps{
Name: "start",
In: "query",
Schema: spec.Int64Property(),
Description: "the start value",
},
},
{
ParameterProps: spec3.ParameterProps{
Name: "flush",
In: "query",
Schema: spec.Int64Property(),
Description: "How often the result is flushed (1-100%)",
Example: 100,
},
},
{
ParameterProps: spec3.ParameterProps{
Name: "speed",
In: "query",
Schema: spec.StringProperty(),
Description: "the clock cycle",
Example: "100ms",
},
},
{
ParameterProps: spec3.ParameterProps{
Name: "format",
In: "query",
Schema: spec.StringProperty().WithEnum("json", "influx"),
Description: "the response format",
},
},
},
Responses: unstructuredResponse,
},
},
},
},
"/boom": {
PathProps: spec3.PathProps{
Summary: "force a panic",
Get: &spec3.Operation{
OperationProps: spec3.OperationProps{
Responses: unstructuredResponse,
},
},
Post: &spec3.Operation{
OperationProps: spec3.OperationProps{
Responses: unstructuredResponse,
},
},
},
},
"/test": {
PathProps: spec3.PathProps{
Summary: "Echo any request",
Post: &spec3.Operation{
OperationProps: spec3.OperationProps{
RequestBody: unstructuredRequest,
Responses: unstructuredResponse,
},
},
},
},
"/test/json": {
PathProps: spec3.PathProps{
Summary: "Echo json request",
Post: &spec3.Operation{
OperationProps: spec3.OperationProps{
RequestBody: unstructuredRequest,
Responses: unstructuredResponse,
},
},
},
},
"/sims": {
PathProps: spec3.PathProps{
Description: "Get list of simulations",
Get: &spec3.Operation{
OperationProps: spec3.OperationProps{
Responses: unstructuredResponse,
},
},
},
},
"/sim/{key}": {
PathProps: spec3.PathProps{
Description: "Get list of simulations",
Get: &spec3.Operation{
OperationProps: spec3.OperationProps{
Parameters: []*spec3.Parameter{
{
ParameterProps: spec3.ParameterProps{
Name: "key",
In: "path",
Description: "simulation key (should include hz)",
},
},
},
Responses: unstructuredResponse,
},
},
Post: &spec3.Operation{
OperationProps: spec3.OperationProps{
Parameters: []*spec3.Parameter{
{
ParameterProps: spec3.ParameterProps{
Name: "key",
In: "path",
Description: "simulation key (should include hz)",
},
},
},
RequestBody: unstructuredRequest,
Responses: unstructuredResponse,
},
},
},
},
}
return oas, nil
}
@@ -0,0 +1,24 @@
package hardcoded
import (
"encoding/json"
"fmt"
"testing"
"github.com/stretchr/testify/require"
// "sigs.k8s.io/yaml" // uses the same structure as json!
)
func TestSpec(t *testing.T) {
info, err := TestdataOpenAPIExtension()
require.NoError(t, err)
require.NotNil(t, info)
jj, err := json.MarshalIndent(info, "", " ")
require.NoError(t, err)
fmt.Printf("%s\n", string(jj))
// jj, err = yaml.Marshal(info)
// require.NoError(t, err)
// fmt.Printf("%s\n", string(jj))
}
+15 -1
View File
@@ -2,6 +2,7 @@ package datasource
import (
"context"
"errors"
"fmt"
"time"
@@ -9,11 +10,15 @@ import (
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/util/validation/field"
"k8s.io/apiserver/pkg/registry/rest"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/apis/datasource/v0alpha1"
"github.com/grafana/grafana/pkg/infra/metrics/metricutil"
"github.com/grafana/grafana/pkg/services/datasources"
)
var (
@@ -84,7 +89,16 @@ func (s *legacyStorage) Create(ctx context.Context, obj runtime.Object, createVa
if !ok {
return nil, fmt.Errorf("expected a datasource object")
}
return s.datasources.CreateDataSource(ctx, ds)
obj, err := s.datasources.CreateDataSource(ctx, ds)
if err != nil {
switch {
case errors.Is(err, datasources.ErrDataSourceNameExists):
return nil, apierrors.NewInvalid(s.resourceInfo.GroupVersionKind().GroupKind(), ds.Name, field.ErrorList{
field.Invalid(field.NewPath("spec", "title"), ds.Spec.Title(), "a datasource with this title already exists")})
}
return nil, err
}
return obj, nil
}
// Update implements rest.Updater.
+136 -1
View File
@@ -2,17 +2,35 @@ package datasource
import (
"fmt"
"maps"
"slices"
"strings"
"k8s.io/kube-openapi/pkg/spec3"
"k8s.io/kube-openapi/pkg/validation/spec"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
datasourceV0 "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/query/queryschema"
"github.com/grafana/grafana/pkg/services/apiserver/builder"
)
func (b *DataSourceAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.OpenAPI, error) {
// The plugin description
oas.Info.Description = b.pluginJSON.Info.Description
// Add plugin information
info := map[string]any{
"plugin": b.pluginJSON.ID,
}
if b.pluginJSON.Info.Version != "" {
info["version"] = b.pluginJSON.Info.Version
}
if b.pluginJSON.Info.Build.Time > 0 {
info["build"] = b.pluginJSON.Info.Build.Time
}
oas.Info.AddExtension("plugin", info)
// The root api URL
root := "/apis/" + b.datasourceResourceInfo.GroupVersion().String() + "/"
@@ -28,6 +46,17 @@ func (b *DataSourceAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.Op
return nil, err
}
// Set the operation ID for the query path
query := oas.Paths.Paths[root+"namespaces/{namespace}/datasources/{name}/query"]
if query != nil && query.Post != nil {
query.Post.OperationId = "queryDataSource"
for _, p := range query.Parameters {
if p.Name == "name" {
p.Description = "DataSource identifier"
}
}
}
// Hide the resource routes -- explicit ones will be added if defined below
prefix := root + "namespaces/{namespace}/datasources/{name}/resource"
r := oas.Paths.Paths[prefix]
@@ -52,7 +81,7 @@ func (b *DataSourceAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.Op
// Mark connections as deprecated
delete(oas.Paths.Paths, root+"namespaces/{namespace}/connections/{name}")
query := oas.Paths.Paths[root+"namespaces/{namespace}/connections/{name}/query"]
query = oas.Paths.Paths[root+"namespaces/{namespace}/connections/{name}/query"]
for query == nil || query.Post == nil {
return nil, fmt.Errorf("missing temporary connection path")
}
@@ -70,5 +99,111 @@ func (b *DataSourceAPIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.Op
},
}
if b.schemaProvider == nil {
return oas, nil
}
custom, err := b.schemaProvider()
if err != nil {
return nil, err
}
return applyCustomSchemas(root, ds, oas, custom)
}
func applyCustomSchemas(root string, ds *spec.Schema, oas *spec3.OpenAPI, custom *datasourceV0.DataSourceOpenAPIExtension) (*spec3.OpenAPI, error) {
if custom == nil {
return oas, nil // nothing special
}
// Add custom schemas
maps.Copy(oas.Components.Schemas, custom.Schemas)
// Replace the generic DataSourceSpec with the explicit one
if custom.DataSourceSpec != nil {
oas.Components.Schemas["DataSourceSpec"] = custom.DataSourceSpec
ds.Properties["spec"] = spec.Schema{
SchemaProps: spec.SchemaProps{
Ref: spec.MustCreateRef("#/components/schemas/DataSourceSpec"),
},
}
}
if len(custom.SecureValues) > 0 {
example := common.InlineSecureValues{}
ref := spec.MustCreateRef("#/components/schemas/com.github.grafana.grafana.pkg.apimachinery.apis.common.v0alpha1.InlineSecureValue")
secure := &spec.Schema{
SchemaProps: spec.SchemaProps{
Properties: make(map[string]spec.Schema),
AdditionalProperties: &spec.SchemaOrBool{Allows: false},
}}
secure.Description = "custom secure value definition"
for _, v := range custom.SecureValues {
secure.Properties[v.Key] = spec.Schema{
SchemaProps: spec.SchemaProps{
Description: v.Description,
Ref: ref,
},
}
if v.Required {
secure.Required = append(secure.Required, v.Key)
example[v.Key] = common.InlineSecureValue{Create: "***"}
}
}
if len(example) > 0 {
secure.Example = example
}
// Link the explicit secure values in the resource
oas.Components.Schemas["SecureValues"] = secure
ds.Properties["secure"] = spec.Schema{
SchemaProps: spec.SchemaProps{
Ref: spec.MustCreateRef("#/components/schemas/SecureValues"),
},
}
}
// Add examples to the POST request
if len(custom.Examples) > 0 {
ds := oas.Paths.Paths[root+"namespaces/{namespace}/datasources"]
if ds != nil && ds.Post != nil {
for _, c := range ds.Post.RequestBody.Content {
c.Examples = custom.Examples
}
}
}
if len(custom.Routes) > 0 {
ds := oas.Paths.Paths[root+"namespaces/{namespace}/datasources/{name}"]
if ds == nil || len(ds.Parameters) < 2 {
return nil, fmt.Errorf("missing Parameters")
}
prefix := root + "namespaces/{namespace}/datasources/{name}/resource"
for k := range oas.Paths.Paths {
if strings.HasPrefix(k, prefix) {
delete(oas.Paths.Paths, k)
}
}
for k, v := range custom.Routes {
if k != "" && !strings.HasPrefix(k, "/") {
return nil, fmt.Errorf("path must have slash prefix")
}
v.Parameters = append(v.Parameters, ds.Parameters[0:2]...)
for m, op := range builder.GetPathOperations(v) {
if op.Extensions == nil {
op.Extensions = make(spec.Extensions)
}
if !slices.Contains(op.Tags, "Route") {
op.Tags = append(op.Tags, "Route") // Custom resource?
}
tmp := strings.ReplaceAll(strings.ReplaceAll(k, "{", ""), "}", "")
op.OperationId = fmt.Sprintf("%s_route%s", strings.ToLower(m), strings.ReplaceAll(tmp, "/", "_"))
}
oas.Paths.Paths[prefix+k] = v
}
}
return oas, nil
}
+36 -18
View File
@@ -25,6 +25,7 @@ import (
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/manager/sources"
"github.com/grafana/grafana/pkg/promlib/models"
"github.com/grafana/grafana/pkg/registry/apis/datasource/hardcoded"
"github.com/grafana/grafana/pkg/registry/apis/query/queryschema"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/apiserver/builder"
@@ -39,14 +40,16 @@ var (
// DataSourceAPIBuilder is used just so wire has something unique to return
type DataSourceAPIBuilder struct {
datasourceResourceInfo utils.ResourceInfo
pluginJSON plugins.JSONData
client PluginClient // will only ever be called with the same plugin id!
datasources PluginDatasourceProvider
contextProvider PluginContextWrapper
accessControl accesscontrol.AccessControl
queryTypes *queryV0.QueryTypeDefinitionList
configCrudUseNewApis bool
dataSourceCRUDMetric *prometheus.HistogramVec
pluginJSON plugins.JSONData
client PluginClient // will only ever be called with the same plugin id!
datasources PluginDatasourceProvider
contextProvider PluginContextWrapper
accessControl accesscontrol.AccessControl
queryTypes *queryV0.QueryTypeDefinitionList
schemaProvider func() (*datasourceV0.DataSourceOpenAPIExtension, error)
configCrudUseNewApis bool
dataSourceCRUDMetric *prometheus.HistogramVec
}
func RegisterAPIService(
@@ -59,8 +62,17 @@ func RegisterAPIService(
reg prometheus.Registerer,
pluginSources sources.Registry,
) (*DataSourceAPIBuilder, error) {
//nolint:staticcheck
useQueryTypes := features.IsEnabledGlobally(featuremgmt.FlagDatasourceQueryTypes)
//nolint:staticcheck
configCrudUseNewApis := features.IsEnabledGlobally(featuremgmt.FlagQueryServiceWithConnections)
//nolint:staticcheck
isExperimental := features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs)
//nolint:staticcheck // not yet migrated to OpenFeature
if !features.IsEnabledGlobally(featuremgmt.FlagQueryServiceWithConnections) && !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) {
if !configCrudUseNewApis && !isExperimental {
return nil, nil
}
@@ -82,28 +94,33 @@ func RegisterAPIService(
return nil, fmt.Errorf("error getting list of datasource plugins: %s", err)
}
for _, pluginJSON := range pluginJSONs {
client, ok := pluginClient.(PluginClient)
if !ok {
return nil, fmt.Errorf("plugin client is not a PluginClient: %T", pluginClient)
}
// For the ST runner, the client can talk to any plugin
client, ok := pluginClient.(PluginClient)
if !ok {
return nil, fmt.Errorf("plugin client is not a PluginClient: %T", pluginClient)
}
for _, pluginJSON := range pluginJSONs {
builder, err = NewDataSourceAPIBuilder(
pluginJSON,
client,
datasources.GetDatasourceProvider(pluginJSON),
contextProvider,
accessControl,
//nolint:staticcheck // not yet migrated to OpenFeature
features.IsEnabledGlobally(featuremgmt.FlagDatasourceQueryTypes),
//nolint:staticcheck // not yet migrated to OpenFeature
features.IsEnabledGlobally(featuremgmt.FlagQueryServiceWithConnections),
useQueryTypes, // Exposes the query type OpenAPI schema
configCrudUseNewApis, // Enables the new connections-based datasource config CRUD APIs
)
if err != nil {
return nil, err
}
builder.SetDataSourceCRUDMetrics(dataSourceCRUDMetric)
// Hardcoded schemas for testdata
// NOTE: this will be driven by the pluginJSON/manifest soon
if pluginJSON.ID == "grafana-testdata-datasource" {
builder.schemaProvider = hardcoded.TestdataOpenAPIExtension
}
apiRegistrar.RegisterAPI(builder)
}
return builder, nil // only used for wire
@@ -247,6 +264,7 @@ func (b *DataSourceAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver
return err
}
} else {
// Read-only access to datasource connection info
storage[ds.StoragePath()] = &connectionAccess{
datasources: b.datasources,
resourceInfo: ds,
@@ -3,6 +3,7 @@
"name": "unique-identifier",
"namespace": "org-0",
"uid": "YpaSG5GQAdxtLZtF6BqQWCeYXOhbVi5C4Cg4oILnJC0X",
"resourceVersion": "1015203600000",
"generation": 8,
"creationTimestamp": "2002-03-04T01:00:00Z",
"labels": {
@@ -10,7 +11,6 @@
}
},
"spec": {
"jsonData": null,
"title": "Display name"
}
}
@@ -5,7 +5,6 @@
"uid": "boDNh7zU3nXj46rOXIJI7r44qaxjs8yy9I9dOj1MyBoX"
},
"spec": {
"jsonData": null,
"title": "Hello testdata"
}
}
+31 -15
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"maps"
"net/http"
"strings"
"sync"
@@ -232,11 +233,9 @@ func getOpenAPIPostProcessor(version string, builders []APIGroupBuilder, gvs []s
parent := copy.Paths.Paths[path[:idx+6]]
if parent != nil && parent.Get != nil {
for _, op := range GetPathOperations(spec) {
if op != nil && op.Extensions != nil {
action, ok := op.Extensions.GetString("x-kubernetes-action")
if ok && action == "connect" {
op.Tags = parent.Get.Tags
}
action, ok := op.Extensions.GetString("x-kubernetes-action")
if ok && action == "connect" {
op.Tags = parent.Get.Tags
}
}
}
@@ -249,15 +248,32 @@ func getOpenAPIPostProcessor(version string, builders []APIGroupBuilder, gvs []s
}
}
func GetPathOperations(path *spec3.Path) []*spec3.Operation {
return []*spec3.Operation{
path.Get,
path.Head,
path.Delete,
path.Patch,
path.Post,
path.Put,
path.Trace,
path.Options,
// GetPathOperations returns the set of non-nil operations defined on a path
func GetPathOperations(path *spec3.Path) map[string]*spec3.Operation {
ops := make(map[string]*spec3.Operation)
if path.Get != nil {
ops[http.MethodGet] = path.Get
}
if path.Head != nil {
ops[http.MethodHead] = path.Head
}
if path.Delete != nil {
ops[http.MethodDelete] = path.Delete
}
if path.Post != nil {
ops[http.MethodPost] = path.Post
}
if path.Put != nil {
ops[http.MethodPut] = path.Put
}
if path.Patch != nil {
ops[http.MethodPatch] = path.Patch
}
if path.Trace != nil {
ops[http.MethodTrace] = path.Trace
}
if path.Options != nil {
ops[http.MethodOptions] = path.Options
}
return ops
}
@@ -0,0 +1,76 @@
package builder
import (
"slices"
"strings"
"testing"
"github.com/stretchr/testify/require"
"k8s.io/kube-openapi/pkg/spec3"
)
func TestOpenAPI_GetPathOperations(t *testing.T) {
testCases := []struct {
name string
input *spec3.Path
expect []string // the methods we should see
exclude []string // the methods we should never see
}{
{
name: "some operations",
input: &spec3.Path{
PathProps: spec3.PathProps{
Get: &spec3.Operation{OperationProps: spec3.OperationProps{Summary: "get"}},
Post: &spec3.Operation{OperationProps: spec3.OperationProps{Summary: "post"}},
Delete: &spec3.Operation{OperationProps: spec3.OperationProps{Summary: "delete"}},
},
},
expect: []string{"GET", "POST", "DELETE"},
exclude: []string{"PUT", "PATCH", "OPTIONS", "HEAD", "TRACE"},
},
{
name: "all operations",
input: &spec3.Path{
PathProps: spec3.PathProps{
Get: &spec3.Operation{OperationProps: spec3.OperationProps{Summary: "get"}},
Post: &spec3.Operation{OperationProps: spec3.OperationProps{Summary: "post"}},
Delete: &spec3.Operation{OperationProps: spec3.OperationProps{Summary: "delete"}},
Put: &spec3.Operation{OperationProps: spec3.OperationProps{Summary: "put"}},
Patch: &spec3.Operation{OperationProps: spec3.OperationProps{Summary: "patch"}},
Options: &spec3.Operation{OperationProps: spec3.OperationProps{Summary: "options"}},
Head: &spec3.Operation{OperationProps: spec3.OperationProps{Summary: "head"}},
Trace: &spec3.Operation{OperationProps: spec3.OperationProps{Summary: "trace"}},
},
},
expect: []string{"GET", "POST", "DELETE", "PUT", "PATCH", "OPTIONS", "HEAD", "TRACE"},
exclude: []string{},
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
expect := make(map[string]bool)
for _, k := range tt.expect {
expect[k] = true
}
for k, op := range GetPathOperations(tt.input) {
require.NotNil(t, op)
require.Equal(t, strings.ToLower(k), op.Summary)
if !expect[k] {
if slices.Contains(tt.expect, k) {
require.Fail(t, "method returned multiple times", k)
} else {
require.Fail(t, "unexpected method", k)
}
}
delete(expect, k)
require.NotContains(t, tt.exclude, k, "exclude")
}
if len(expect) > 0 {
require.Fail(t, "missing expected method", expect)
}
})
}
}
+14 -5
View File
@@ -873,10 +873,19 @@ func VerifyOpenAPISnapshots(t *testing.T, dir string, gv schema.GroupVersion, h
require.Failf(t, "Not OK", "Code[%d] %s", rsp.Response.StatusCode, string(rsp.Body))
}
var prettyJSON bytes.Buffer
err := json.Indent(&prettyJSON, rsp.Body, "", " ")
schema := map[string]any{}
err := json.Unmarshal(rsp.Body, &schema)
require.NoError(t, err)
info, found, err := unstructured.NestedMap(schema, "info", "plugin")
require.NoError(t, err)
if found {
delete(info, "version") // the version is unstable in test environments
err = unstructured.SetNestedMap(schema, info, "info", "plugin")
require.NoError(t, err)
}
pretty, err := json.MarshalIndent(schema, "", " ")
require.NoError(t, err)
pretty := prettyJSON.String()
write := false
fpath := filepath.Join(dir, fmt.Sprintf("%s-%s.json", gv.Group, gv.Version))
@@ -885,7 +894,7 @@ func VerifyOpenAPISnapshots(t *testing.T, dir string, gv schema.GroupVersion, h
// We can ignore the gosec G304 warning since this is a test and the function is only called with explicit paths
body, err := os.ReadFile(fpath)
if err == nil {
if !assert.JSONEq(t, string(body), pretty) {
if !assert.JSONEq(t, string(body), string(pretty)) {
t.Logf("openapi spec has changed: %s", path)
t.Fail()
write = true
@@ -896,7 +905,7 @@ func VerifyOpenAPISnapshots(t *testing.T, dir string, gv schema.GroupVersion, h
}
if write {
e2 := os.WriteFile(fpath, []byte(pretty), 0o644)
e2 := os.WriteFile(fpath, pretty, 0o644)
if e2 != nil {
t.Errorf("error writing file: %s", e2.Error())
}
@@ -2,7 +2,10 @@
"openapi": "3.0.0",
"info": {
"description": "Generates test data in different forms",
"title": "testdata.datasource.grafana.app/v0alpha1"
"title": "testdata.datasource.grafana.app/v0alpha1",
"plugin": {
"plugin": "grafana-testdata-datasource"
}
},
"paths": {
"/apis/testdata.datasource.grafana.app/v0alpha1/": {
@@ -377,7 +380,7 @@
"DataSource"
],
"description": "Query the TestData datasources",
"operationId": "createDataSourceQuery",
"operationId": "queryDataSource",
"requestBody": {
"content": {
"application/json": {
@@ -410,7 +413,7 @@
{
"name": "name",
"in": "path",
"description": "name of the QueryDataResponse",
"description": "DataSource identifier",
"required": true,
"schema": {
"type": "string",
@@ -430,36 +433,462 @@
]
},
"/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}/resource": {
"summary": "hello world",
"get": {
"tags": [
"DataSource"
"Route"
],
"description": "Get resources in the datasource plugin. NOTE, additional routes may exist, but are not exposed via OpenAPI",
"operationId": "getDataSourceResource",
"operationId": "get_route",
"responses": {
"200": {
"description": "OK",
"default": {
"content": {
"*/*": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
}
},
"x-kubernetes-action": "connect",
"x-kubernetes-group-version-kind": {
"group": "testdata.datasource.grafana.app",
"version": "v0alpha1",
"kind": "Status"
}
},
"parameters": [
{
"name": "name",
"in": "path",
"description": "name of the Status",
"description": "name of the DataSource",
"required": true,
"schema": {
"type": "string",
"uniqueItems": true
}
},
{
"name": "namespace",
"in": "path",
"description": "object name and auth scope, such as for teams and projects",
"required": true,
"schema": {
"type": "string",
"uniqueItems": true
}
}
]
},
"/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}/resource/boom": {
"summary": "force a panic",
"get": {
"tags": [
"Route"
],
"operationId": "get_route_boom",
"responses": {
"default": {
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
}
},
"post": {
"tags": [
"Route"
],
"operationId": "post_route_boom",
"responses": {
"default": {
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
}
},
"parameters": [
{
"name": "name",
"in": "path",
"description": "name of the DataSource",
"required": true,
"schema": {
"type": "string",
"uniqueItems": true
}
},
{
"name": "namespace",
"in": "path",
"description": "object name and auth scope, such as for teams and projects",
"required": true,
"schema": {
"type": "string",
"uniqueItems": true
}
}
]
},
"/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}/resource/scenarios": {
"summary": "hello world",
"get": {
"tags": [
"Route"
],
"operationId": "get_route_scenarios",
"responses": {
"default": {
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
}
},
"parameters": [
{
"name": "name",
"in": "path",
"description": "name of the DataSource",
"required": true,
"schema": {
"type": "string",
"uniqueItems": true
}
},
{
"name": "namespace",
"in": "path",
"description": "object name and auth scope, such as for teams and projects",
"required": true,
"schema": {
"type": "string",
"uniqueItems": true
}
}
]
},
"/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}/resource/sim/{key}": {
"description": "Get list of simulations",
"get": {
"tags": [
"Route"
],
"operationId": "get_route_sim_key",
"parameters": [
{
"name": "key",
"in": "path",
"description": "simulation key (should include hz)"
}
],
"responses": {
"default": {
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
}
},
"post": {
"tags": [
"Route"
],
"operationId": "post_route_sim_key",
"parameters": [
{
"name": "key",
"in": "path",
"description": "simulation key (should include hz)"
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"responses": {
"default": {
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
}
},
"parameters": [
{
"name": "name",
"in": "path",
"description": "name of the DataSource",
"required": true,
"schema": {
"type": "string",
"uniqueItems": true
}
},
{
"name": "namespace",
"in": "path",
"description": "object name and auth scope, such as for teams and projects",
"required": true,
"schema": {
"type": "string",
"uniqueItems": true
}
}
]
},
"/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}/resource/sims": {
"description": "Get list of simulations",
"get": {
"tags": [
"Route"
],
"operationId": "get_route_sims",
"responses": {
"default": {
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
}
},
"parameters": [
{
"name": "name",
"in": "path",
"description": "name of the DataSource",
"required": true,
"schema": {
"type": "string",
"uniqueItems": true
}
},
{
"name": "namespace",
"in": "path",
"description": "object name and auth scope, such as for teams and projects",
"required": true,
"schema": {
"type": "string",
"uniqueItems": true
}
}
]
},
"/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}/resource/stream": {
"summary": "Get streaming response",
"get": {
"tags": [
"Route"
],
"operationId": "get_route_stream",
"parameters": [
{
"name": "count",
"in": "query",
"description": "number of points that will be returned",
"schema": {
"type": "integer",
"format": "int64"
},
"example": 10
},
{
"name": "start",
"in": "query",
"description": "the start value",
"schema": {
"type": "integer",
"format": "int64"
}
},
{
"name": "flush",
"in": "query",
"description": "How often the result is flushed (1-100%)",
"schema": {
"type": "integer",
"format": "int64"
},
"example": 100
},
{
"name": "speed",
"in": "query",
"description": "the clock cycle",
"schema": {
"type": "string"
},
"example": "100ms"
},
{
"name": "format",
"in": "query",
"description": "the response format",
"schema": {
"type": "string",
"enum": [
"json",
"influx"
]
}
}
],
"responses": {
"default": {
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
}
},
"parameters": [
{
"name": "name",
"in": "path",
"description": "name of the DataSource",
"required": true,
"schema": {
"type": "string",
"uniqueItems": true
}
},
{
"name": "namespace",
"in": "path",
"description": "object name and auth scope, such as for teams and projects",
"required": true,
"schema": {
"type": "string",
"uniqueItems": true
}
}
]
},
"/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}/resource/test": {
"summary": "Echo any request",
"post": {
"tags": [
"Route"
],
"operationId": "post_route_test",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"responses": {
"default": {
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
}
},
"parameters": [
{
"name": "name",
"in": "path",
"description": "name of the DataSource",
"required": true,
"schema": {
"type": "string",
"uniqueItems": true
}
},
{
"name": "namespace",
"in": "path",
"description": "object name and auth scope, such as for teams and projects",
"required": true,
"schema": {
"type": "string",
"uniqueItems": true
}
}
]
},
"/apis/testdata.datasource.grafana.app/v0alpha1/namespaces/{namespace}/datasources/{name}/resource/test/json": {
"summary": "Echo json request",
"post": {
"tags": [
"Route"
],
"operationId": "post_route_test_json",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"responses": {
"default": {
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
}
},
"parameters": [
{
"name": "name",
"in": "path",
"description": "name of the DataSource",
"required": true,
"schema": {
"type": "string",
@@ -530,6 +959,26 @@
},
"components": {
"schemas": {
"DataSourceSpec": {
"description": "Test data does not require any explicit configuration",
"required": [
"title"
],
"properties": {
"title": {
"description": "display name",
"type": "string"
},
"url": {
"description": "not used",
"type": "string"
}
},
"additionalProperties": false,
"example": {
"url": "http://xxxx"
}
},
"QueryRequestSchema": {
"description": "Schema for a set of queries sent to the query method",
"type": "object",
@@ -650,12 +1099,7 @@
}
},
"spec": {
"description": "DataSource configuration -- these properties are all visible to anyone able to query the data source from their browser",
"allOf": [
{
"$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apis.datasource.v0alpha1.UnstructuredSpec"
}
]
"$ref": "#/components/schemas/DataSourceSpec"
}
},
"x-kubernetes-group-version-kind": [
@@ -24,7 +24,7 @@ func (e *elasticsearchDataQuery) processQuery(q *Query, ms *es.MultiSearchReques
filters.AddDateRangeFilter(defaultTimeField, to, from, es.DateFormatEpochMS)
filters.AddQueryStringFilter(q.RawQuery, true)
if q.EditorType != nil && *q.EditorType == "code" && q.RawDSLQuery != "" {
if q.EditorType != nil && *q.EditorType == "code" {
cfg := backend.GrafanaConfigFromContext(e.ctx)
if !cfg.FeatureToggles().IsEnabled("elasticsearchRawDSLQuery") {
return backend.DownstreamError(fmt.Errorf("raw DSL query feature is disabled. Enable the elasticsearchRawDSLQuery feature toggle to use this query type"))
@@ -7,7 +7,7 @@ import (
// isQueryWithError validates the query and returns an error if invalid
func isQueryWithError(query *Query) error {
// Skip validation for raw DSL queries because no easy way to see it is valid without just running it
if query.EditorType != nil && *query.EditorType == "code" && query.RawDSLQuery != "" {
if query.EditorType != nil && *query.EditorType == "code" {
return nil
}
if len(query.BucketAggs) == 0 {
@@ -4,7 +4,7 @@ import { DataFrame, DataTransformerID, standardTransformersRegistry, Transformer
import { selectors } from '@grafana/e2e-selectors';
import { t, Trans } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { Box, Button, Grid, Stack, Text } from '@grafana/ui';
import { Box, Button, Stack, Text } from '@grafana/ui';
import config from 'app/core/config';
import { SqlExpressionCard } from '../../../dashboard/components/TransformationsEditor/SqlExpressionCard';
@@ -26,9 +26,6 @@ const TRANSFORMATION_IDS = [
DataTransformerID.filterByValue,
];
const GRID_COLUMNS_WITH_SQL = 5;
const GRID_COLUMNS_WITHOUT_SQL = 4;
export function LegacyEmptyTransformationsMessage({ onShowPicker }: { onShowPicker: () => void }) {
return (
<Box alignItems="center" padding={4}>
@@ -94,13 +91,25 @@ export function NewEmptyTransformationsMessage(props: EmptyTransformationsProps)
};
const showSqlCard = hasGoToQueries && config.featureToggles.sqlExpressions;
const gridColumns = showSqlCard ? GRID_COLUMNS_WITH_SQL : GRID_COLUMNS_WITHOUT_SQL;
return (
<Box alignItems="center" padding={4}>
<Stack direction="column" alignItems="center" gap={4}>
<Box padding={2}>
<Stack direction="column" alignItems="start" gap={2}>
<Stack direction="column" alignItems="start" gap={1}>
<Text element="h3" textAlignment="start">
<Trans i18nKey="transformations.empty.add-transformation-header">Add a Transformation</Trans>
</Text>
<Text element="p" textAlignment="start" color="secondary">
<Trans i18nKey="transformations.empty.add-transformation-body">
Transformations allow data to be changed in various ways before your visualization is shown.
<br />
This includes joining data together, renaming fields, making calculations, formatting data for display,
and more.
</Trans>
</Text>
</Stack>
{(hasAddTransformation || hasGoToQueries) && (
<Grid columns={gridColumns} gap={1}>
<Stack direction="row" gap={1} wrap>
{showSqlCard && (
<SqlExpressionCard
name={t('dashboard-scene.empty-transformations-message.sql-name', 'Transform with SQL')}
@@ -125,19 +134,17 @@ export function NewEmptyTransformationsMessage(props: EmptyTransformationsProps)
data={props.data}
/>
))}
</Grid>
</Stack>
)}
<Stack direction="row" gap={2}>
<Button
icon="plus"
variant="primary"
size="md"
onClick={handleShowMoreClick}
data-testid={selectors.components.Transforms.addTransformationButton}
>
<Trans i18nKey="dashboard-scene.empty-transformations-message.show-more">Show more</Trans>
</Button>
</Stack>
<Button
icon="plus"
variant="primary"
size="md"
onClick={handleShowMoreClick}
data-testid={selectors.components.Transforms.addTransformationButton}
>
<Trans i18nKey="dashboard-scene.empty-transformations-message.show-more">Show more</Trans>
</Button>
</Stack>
</Box>
);
@@ -112,6 +112,37 @@ describe('PanelEditor', () => {
});
});
describe('Entering panel edit', () => {
it('should clear edit pane selection', () => {
pluginPromise = Promise.resolve(getPanelPlugin({ id: 'text', skipDataQuery: true }));
const panel = new VizPanel({
key: 'panel-1',
pluginId: 'text',
title: 'original title',
});
const gridItem = new DashboardGridItem({ body: panel });
const panelEditor = buildPanelEditScene(panel);
const dashboard = new DashboardScene({
editPanel: panelEditor,
isEditing: true,
$timeRange: new SceneTimeRange({ from: 'now-6h', to: 'now' }),
body: new DefaultGridLayoutManager({
grid: new SceneGridLayout({
children: [gridItem],
}),
}),
});
dashboard.state.editPane.selectObject(panel, panel.state.key!, { force: true });
expect(dashboard.state.editPane.getSelection()).toBe(panel);
deactivate = activateFullSceneTree(dashboard);
expect(dashboard.state.editPane.getSelection()).toBeUndefined();
});
});
describe('When discarding', () => {
it('should discard changes revert all changes', async () => {
const { panelEditor, panel, dashboard } = await setup();
@@ -84,6 +84,11 @@ export class PanelEditor extends SceneObjectBase<PanelEditorState> {
private _activationHandler() {
const panel = this.state.panelRef.resolve();
const dashboard = getDashboardSceneFor(this);
// Clear any panel selection when entering panel edit mode.
// Need to clear selection here since selection is activated when panel edit mode is entered through the panel actions menu. This causes sidebar panel editor to be open when exiting panel edit mode
dashboard.state.editPane.clearSelection();
if (panel.state.pluginId === UNCONFIGURED_PANEL_PLUGIN_ID) {
if (config.featureToggles.newVizSuggestions) {
@@ -59,7 +59,11 @@ export function CanvasGridAddActions({ layoutManager }: Props) {
}, [layoutManager]);
return (
<div className={cx(styles.addAction, 'dashboard-canvas-add-button')}>
<div
className={cx(styles.addAction, 'dashboard-canvas-add-button')}
onPointerUp={(evt) => evt.stopPropagation()}
onPointerDown={(evt) => evt.stopPropagation()}
>
<Button
variant="primary"
fill="text"
@@ -189,7 +193,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
height: theme.spacing(5),
bottom: 0,
left: 0,
right: 0,
opacity: 0,
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
transition: theme.transitions.create('opacity'),
@@ -1,7 +1,6 @@
import { css } from '@emotion/css';
import { Card, Text, useStyles2 } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
import { Card, useStyles2 } from '@grafana/ui';
import { getCardStyles } from './getCardStyles';
export interface SqlExpressionCardProps {
name: string;
@@ -12,60 +11,15 @@ export interface SqlExpressionCardProps {
}
export function SqlExpressionCard({ name, description, imageUrl, onClick, testId }: SqlExpressionCardProps) {
const styles = useStyles2(getSqlExpressionCardStyles);
const styles = useStyles2(getCardStyles);
return (
<Card className={styles.card} data-testid={testId} onClick={onClick} noMargin>
<Card.Heading className={styles.heading}>
<div className={styles.titleRow}>
<span>{name}</span>
</div>
</Card.Heading>
<Card.Description className={styles.description}>
<span>{description}</span>
{imageUrl && (
<span>
<img className={styles.image} src={imageUrl} alt={name} />
</span>
)}
<Card className={styles.baseCard} data-testid={testId} onClick={onClick} noMargin>
<Card.Heading>{name}</Card.Heading>
<Card.Description>
<Text variant="bodySmall">{description}</Text>
{imageUrl && <img className={styles.image} src={imageUrl} alt={name} />}
</Card.Description>
</Card>
);
}
function getSqlExpressionCardStyles(theme: GrafanaTheme2) {
return {
card: css({
gridTemplateRows: 'min-content 0 1fr 0',
marginBottom: 0,
}),
heading: css({
fontWeight: 400,
'> button': {
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: theme.spacing(1),
},
}),
titleRow: css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'nowrap',
width: '100%',
}),
description: css({
fontSize: theme.typography.bodySmall.fontSize,
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
}),
image: css({
display: 'block',
maxWidth: '100%',
marginTop: theme.spacing(2),
}),
};
}
@@ -1,35 +1,38 @@
import { cx, css } from '@emotion/css';
import { cx } from '@emotion/css';
import {
DataFrame,
GrafanaTheme2,
TransformerRegistryItem,
TransformationApplicabilityLevels,
standardTransformersRegistry,
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Badge, Card, IconButton, useStyles2, useTheme2 } from '@grafana/ui';
import { Badge, Card, IconButton, Stack, Text, useStyles2, useTheme2 } from '@grafana/ui';
import { PluginStateInfo } from 'app/features/plugins/components/PluginStateInfo';
import { getCardStyles } from './getCardStyles';
export interface TransformationCardProps {
transform: TransformerRegistryItem;
data?: DataFrame[];
fullWidth?: boolean;
onClick: (id: string) => void;
showIllustrations?: boolean;
data?: DataFrame[];
showPluginState?: boolean;
showTags?: boolean;
transform: TransformerRegistryItem;
}
export function TransformationCard({
transform,
showIllustrations,
onClick,
data = [],
fullWidth = false,
onClick,
showIllustrations,
showPluginState = true,
showTags = true,
transform,
}: TransformationCardProps) {
const theme = useTheme2();
const styles = useStyles2(getTransformationCardStyles);
const styles = useStyles2(getCardStyles, fullWidth);
// Check to see if the transform is applicable to the given data
let applicabilityScore = TransformationApplicabilityLevels.Applicable;
@@ -47,7 +50,7 @@ export function TransformationCard({
}
}
const cardClasses = !isApplicable && data.length > 0 ? cx(styles.newCard, styles.cardDisabled) : styles.newCard;
const cardClasses = cx(styles.baseCard, { [styles.cardDisabled]: !isApplicable });
const imageUrl = theme.isDark ? transform.imageDark : transform.imageLight;
const description = standardTransformersRegistry.getIfExists(transform.id)?.description;
@@ -58,15 +61,11 @@ export function TransformationCard({
onClick={() => onClick(transform.id)}
noMargin
>
<Card.Heading className={styles.heading}>
<div className={styles.titleRow}>
<span>{transform.name}</span>
{showPluginState && (
<span className={styles.pluginStateInfoWrapper}>
<PluginStateInfo state={transform.state} />
</span>
)}
</div>
<Card.Heading>
<Stack alignItems="center" justifyContent="space-between">
{transform.name}
{showPluginState && <PluginStateInfo state={transform.state} />}
</Stack>
{showTags && transform.tags && transform.tags.size > 0 && (
<div className={styles.tagsWrapper}>
{Array.from(transform.tags).map((tag) => (
@@ -75,74 +74,13 @@ export function TransformationCard({
</div>
)}
</Card.Heading>
<Card.Description className={styles.description}>
<span>{description}</span>
{showIllustrations && imageUrl && (
<span>
<img className={styles.image} src={imageUrl} alt={transform.name} />
</span>
)}
<Card.Description>
<Text variant="bodySmall">{description || ''}</Text>
{showIllustrations && imageUrl && <img className={styles.image} src={imageUrl} alt={transform.name} />}
{!isApplicable && applicabilityDescription !== null && (
<IconButton className={styles.cardApplicableInfo} name="info-circle" tooltip={applicabilityDescription} />
<IconButton className={styles.applicableInfoButton} name="info-circle" tooltip={applicabilityDescription} />
)}
</Card.Description>
</Card>
);
}
function getTransformationCardStyles(theme: GrafanaTheme2) {
return {
heading: css({
fontWeight: 400,
'> button': {
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: theme.spacing(1),
},
}),
titleRow: css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'nowrap',
width: '100%',
}),
description: css({
fontSize: theme.typography.bodySmall.fontSize,
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
}),
image: css({
display: 'block',
maxWidth: '100%',
marginTop: theme.spacing(2),
}),
cardDisabled: css({
backgroundColor: theme.colors.action.disabledBackground,
img: {
filter: 'grayscale(100%)',
opacity: 0.33,
},
}),
cardApplicableInfo: css({
position: 'absolute',
bottom: theme.spacing(1),
right: theme.spacing(1),
}),
newCard: css({
gridTemplateRows: 'min-content 0 1fr 0',
marginBottom: 0,
}),
pluginStateInfoWrapper: css({
marginLeft: theme.spacing(0.5),
}),
tagsWrapper: css({
display: 'flex',
flexWrap: 'wrap',
gap: theme.spacing(0.5),
}),
};
}
@@ -165,11 +165,12 @@ function TransformationsGrid({ showIllustrations, transformations, onClick, data
<Grid columns={3} gap={1}>
{transformations.map((transform) => (
<TransformationCard
key={transform.id}
transform={transform}
showIllustrations={showIllustrations}
onClick={onClick}
data={data}
fullWidth
key={transform.id}
onClick={onClick}
showIllustrations={showIllustrations}
transform={transform}
/>
))}
</Grid>
@@ -0,0 +1,34 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
export const getCardStyles = (theme: GrafanaTheme2, fullWidth?: boolean) => ({
baseCard: css({
maxWidth: fullWidth ? 'none' : '200px',
width: fullWidth ? '100%' : 'auto',
marginBottom: 0,
}),
image: css({
display: 'block',
maxWidth: '100%',
marginTop: theme.spacing(2),
}),
cardDisabled: css({
backgroundColor: theme.colors.action.disabledBackground,
img: {
filter: 'grayscale(100%)',
opacity: 0.33,
},
}),
applicableInfoButton: css({
position: 'absolute',
bottom: theme.spacing(1),
right: theme.spacing(1),
}),
tagsWrapper: css({
display: 'flex',
flexWrap: 'wrap',
gap: theme.spacing(0.5),
marginTop: theme.spacing(0.5),
}),
});
@@ -7,7 +7,7 @@ import {
import { defaultBucketAgg } from '../../../../queryDef';
import { reducerTester } from '../../../reducerTester';
import { changeMetricType } from '../../MetricAggregationsEditor/state/actions';
import { initQuery } from '../../state';
import { changeEditorTypeAndResetQuery, initQuery } from '../../state';
import { bucketAggregationConfig } from '../utils';
import {
@@ -180,4 +180,27 @@ describe('Bucket Aggregations Reducer', () => {
.thenStateShouldEqual([bucketAgg]);
});
});
describe('When switching editor type', () => {
it('Should reset bucket aggregations to default when switching editor types', () => {
const defaultTimeField = '@timestamp';
const initialState: BucketAggregation[] = [
{
id: '1',
type: 'date_histogram',
field: '@timestamp',
},
{
id: '2',
type: 'terms',
field: 'status',
},
];
reducerTester<ElasticsearchDataQuery['bucketAggs']>()
.givenReducer(createReducer(defaultTimeField), initialState)
.whenActionIsDispatched(changeEditorTypeAndResetQuery('code'))
.thenStateShouldEqual([{ ...defaultBucketAgg('2'), field: defaultTimeField }]);
});
});
});
@@ -6,7 +6,7 @@ import { defaultBucketAgg } from '../../../../queryDef';
import { removeEmpty } from '../../../../utils';
import { changeMetricType } from '../../MetricAggregationsEditor/state/actions';
import { metricAggregationConfig } from '../../MetricAggregationsEditor/utils';
import { initQuery } from '../../state';
import { changeEditorTypeAndResetQuery, initQuery } from '../../state';
import { bucketAggregationConfig } from '../utils';
import {
@@ -87,6 +87,11 @@ export const createReducer =
return state;
}
if (changeEditorTypeAndResetQuery.match(action)) {
// Returns the default bucket agg. We will always want to set the default when switching types
return [{ ...defaultBucketAgg('2'), field: defaultTimeField }];
}
if (changeBucketAggregationSetting.match(action)) {
return state!.map((bucketAgg) => {
if (bucketAgg.id !== action.payload.bucketAgg.id) {
@@ -7,7 +7,7 @@ import {
import { defaultMetricAgg } from '../../../../queryDef';
import { reducerTester } from '../../../reducerTester';
import { initQuery } from '../../state';
import { changeEditorTypeAndResetQuery, initQuery } from '../../state';
import { metricAggregationConfig } from '../utils';
import {
@@ -248,4 +248,26 @@ describe('Metric Aggregations Reducer', () => {
.whenActionIsDispatched(initQuery())
.thenStateShouldEqual([defaultMetricAgg('1')]);
});
describe('When switching editor type', () => {
it('Should reset to single default metric when switching to code editor', () => {
const initialState: MetricAggregation[] = [
{
id: '1',
type: 'avg',
field: 'value',
},
{
id: '2',
type: 'max',
field: 'value',
},
];
reducerTester<ElasticsearchDataQuery['metrics']>()
.givenReducer(reducer, initialState)
.whenActionIsDispatched(changeEditorTypeAndResetQuery('code'))
.thenStateShouldEqual([defaultMetricAgg('1')]);
});
});
});
@@ -4,7 +4,7 @@ import { ElasticsearchDataQuery, MetricAggregation } from 'app/plugins/datasourc
import { defaultMetricAgg, queryTypeToMetricType } from '../../../../queryDef';
import { removeEmpty } from '../../../../utils';
import { initQuery } from '../../state';
import { changeEditorTypeAndResetQuery, initQuery } from '../../state';
import { isMetricAggregationWithMeta, isMetricAggregationWithSettings, isPipelineAggregation } from '../aggregations';
import { getChildren, metricAggregationConfig } from '../utils';
@@ -65,6 +65,11 @@ export const reducer = (
});
}
if (changeEditorTypeAndResetQuery.match(action)) {
// Reset to default metric when switching to editor types
return [defaultMetricAgg('1')];
}
if (changeMetricField.match(action)) {
return state!.map((metric) => {
if (metric.id !== action.payload.id) {
@@ -1,7 +1,15 @@
import { ElasticsearchDataQuery } from '../../dataquery.gen';
import { reducerTester } from '../reducerTester';
import { aliasPatternReducer, changeAliasPattern, changeQuery, initQuery, queryReducer } from './state';
import {
aliasPatternReducer,
changeAliasPattern,
changeEditorTypeAndResetQuery,
changeQuery,
initQuery,
queryReducer,
rawDSLQueryReducer,
} from './state';
describe('Query Reducer', () => {
describe('On Init', () => {
@@ -42,6 +50,17 @@ describe('Query Reducer', () => {
.whenActionIsDispatched({ type: 'THIS ACTION SHOULD NOT HAVE ANY EFFECT IN THIS REDUCER' })
.thenStateShouldEqual(initialState);
});
describe('When switching editor type', () => {
it('Should clear query when switching editor types', () => {
const initialQuery: ElasticsearchDataQuery['query'] = 'Some lucene query';
reducerTester<ElasticsearchDataQuery['query']>()
.givenReducer(queryReducer, initialQuery)
.whenActionIsDispatched(changeEditorTypeAndResetQuery('code'))
.thenStateShouldEqual('');
});
});
});
describe('Alias Pattern Reducer', () => {
@@ -62,4 +81,26 @@ describe('Alias Pattern Reducer', () => {
.whenActionIsDispatched({ type: 'THIS ACTION SHOULD NOT HAVE ANY EFFECT IN THIS REDUCER' })
.thenStateShouldEqual(initialState);
});
describe('When switching editor type', () => {
it('Should clear alias when switching editor types', () => {
const initialAlias: ElasticsearchDataQuery['alias'] = 'Some alias pattern';
reducerTester<ElasticsearchDataQuery['alias']>()
.givenReducer(aliasPatternReducer, initialAlias)
.whenActionIsDispatched(changeEditorTypeAndResetQuery('code'))
.thenStateShouldEqual('');
});
});
});
describe('Raw DSL Query Reducer', () => {
it('Should clear raw DSL query when switching editor types', () => {
const initialRawQuery: ElasticsearchDataQuery['rawDSLQuery'] = '{"query": {"match_all": {}}}';
reducerTester<ElasticsearchDataQuery['rawDSLQuery']>()
.givenReducer(rawDSLQueryReducer, initialRawQuery)
.whenActionIsDispatched(changeEditorTypeAndResetQuery('builder'))
.thenStateShouldEqual('');
});
});
@@ -58,6 +58,10 @@ export const aliasPatternReducer = (prevAliasPattern: ElasticsearchDataQuery['al
return action.payload;
}
if (changeEditorTypeAndResetQuery.match(action)) {
return '';
}
if (initQuery.match(action)) {
return prevAliasPattern || '';
}
@@ -52,6 +52,7 @@ export function RadialBarPanel({
nameManualFontSize={options.text?.titleSize}
endpointMarker={options.endpointMarker !== 'none' ? options.endpointMarker : undefined}
onClick={menuProps.openMenu}
textMode={options.textMode}
/>
);
}
@@ -65,9 +66,7 @@ export function RadialBarPanel({
if (hasLinks && getLinks) {
return (
<DataLinksContextMenu links={getLinks} style={{ flexGrow: 1 }}>
{(api) => {
return renderComponent(valueProps, api);
}}
{(api) => renderComponent(valueProps, api)}
</DataLinksContextMenu>
);
}
@@ -1,7 +1,7 @@
import { PanelModel } from '@grafana/data';
import { FieldColorModeId } from '@grafana/schema/dist/esm/index.gen';
import { gaugePanelMigrationHandler, gaugePanelChangedHandler } from './GaugeMigrations';
import { gaugePanelMigrationHandler, gaugePanelChangedHandler } from './migrations';
describe('Gauge Panel Migrations', () => {
it('from old gauge', () => {
@@ -32,6 +32,51 @@ describe('Gauge Panel Migrations', () => {
expect(result.sparkline).toBe(false);
});
it.each([
{
textMode: 'value_and_name',
displayName: 'My gauge',
},
{
textMode: undefined,
displayName: '',
},
{
textMode: undefined,
displayName: null,
},
{
textMode: undefined,
displayName: undefined,
},
])('sets the the text mode to "$textMode" for "$displayName"', ({ textMode, displayName }) => {
const panel = {
id: 2,
options: {
displayName,
reduceOptions: {
calcs: ['lastNotNull'],
},
showThresholdLabels: false,
showThresholdMarkers: true,
},
fieldConfig: {
defaults: {
color: {
mode: FieldColorModeId.Fixed,
fixedColor: 'blue',
},
},
overrides: [],
},
pluginVersion: '12.3.0',
type: 'gauge',
} as Omit<PanelModel, 'fieldConfig'>;
const result = gaugePanelMigrationHandler(panel as PanelModel);
expect(result.textMode).toEqual(textMode);
});
it('does not overwrite new gauge', () => {
const panel = {
id: 2,
@@ -21,6 +21,11 @@ export function gaugePanelMigrationHandler(panel: PanelModel<Options>): Partial<
newOptions.sparkline = false;
newOptions.effects = { gradient: false };
// if a display name is set, set the appropriate text mode
if ('displayName' in newOptions && newOptions.displayName && newOptions.displayName !== '') {
newOptions.textMode = 'value_and_name';
}
// Remove deprecated sizing options
if ('sizing' in newOptions) {
delete newOptions.sizing;
+17 -1
View File
@@ -5,8 +5,8 @@ import { commonOptionsBuilder } from '@grafana/ui';
import { addOrientationOption, addStandardDataReduceOptions } from '../stat/common';
import { EffectsEditor } from './EffectsEditor';
import { gaugePanelChangedHandler, gaugePanelMigrationHandler, shouldMigrateGauge } from './GaugeMigrations';
import { RadialBarPanel } from './RadialBarPanel';
import { gaugePanelChangedHandler, gaugePanelMigrationHandler, shouldMigrateGauge } from './migrations';
import { defaultGaugePanelEffects, defaultOptions, Options } from './panelcfg.gen';
import { radialBarSuggestionsSupplier } from './suggestions';
@@ -99,6 +99,22 @@ export const plugin = new PanelPlugin<Options>(RadialBarPanel)
showIf: (options) => options.barShape === 'rounded' && options.segmentCount === 1,
});
builder.addSelect({
path: 'textMode',
name: t('radialbar.config.text-mode', 'Text mode'),
category,
settings: {
options: [
{ value: 'auto', label: t('radialbar.config.text-mode-auto', 'Auto') },
{ value: 'value_and_name', label: t('radialbar.config.text-mode-value-and-name', 'Value and Name') },
{ value: 'value', label: t('radialbar.config.text-mode-value', 'Value') },
{ value: 'name', label: t('radialbar.config.text-mode-name', 'Name') },
{ value: 'none', label: t('radialbar.config.text-mode-none', 'None') },
],
},
defaultValue: defaultOptions.textMode,
});
builder.addBooleanSwitch({
path: 'sparkline',
name: t('radialbar.config.sparkline', 'Show sparkline'),
@@ -42,6 +42,7 @@ composableKinds: PanelCfg: {
barWidthFactor: number | *0.5
barShape: "flat" | "rounded" | *"flat"
endpointMarker?: "point" | "glow" | "none" | *"point"
textMode?: "auto" | "value_and_name" | "value" | "name" | "none" | *"auto"
effects: GaugePanelEffects | *{}
} @cuetsy(kind="interface")
}
+2
View File
@@ -33,6 +33,7 @@ export interface Options extends common.SingleStatBaseOptions {
showThresholdLabels: boolean;
showThresholdMarkers: boolean;
sparkline?: boolean;
textMode?: ('auto' | 'value_and_name' | 'value' | 'name' | 'none');
}
export const defaultOptions: Partial<Options> = {
@@ -46,4 +47,5 @@ export const defaultOptions: Partial<Options> = {
showThresholdLabels: false,
showThresholdMarkers: true,
sparkline: true,
textMode: 'auto',
};
+9
View File
@@ -6009,6 +6009,9 @@
"name-values-separated-comma": "Hodnoty oddělené čárkou",
"selection-options": "Možnosti výběru"
},
"dashboard-edit-pane-renderer": {
"title-feedback-dashboard-editing-experience": ""
},
"dashboard-link-form": {
"back-to-list": "Zpět na seznam",
"label-icon": "Ikona",
@@ -12620,6 +12623,12 @@
"shape-circle": "",
"shape-gauge": "",
"sparkline": "",
"text-mode": "",
"text-mode-auto": "",
"text-mode-name": "",
"text-mode-none": "",
"text-mode-value": "",
"text-mode-value-and-name": "",
"threshold-labels": "",
"threshold-markers": ""
}
+9
View File
@@ -5967,6 +5967,9 @@
"name-values-separated-comma": "Werte werden durch Komma getrennt",
"selection-options": "Auswahloptionen"
},
"dashboard-edit-pane-renderer": {
"title-feedback-dashboard-editing-experience": ""
},
"dashboard-link-form": {
"back-to-list": "Zurück zur Liste",
"label-icon": "Symbol",
@@ -12516,6 +12519,12 @@
"shape-circle": "",
"shape-gauge": "",
"sparkline": "",
"text-mode": "",
"text-mode-auto": "",
"text-mode-name": "",
"text-mode-none": "",
"text-mode-value": "",
"text-mode-value-and-name": "",
"threshold-labels": "",
"threshold-markers": ""
}
+6
View File
@@ -12519,6 +12519,12 @@
"shape-circle": "Circle",
"shape-gauge": "Arc",
"sparkline": "Show sparkline",
"text-mode": "Text mode",
"text-mode-auto": "Auto",
"text-mode-name": "Name",
"text-mode-none": "None",
"text-mode-value": "Value",
"text-mode-value-and-name": "Value and Name",
"threshold-labels": "Show threshold labels",
"threshold-markers": "Show thresholds"
}
+9
View File
@@ -5967,6 +5967,9 @@
"name-values-separated-comma": "Valores separados por comas",
"selection-options": "Opciones de selección"
},
"dashboard-edit-pane-renderer": {
"title-feedback-dashboard-editing-experience": ""
},
"dashboard-link-form": {
"back-to-list": "Regresar a la lista",
"label-icon": "Icono",
@@ -12516,6 +12519,12 @@
"shape-circle": "",
"shape-gauge": "",
"sparkline": "",
"text-mode": "",
"text-mode-auto": "",
"text-mode-name": "",
"text-mode-none": "",
"text-mode-value": "",
"text-mode-value-and-name": "",
"threshold-labels": "",
"threshold-markers": ""
}
+9
View File
@@ -5967,6 +5967,9 @@
"name-values-separated-comma": "Valeurs séparées par des virgules",
"selection-options": "Options de sélection"
},
"dashboard-edit-pane-renderer": {
"title-feedback-dashboard-editing-experience": ""
},
"dashboard-link-form": {
"back-to-list": "Retour à la liste",
"label-icon": "Icône",
@@ -12516,6 +12519,12 @@
"shape-circle": "",
"shape-gauge": "",
"sparkline": "",
"text-mode": "",
"text-mode-auto": "",
"text-mode-name": "",
"text-mode-none": "",
"text-mode-value": "",
"text-mode-value-and-name": "",
"threshold-labels": "",
"threshold-markers": ""
}
+9
View File
@@ -5967,6 +5967,9 @@
"name-values-separated-comma": "Értékek vesszővel elválasztva",
"selection-options": "Kijelölés beállításai"
},
"dashboard-edit-pane-renderer": {
"title-feedback-dashboard-editing-experience": ""
},
"dashboard-link-form": {
"back-to-list": "Vissza a listához",
"label-icon": "Ikon",
@@ -12516,6 +12519,12 @@
"shape-circle": "",
"shape-gauge": "",
"sparkline": "",
"text-mode": "",
"text-mode-auto": "",
"text-mode-name": "",
"text-mode-none": "",
"text-mode-value": "",
"text-mode-value-and-name": "",
"threshold-labels": "",
"threshold-markers": ""
}
+9
View File
@@ -5946,6 +5946,9 @@
"name-values-separated-comma": "Nilai dipisahkan dengan koma",
"selection-options": "Opsi pemilihan"
},
"dashboard-edit-pane-renderer": {
"title-feedback-dashboard-editing-experience": ""
},
"dashboard-link-form": {
"back-to-list": "Kembali ke daftar",
"label-icon": "Ikon",
@@ -12464,6 +12467,12 @@
"shape-circle": "",
"shape-gauge": "",
"sparkline": "",
"text-mode": "",
"text-mode-auto": "",
"text-mode-name": "",
"text-mode-none": "",
"text-mode-value": "",
"text-mode-value-and-name": "",
"threshold-labels": "",
"threshold-markers": ""
}
+9
View File
@@ -5967,6 +5967,9 @@
"name-values-separated-comma": "Valori separati da virgola",
"selection-options": "Seleziona opzioni"
},
"dashboard-edit-pane-renderer": {
"title-feedback-dashboard-editing-experience": ""
},
"dashboard-link-form": {
"back-to-list": "Torna all'elenco",
"label-icon": "Icona",
@@ -12516,6 +12519,12 @@
"shape-circle": "",
"shape-gauge": "",
"sparkline": "",
"text-mode": "",
"text-mode-auto": "",
"text-mode-name": "",
"text-mode-none": "",
"text-mode-value": "",
"text-mode-value-and-name": "",
"threshold-labels": "",
"threshold-markers": ""
}
+9
View File
@@ -5946,6 +5946,9 @@
"name-values-separated-comma": "カンマ区切りの値",
"selection-options": "選択オプション"
},
"dashboard-edit-pane-renderer": {
"title-feedback-dashboard-editing-experience": ""
},
"dashboard-link-form": {
"back-to-list": "一覧に戻る",
"label-icon": "アイコン",
@@ -12464,6 +12467,12 @@
"shape-circle": "",
"shape-gauge": "",
"sparkline": "",
"text-mode": "",
"text-mode-auto": "",
"text-mode-name": "",
"text-mode-none": "",
"text-mode-value": "",
"text-mode-value-and-name": "",
"threshold-labels": "",
"threshold-markers": ""
}
+9
View File
@@ -5946,6 +5946,9 @@
"name-values-separated-comma": "쉼표로 구분된 값",
"selection-options": "선택 옵션"
},
"dashboard-edit-pane-renderer": {
"title-feedback-dashboard-editing-experience": ""
},
"dashboard-link-form": {
"back-to-list": "목록으로 돌아가기",
"label-icon": "아이콘",
@@ -12464,6 +12467,12 @@
"shape-circle": "",
"shape-gauge": "",
"sparkline": "",
"text-mode": "",
"text-mode-auto": "",
"text-mode-name": "",
"text-mode-none": "",
"text-mode-value": "",
"text-mode-value-and-name": "",
"threshold-labels": "",
"threshold-markers": ""
}
+9
View File
@@ -5967,6 +5967,9 @@
"name-values-separated-comma": "Waarden gescheiden door komma",
"selection-options": "Selectiemogelijkheden"
},
"dashboard-edit-pane-renderer": {
"title-feedback-dashboard-editing-experience": ""
},
"dashboard-link-form": {
"back-to-list": "Terug naar lijst",
"label-icon": "Pictogram",
@@ -12516,6 +12519,12 @@
"shape-circle": "",
"shape-gauge": "",
"sparkline": "",
"text-mode": "",
"text-mode-auto": "",
"text-mode-name": "",
"text-mode-none": "",
"text-mode-value": "",
"text-mode-value-and-name": "",
"threshold-labels": "",
"threshold-markers": ""
}
+9
View File
@@ -6009,6 +6009,9 @@
"name-values-separated-comma": "Wartości rozdzielone przecinkami",
"selection-options": "Opcje wyboru"
},
"dashboard-edit-pane-renderer": {
"title-feedback-dashboard-editing-experience": ""
},
"dashboard-link-form": {
"back-to-list": "Powrót do listy",
"label-icon": "Ikona",
@@ -12620,6 +12623,12 @@
"shape-circle": "",
"shape-gauge": "",
"sparkline": "",
"text-mode": "",
"text-mode-auto": "",
"text-mode-name": "",
"text-mode-none": "",
"text-mode-value": "",
"text-mode-value-and-name": "",
"threshold-labels": "",
"threshold-markers": ""
}
+9
View File
@@ -5967,6 +5967,9 @@
"name-values-separated-comma": "Valores separados por vírgula",
"selection-options": "Opções de seleção"
},
"dashboard-edit-pane-renderer": {
"title-feedback-dashboard-editing-experience": ""
},
"dashboard-link-form": {
"back-to-list": "Voltar para a lista",
"label-icon": "Ícone",
@@ -12516,6 +12519,12 @@
"shape-circle": "",
"shape-gauge": "",
"sparkline": "",
"text-mode": "",
"text-mode-auto": "",
"text-mode-name": "",
"text-mode-none": "",
"text-mode-value": "",
"text-mode-value-and-name": "",
"threshold-labels": "",
"threshold-markers": ""
}
+9
View File
@@ -5967,6 +5967,9 @@
"name-values-separated-comma": "Valores separados por vírgulas",
"selection-options": "Opções de seleção"
},
"dashboard-edit-pane-renderer": {
"title-feedback-dashboard-editing-experience": ""
},
"dashboard-link-form": {
"back-to-list": "Voltar à lista",
"label-icon": "Ícone",
@@ -12516,6 +12519,12 @@
"shape-circle": "",
"shape-gauge": "",
"sparkline": "",
"text-mode": "",
"text-mode-auto": "",
"text-mode-name": "",
"text-mode-none": "",
"text-mode-value": "",
"text-mode-value-and-name": "",
"threshold-labels": "",
"threshold-markers": ""
}
+9
View File
@@ -6009,6 +6009,9 @@
"name-values-separated-comma": "Значения, разделенные запятыми",
"selection-options": "Параметры выбора"
},
"dashboard-edit-pane-renderer": {
"title-feedback-dashboard-editing-experience": ""
},
"dashboard-link-form": {
"back-to-list": "Назад к списку",
"label-icon": "Значок",
@@ -12620,6 +12623,12 @@
"shape-circle": "",
"shape-gauge": "",
"sparkline": "",
"text-mode": "",
"text-mode-auto": "",
"text-mode-name": "",
"text-mode-none": "",
"text-mode-value": "",
"text-mode-value-and-name": "",
"threshold-labels": "",
"threshold-markers": ""
}
+9
View File
@@ -5967,6 +5967,9 @@
"name-values-separated-comma": "Värden åtskilda med kommatecken",
"selection-options": "Urvalsalternativ"
},
"dashboard-edit-pane-renderer": {
"title-feedback-dashboard-editing-experience": ""
},
"dashboard-link-form": {
"back-to-list": "Tillbaka till listan",
"label-icon": "Ikon",
@@ -12516,6 +12519,12 @@
"shape-circle": "",
"shape-gauge": "",
"sparkline": "",
"text-mode": "",
"text-mode-auto": "",
"text-mode-name": "",
"text-mode-none": "",
"text-mode-value": "",
"text-mode-value-and-name": "",
"threshold-labels": "",
"threshold-markers": ""
}
+9
View File
@@ -5967,6 +5967,9 @@
"name-values-separated-comma": "Virgülle ayrılmış değerler",
"selection-options": "Seçim ayarları"
},
"dashboard-edit-pane-renderer": {
"title-feedback-dashboard-editing-experience": ""
},
"dashboard-link-form": {
"back-to-list": "Listeye geri dön",
"label-icon": "Simge",
@@ -12516,6 +12519,12 @@
"shape-circle": "",
"shape-gauge": "",
"sparkline": "",
"text-mode": "",
"text-mode-auto": "",
"text-mode-name": "",
"text-mode-none": "",
"text-mode-value": "",
"text-mode-value-and-name": "",
"threshold-labels": "",
"threshold-markers": ""
}
+9
View File
@@ -5946,6 +5946,9 @@
"name-values-separated-comma": "以逗号分隔的值",
"selection-options": "选择内容选项"
},
"dashboard-edit-pane-renderer": {
"title-feedback-dashboard-editing-experience": ""
},
"dashboard-link-form": {
"back-to-list": "回到列表",
"label-icon": "图标",
@@ -12464,6 +12467,12 @@
"shape-circle": "",
"shape-gauge": "",
"sparkline": "",
"text-mode": "",
"text-mode-auto": "",
"text-mode-name": "",
"text-mode-none": "",
"text-mode-value": "",
"text-mode-value-and-name": "",
"threshold-labels": "",
"threshold-markers": ""
}
+9
View File
@@ -5946,6 +5946,9 @@
"name-values-separated-comma": "以逗號分隔的值",
"selection-options": "選擇選項"
},
"dashboard-edit-pane-renderer": {
"title-feedback-dashboard-editing-experience": ""
},
"dashboard-link-form": {
"back-to-list": "返回清單",
"label-icon": "圖示",
@@ -12464,6 +12467,12 @@
"shape-circle": "",
"shape-gauge": "",
"sparkline": "",
"text-mode": "",
"text-mode-auto": "",
"text-mode-name": "",
"text-mode-none": "",
"text-mode-value": "",
"text-mode-value-and-name": "",
"threshold-labels": "",
"threshold-markers": ""
}