Compare commits
160 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 352ac8fc5b | |||
| 00917091d7 | |||
| d73e4a229f | |||
| bba5c44dc4 | |||
| 44e6ea3d8b | |||
| 014d4758c6 | |||
| 82b4ce0ece | |||
| 52698cf0da | |||
| d291dfb35b | |||
| 9c6feb8de5 | |||
| e7625186af | |||
| 75b2c905cd | |||
| 45fc95cfc9 | |||
| 9c3cdd4814 | |||
| 2dad8b7b5b | |||
| 9a831ab4e1 | |||
| 759035a465 | |||
| 6e155523a3 | |||
| 5c0ee2d746 | |||
| 0c6b97bee2 | |||
| 4c79775b57 | |||
| e088c9aac9 | |||
| 7182511bcf | |||
| 3023a72175 | |||
| 96e3fdbfd5 | |||
| 68a65af091 | |||
| 82c045e501 | |||
| 80d806a822 | |||
| 570146fb36 | |||
| 61ec394f59 | |||
| ad0adf79bd | |||
| 1f396581a6 | |||
| 406502f351 | |||
| 9648c0956f | |||
| 008d373f7a | |||
| 3f1145fe3b | |||
| 09c5311797 | |||
| 6df663584c | |||
| 2614a917de | |||
| a1015f7a9f | |||
| f17765b70c | |||
| 9a05906299 | |||
| 969ae75b08 | |||
| eeb6d105ed | |||
| b93fb964b7 | |||
| d1657d4684 | |||
| 63374d29c0 | |||
| 379aff5ff4 | |||
| 8bf8c07878 | |||
| f7dc2f6e56 | |||
| 704f533846 | |||
| 82312c3418 | |||
| 87a9f26997 | |||
| 1fe4415682 | |||
| a629b70c1f | |||
| 36fe8c6b61 | |||
| e96dd1b12a | |||
| a4adcf8896 | |||
| 1671a8644f | |||
| bfcf649e8b | |||
| ebb4cfadff | |||
| 29e9ae1918 | |||
| 540eb6c862 | |||
| 17b20fb464 | |||
| dc986afd68 | |||
| eba83d8973 | |||
| cf89fb2a13 | |||
| 290e8a97f1 | |||
| 0ed24434c2 | |||
| cbfb1e15ed | |||
| 47e379c8d9 | |||
| 500b029b25 | |||
| bce28b8663 | |||
| b0c9350580 | |||
| 76b4c687b0 | |||
| 0d6f718255 | |||
| 6b72ddb4d3 | |||
| 2f094fdcd9 | |||
| 6a0ce01d18 | |||
| d00b8ab76d | |||
| e577b8d0b8 | |||
| 2270d6cb22 | |||
| c539be48d8 | |||
| 03a2153bd8 | |||
| eb01a3e462 | |||
| f6b6b62f5e | |||
| 5a2351387a | |||
| 78d9829d3b | |||
| d9e1adaa48 | |||
| 949b521ac7 | |||
| 1d9572cdb2 | |||
| f5e649183b | |||
| 4b44a83802 | |||
| 26dc101ecc | |||
| ca2f2e8d9e | |||
| 0e10d9cd16 | |||
| ed9789179b | |||
| 5890137232 | |||
| 471c4eb89d | |||
| 3ac63ea9c4 | |||
| 7d2d38fd94 | |||
| 80dda87868 | |||
| 04a4fd7c61 | |||
| 1d26b455fd | |||
| 8f0109a1ee | |||
| 798e9a32fc | |||
| f4001d7cdc | |||
| 718c28438e | |||
| b58b9144a5 | |||
| 96830de552 | |||
| 58c956eb19 | |||
| b1d8e9ca41 | |||
| 4006a61964 | |||
| 0a24935e45 | |||
| f25c9b0e03 | |||
| 5c478e98c4 | |||
| ddc99a1dca | |||
| e12d1ba6ca | |||
| 420e070aea | |||
| 732d4351de | |||
| f842bb7af7 | |||
| a1e2ba6617 | |||
| 1cade082fc | |||
| ea38c4ad5a | |||
| 01926ab3c8 | |||
| 84a5282ea2 | |||
| 35d7bb880a | |||
| 251b7b4b4e | |||
| 4e3197a58f | |||
| 1a883d1167 | |||
| cc2e96a558 | |||
| 6b2ebb2d65 | |||
| 7cc36672bb | |||
| 5be096833a | |||
| 076d1a5ad5 | |||
| 775ed81b58 | |||
| fb0aaa321e | |||
| c1c1f3a85c | |||
| c0f1a6423c | |||
| a44fdee0ef | |||
| 97a089ef05 | |||
| b2799f977f | |||
| 2d4fd99e7a | |||
| c4842845e7 | |||
| e9105e6303 | |||
| 841e0bd5e5 | |||
| 5c80bc12f0 | |||
| 60981e813b | |||
| 94eea040e2 | |||
| 6869160e97 | |||
| 4dc6ad9257 | |||
| b2c368e1cd | |||
| 1ea3b5440c | |||
| 9bbe311bdf | |||
| 5a338adf47 | |||
| 1d218d01b2 | |||
| ae6ba4ba44 | |||
| 90614fcb91 | |||
| 1bedd399d6 | |||
| 0bff610506 |
@@ -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
|
||||
|
||||
Vendored
+15
-1
@@ -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": ""
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+32
@@ -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"
|
||||
},
|
||||
|
||||
+1
@@ -485,6 +485,7 @@
|
||||
},
|
||||
"id": 12,
|
||||
"options": {
|
||||
"displayName": "My gauge",
|
||||
"minVizHeight": 75,
|
||||
"minVizWidth": 75,
|
||||
"orientation": "auto",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Plugins App
|
||||
|
||||
API documentation is available at http://localhost:3000/swagger?api=plugins.grafana.app-v0alpha1
|
||||
|
||||
## Codegen
|
||||
|
||||
- Go: `make generate`
|
||||
- Frontend: Follow instructions in this [README](../..//packages/grafana-api-clients/README.md)
|
||||
|
||||
## Plugin sync
|
||||
|
||||
The plugin sync pushes the plugins loaded from disk to the plugins API.
|
||||
|
||||
To enable, add these feature toggles in your `custom.ini`:
|
||||
|
||||
```ini
|
||||
[feature_toggles]
|
||||
pluginInstallAPISync = true
|
||||
pluginStoreServiceLoading = true
|
||||
```
|
||||
@@ -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",
|
||||
|
||||
@@ -98,7 +98,7 @@ You can share dashboards in the following ways:
|
||||
- [As a report](#schedule-a-report)
|
||||
- [As a snapshot](#share-a-snapshot)
|
||||
- [As a PDF export](#export-a-dashboard-as-pdf)
|
||||
- [As a JSON file export](#export-a-dashboard-as-json)
|
||||
- [As a JSON file export](#export-a-dashboard-as-code)
|
||||
- [As an image export](#export-a-dashboard-as-an-image)
|
||||
|
||||
When you share a dashboard externally as a link or by email, those dashboards are included in a list of your shared dashboards. To view the list and manage these dashboards, navigate to **Dashboards > Shared dashboards**.
|
||||
|
||||
@@ -10,7 +10,7 @@ const NUM_NESTED_DASHBOARDS = 60;
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import testDashboard from '../dashboards/TestDashboard.json';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ test.use({
|
||||
scenes: true,
|
||||
sharingDashboardImage: true, // Enable the export image feature
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { test, expect } from '@grafana/plugin-e2e';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { test, expect } from '@grafana/plugin-e2e';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import testDashboard from '../dashboards/DataLinkWithoutSlugTest.json';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import testDashboard from '../dashboards/DashboardLiveTest.json';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { test, expect } from '@grafana/plugin-e2e';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardScene: false, // this test is for the old sharing modal only used when scenes is turned off
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { test, expect } from '@grafana/plugin-e2e';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardScene: false, // this test is for the old sharing modal only used when scenes is turned off
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ test.use({
|
||||
featureToggles: {
|
||||
scenes: true,
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ test.use({
|
||||
featureToggles: {
|
||||
scenes: true,
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ test.use({
|
||||
featureToggles: {
|
||||
scenes: true,
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ test.use({
|
||||
timezoneId: 'Pacific/Easter',
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ const TIMEZONE_DASHBOARD_UID = 'd41dbaa2-a39e-4536-ab2b-caca52f1a9c8';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ test.use({
|
||||
},
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { test, expect } from '@grafana/plugin-e2e';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ const PAGE_UNDER_TEST = 'edediimbjhdz4b/a-tall-dashboard';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import testDashboard from '../dashboards/TestDashboard.json';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ const PAGE_UNDER_TEST = '-Y-tnEDWk/templating-nested-template-variables';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ const DASHBOARD_NAME = 'Test variable output';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ async function assertPreviewValues(
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ const DASHBOARD_NAME = 'Test variable output';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ async function assertPreviewValues(
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ const DASHBOARD_NAME = 'Templating - Nested Template Variables';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ const DASHBOARD_NAME = 'Test variable output';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ const PAGE_UNDER_TEST = 'WVpf2jp7z/repeating-a-panel-horizontally';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ const PAGE_UNDER_TEST = 'OY8Ghjt7k/repeating-a-panel-vertically';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ const PAGE_UNDER_TEST = 'dtpl2Ctnk/repeating-an-empty-row';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ const PAGE_UNDER_TEST = '-Y-tnEDWk/templating-nested-template-variables';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ const DASHBOARD_UID = 'ZqZnVvFZz';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardScene: false, // this test is for the old sharing modal only used when scenes is turned off
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ const DASHBOARD_UID = 'yBCC3aKGk';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ const PAGE_UNDER_TEST = 'AejrN1AMz';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -2,18 +2,16 @@ import { Locator } from '@playwright/test';
|
||||
|
||||
import { test, expect } from '@grafana/plugin-e2e';
|
||||
|
||||
import { setVisualization } from './vizpicker-utils';
|
||||
|
||||
test.use({
|
||||
featureToggles: {
|
||||
canvasPanelPanZoom: true,
|
||||
},
|
||||
});
|
||||
test.describe('Canvas Panel - Scene Tests', () => {
|
||||
test.beforeEach(async ({ page, gotoDashboardPage, selectors }) => {
|
||||
test.beforeEach(async ({ page, gotoDashboardPage }) => {
|
||||
const dashboardPage = await gotoDashboardPage({});
|
||||
const panelEditPage = await dashboardPage.addPanel();
|
||||
await setVisualization(panelEditPage, 'Canvas', selectors);
|
||||
await panelEditPage.setVisualization('Canvas');
|
||||
|
||||
// Wait for canvas panel to load
|
||||
await page.waitForSelector('[data-testid="canvas-scene-pan-zoom"]', { timeout: 10000 });
|
||||
|
||||
@@ -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,24 +0,0 @@
|
||||
import { expect, E2ESelectorGroups, PanelEditPage } from '@grafana/plugin-e2e';
|
||||
|
||||
// this replaces the panelEditPage.setVisualization method used previously in tests, since it
|
||||
// does not know how to use the updated 12.4 viz picker UI to set the visualization
|
||||
export const setVisualization = async (panelEditPage: PanelEditPage, vizName: string, selectors: E2ESelectorGroups) => {
|
||||
const vizPicker = panelEditPage.getByGrafanaSelector(selectors.components.PanelEditor.toggleVizPicker);
|
||||
await expect(vizPicker, '"Change" button should be visible').toBeVisible();
|
||||
await vizPicker.click();
|
||||
|
||||
const allVizTabBtn = panelEditPage.getByGrafanaSelector(selectors.components.Tab.title('All visualizations'));
|
||||
await expect(allVizTabBtn, '"All visualiations" button should be visible').toBeVisible();
|
||||
await allVizTabBtn.click();
|
||||
|
||||
const vizItem = panelEditPage.getByGrafanaSelector(selectors.components.PluginVisualization.item(vizName));
|
||||
await expect(vizItem, `"${vizName}" item should be visible`).toBeVisible();
|
||||
await vizItem.scrollIntoViewIfNeeded();
|
||||
await vizItem.click();
|
||||
|
||||
await expect(vizPicker, '"Change" button should be visible again').toBeVisible();
|
||||
await expect(
|
||||
panelEditPage.getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.header),
|
||||
'Panel header should have the new viz type name'
|
||||
).toHaveText(vizName);
|
||||
};
|
||||
+4
-5
@@ -1,6 +1,5 @@
|
||||
import { expect, test } from '@grafana/plugin-e2e';
|
||||
|
||||
import { setVisualization } from '../../../panels-suite/vizpicker-utils';
|
||||
import { formatExpectError } from '../errors';
|
||||
import { successfulDataQuery } from '../mocks/queries';
|
||||
|
||||
@@ -25,10 +24,10 @@ test.describe(
|
||||
).toContainText(['Field', 'Max', 'Mean', 'Last']);
|
||||
});
|
||||
|
||||
test('table panel data assertions', async ({ panelEditPage, selectors }) => {
|
||||
test('table panel data assertions', async ({ panelEditPage }) => {
|
||||
await panelEditPage.mockQueryDataResponse(successfulDataQuery, 200);
|
||||
await panelEditPage.datasource.set('gdev-testdata');
|
||||
await setVisualization(panelEditPage, 'Table', selectors);
|
||||
await panelEditPage.setVisualization('Table');
|
||||
await panelEditPage.refreshPanel();
|
||||
await expect(
|
||||
panelEditPage.panel.locator,
|
||||
@@ -44,10 +43,10 @@ test.describe(
|
||||
).toContainText(['val1', 'val2', 'val3', 'val4']);
|
||||
});
|
||||
|
||||
test('timeseries panel - table view assertions', async ({ panelEditPage, selectors }) => {
|
||||
test('timeseries panel - table view assertions', async ({ panelEditPage }) => {
|
||||
await panelEditPage.mockQueryDataResponse(successfulDataQuery, 200);
|
||||
await panelEditPage.datasource.set('gdev-testdata');
|
||||
await setVisualization(panelEditPage, 'Time series', selectors);
|
||||
await panelEditPage.setVisualization('Time series');
|
||||
await panelEditPage.refreshPanel();
|
||||
await panelEditPage.toggleTableView();
|
||||
await expect(
|
||||
|
||||
+25
-26
@@ -1,6 +1,5 @@
|
||||
import { expect, test } from '@grafana/plugin-e2e';
|
||||
|
||||
import { setVisualization } from '../../../panels-suite/vizpicker-utils';
|
||||
import { formatExpectError } from '../errors';
|
||||
import { successfulDataQuery } from '../mocks/queries';
|
||||
import { scenarios } from '../mocks/resources';
|
||||
@@ -54,10 +53,10 @@ test.describe(
|
||||
).toHaveText(scenarios.map((s) => s.name));
|
||||
});
|
||||
|
||||
test('mocked query data response', async ({ panelEditPage, page, selectors }) => {
|
||||
test('mocked query data response', async ({ panelEditPage, page }) => {
|
||||
await panelEditPage.mockQueryDataResponse(successfulDataQuery, 200);
|
||||
await panelEditPage.datasource.set('gdev-testdata');
|
||||
await setVisualization(panelEditPage, TABLE_VIZ_NAME, selectors);
|
||||
await panelEditPage.setVisualization(TABLE_VIZ_NAME);
|
||||
await panelEditPage.refreshPanel();
|
||||
await expect(
|
||||
panelEditPage.panel.getErrorIcon(),
|
||||
@@ -76,7 +75,7 @@ test.describe(
|
||||
selectors,
|
||||
page,
|
||||
}) => {
|
||||
await setVisualization(panelEditPage, TABLE_VIZ_NAME, selectors);
|
||||
await panelEditPage.setVisualization(TABLE_VIZ_NAME);
|
||||
await expect(
|
||||
panelEditPage.getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.header),
|
||||
formatExpectError('Expected panel visualization to be set to table')
|
||||
@@ -93,8 +92,8 @@ test.describe(
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('Select time zone in timezone picker', async ({ panelEditPage, selectors }) => {
|
||||
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
|
||||
test('Select time zone in timezone picker', async ({ panelEditPage }) => {
|
||||
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
|
||||
const axisOptions = await panelEditPage.getCustomOptions('Axis');
|
||||
const timeZonePicker = axisOptions.getSelect('Time zone');
|
||||
|
||||
@@ -102,8 +101,8 @@ test.describe(
|
||||
await expect(timeZonePicker).toHaveSelected('Europe/Stockholm');
|
||||
});
|
||||
|
||||
test('select unit in unit picker', async ({ panelEditPage, selectors }) => {
|
||||
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
|
||||
test('select unit in unit picker', async ({ panelEditPage }) => {
|
||||
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
|
||||
const standardOptions = panelEditPage.getStandardOptions();
|
||||
const unitPicker = standardOptions.getUnitPicker('Unit');
|
||||
|
||||
@@ -112,8 +111,8 @@ test.describe(
|
||||
await expect(unitPicker).toHaveSelected('Pixels');
|
||||
});
|
||||
|
||||
test('enter value in number input', async ({ panelEditPage, selectors }) => {
|
||||
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
|
||||
test('enter value in number input', async ({ panelEditPage }) => {
|
||||
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
|
||||
const axisOptions = panelEditPage.getCustomOptions('Axis');
|
||||
const lineWith = axisOptions.getNumberInput('Soft min');
|
||||
|
||||
@@ -122,8 +121,8 @@ test.describe(
|
||||
await expect(lineWith).toHaveValue('10');
|
||||
});
|
||||
|
||||
test('enter value in slider', async ({ panelEditPage, selectors }) => {
|
||||
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
|
||||
test('enter value in slider', async ({ panelEditPage }) => {
|
||||
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
|
||||
const graphOptions = panelEditPage.getCustomOptions('Graph styles');
|
||||
const lineWidth = graphOptions.getSliderInput('Line width');
|
||||
|
||||
@@ -132,8 +131,8 @@ test.describe(
|
||||
await expect(lineWidth).toHaveValue('10');
|
||||
});
|
||||
|
||||
test('select value in single value select', async ({ panelEditPage, selectors }) => {
|
||||
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
|
||||
test('select value in single value select', async ({ panelEditPage }) => {
|
||||
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
|
||||
const standardOptions = panelEditPage.getStandardOptions();
|
||||
const colorSchemeSelect = standardOptions.getSelect('Color scheme');
|
||||
|
||||
@@ -141,8 +140,8 @@ test.describe(
|
||||
await expect(colorSchemeSelect).toHaveSelected('Classic palette');
|
||||
});
|
||||
|
||||
test('clear input', async ({ panelEditPage, selectors }) => {
|
||||
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
|
||||
test('clear input', async ({ panelEditPage }) => {
|
||||
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
|
||||
const panelOptions = panelEditPage.getPanelOptions();
|
||||
const title = panelOptions.getTextInput('Title');
|
||||
|
||||
@@ -151,8 +150,8 @@ test.describe(
|
||||
await expect(title).toHaveValue('');
|
||||
});
|
||||
|
||||
test('enter value in input', async ({ panelEditPage, selectors }) => {
|
||||
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
|
||||
test('enter value in input', async ({ panelEditPage }) => {
|
||||
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
|
||||
const panelOptions = panelEditPage.getPanelOptions();
|
||||
const description = panelOptions.getTextInput('Description');
|
||||
|
||||
@@ -161,8 +160,8 @@ test.describe(
|
||||
await expect(description).toHaveValue('This is a panel');
|
||||
});
|
||||
|
||||
test('unchecking switch', async ({ panelEditPage, selectors }) => {
|
||||
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
|
||||
test('unchecking switch', async ({ panelEditPage }) => {
|
||||
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
|
||||
const axisOptions = panelEditPage.getCustomOptions('Axis');
|
||||
const showBorder = axisOptions.getSwitch('Show border');
|
||||
|
||||
@@ -174,8 +173,8 @@ test.describe(
|
||||
await expect(showBorder).toBeChecked({ checked: false });
|
||||
});
|
||||
|
||||
test('checking switch', async ({ panelEditPage, selectors }) => {
|
||||
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
|
||||
test('checking switch', async ({ panelEditPage }) => {
|
||||
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
|
||||
const axisOptions = panelEditPage.getCustomOptions('Axis');
|
||||
const showBorder = axisOptions.getSwitch('Show border');
|
||||
|
||||
@@ -184,8 +183,8 @@ test.describe(
|
||||
await expect(showBorder).toBeChecked();
|
||||
});
|
||||
|
||||
test('re-selecting value in radio button group', async ({ panelEditPage, selectors }) => {
|
||||
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
|
||||
test('re-selecting value in radio button group', async ({ panelEditPage }) => {
|
||||
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
|
||||
const axisOptions = panelEditPage.getCustomOptions('Axis');
|
||||
const placement = axisOptions.getRadioGroup('Placement');
|
||||
|
||||
@@ -196,8 +195,8 @@ test.describe(
|
||||
await expect(placement).toHaveChecked('Auto');
|
||||
});
|
||||
|
||||
test('selecting value in radio button group', async ({ panelEditPage, selectors }) => {
|
||||
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
|
||||
test('selecting value in radio button group', async ({ panelEditPage }) => {
|
||||
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
|
||||
const axisOptions = panelEditPage.getCustomOptions('Axis');
|
||||
const placement = axisOptions.getRadioGroup('Placement');
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
);
|
||||
+8
@@ -285,6 +285,10 @@ const injectedRtkApi = api
|
||||
query: (queryArg) => ({ url: `/snapshots/delete/${queryArg.deleteKey}`, method: 'DELETE' }),
|
||||
invalidatesTags: ['Snapshot'],
|
||||
}),
|
||||
getSnapshotSettings: build.query<GetSnapshotSettingsApiResponse, GetSnapshotSettingsApiArg>({
|
||||
query: () => ({ url: `/snapshots/settings` }),
|
||||
providesTags: ['Snapshot'],
|
||||
}),
|
||||
getSnapshot: build.query<GetSnapshotApiResponse, GetSnapshotApiArg>({
|
||||
query: (queryArg) => ({
|
||||
url: `/snapshots/${queryArg.name}`,
|
||||
@@ -742,6 +746,8 @@ export type DeleteWithKeyApiArg = {
|
||||
/** unique key returned in create */
|
||||
deleteKey: string;
|
||||
};
|
||||
export type GetSnapshotSettingsApiResponse = /** status 200 undefined */ any;
|
||||
export type GetSnapshotSettingsApiArg = void;
|
||||
export type GetSnapshotApiResponse = /** status 200 OK */ Snapshot;
|
||||
export type GetSnapshotApiArg = {
|
||||
/** name of the Snapshot */
|
||||
@@ -1273,6 +1279,8 @@ export const {
|
||||
useLazyListSnapshotQuery,
|
||||
useCreateSnapshotMutation,
|
||||
useDeleteWithKeyMutation,
|
||||
useGetSnapshotSettingsQuery,
|
||||
useLazyGetSnapshotSettingsQuery,
|
||||
useGetSnapshotQuery,
|
||||
useLazyGetSnapshotQuery,
|
||||
useDeleteSnapshotMutation,
|
||||
|
||||
+4
-4
@@ -356,10 +356,6 @@ export interface FeatureToggles {
|
||||
*/
|
||||
dashboardNewLayouts?: boolean;
|
||||
/**
|
||||
* Use the v2 kubernetes API in the frontend for dashboards
|
||||
*/
|
||||
kubernetesDashboardsV2?: boolean;
|
||||
/**
|
||||
* Enables undo/redo in dynamic dashboards
|
||||
*/
|
||||
dashboardUndoRedo?: boolean;
|
||||
@@ -421,6 +417,10 @@ export interface FeatureToggles {
|
||||
*/
|
||||
jitterAlertRulesWithinGroups?: boolean;
|
||||
/**
|
||||
* Enable audit logging with Kubernetes under app platform
|
||||
*/
|
||||
auditLoggingAppPlatform?: boolean;
|
||||
/**
|
||||
* Enable the secrets management API and services under app platform
|
||||
*/
|
||||
secretsManagementAppPlatform?: boolean;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
+6
-2
@@ -48,7 +48,7 @@ describe('MetricsModal', () => {
|
||||
operations: [],
|
||||
};
|
||||
|
||||
setup(query, ['with-labels'], true);
|
||||
setup(query, ['with-labels']);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('with-labels')).toBeInTheDocument();
|
||||
});
|
||||
@@ -220,6 +220,10 @@ function createDatasource(withLabels?: boolean) {
|
||||
// display different results if their labels are selected in the PromVisualQuery
|
||||
if (withLabels) {
|
||||
languageProvider.queryMetricsMetadata = jest.fn().mockResolvedValue({
|
||||
ALERTS: {
|
||||
type: 'gauge',
|
||||
help: 'alerts help text',
|
||||
},
|
||||
'with-labels': {
|
||||
type: 'with-labels-type',
|
||||
help: 'with-labels-help',
|
||||
@@ -297,7 +301,7 @@ function createProps(query: PromVisualQuery, datasource: PrometheusDatasource, m
|
||||
};
|
||||
}
|
||||
|
||||
function setup(query: PromVisualQuery, metrics: string[], withlabels?: boolean) {
|
||||
function setup(query: PromVisualQuery, metrics: string[]) {
|
||||
const withLabels: boolean = query.labels.length > 0;
|
||||
const datasource = createDatasource(withLabels);
|
||||
const props = createProps(query, datasource, metrics);
|
||||
|
||||
+1
-1
@@ -138,7 +138,7 @@ const MetricsModalContent = (props: MetricsModalProps) => {
|
||||
|
||||
export const MetricsModal = (props: MetricsModalProps) => {
|
||||
return (
|
||||
<MetricsModalContextProvider languageProvider={props.datasource.languageProvider}>
|
||||
<MetricsModalContextProvider languageProvider={props.datasource.languageProvider} timeRange={props.timeRange}>
|
||||
<MetricsModalContent {...props} />
|
||||
</MetricsModalContextProvider>
|
||||
);
|
||||
|
||||
+20
-4
@@ -4,6 +4,7 @@ import { ReactNode } from 'react';
|
||||
import { TimeRange } from '@grafana/data';
|
||||
|
||||
import { PrometheusLanguageProviderInterface } from '../../../language_provider';
|
||||
import { getMockTimeRange } from '../../../test/mocks/datasource';
|
||||
|
||||
import { DEFAULT_RESULTS_PER_PAGE, MetricsModalContextProvider, useMetricsModal } from './MetricsModalContext';
|
||||
import { generateMetricData } from './helpers';
|
||||
@@ -25,7 +26,9 @@ const mockLanguageProvider: PrometheusLanguageProviderInterface = {
|
||||
// Helper to create wrapper component
|
||||
const createWrapper = (languageProvider = mockLanguageProvider) => {
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<MetricsModalContextProvider languageProvider={languageProvider}>{children}</MetricsModalContextProvider>
|
||||
<MetricsModalContextProvider languageProvider={languageProvider} timeRange={getMockTimeRange()}>
|
||||
{children}
|
||||
</MetricsModalContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -167,6 +170,7 @@ describe('MetricsModalContext', () => {
|
||||
|
||||
it('should handle empty metadata response', async () => {
|
||||
(mockLanguageProvider.queryMetricsMetadata as jest.Mock).mockResolvedValue({});
|
||||
(mockLanguageProvider.queryLabelValues as jest.Mock).mockResolvedValue(['metric1', 'metric2']);
|
||||
|
||||
const { result } = renderHook(() => useMetricsModal(), {
|
||||
wrapper: createWrapper(),
|
||||
@@ -176,7 +180,18 @@ describe('MetricsModalContext', () => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.filteredMetricsData).toEqual([]);
|
||||
expect(result.current.filteredMetricsData).toEqual([
|
||||
{
|
||||
value: 'metric1',
|
||||
type: 'counter',
|
||||
description: 'Test metric',
|
||||
},
|
||||
{
|
||||
value: 'metric2',
|
||||
type: 'counter',
|
||||
description: 'Test metric',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle metadata fetch error', async () => {
|
||||
@@ -239,6 +254,7 @@ describe('MetricsModalContext', () => {
|
||||
}));
|
||||
|
||||
(mockLanguageProvider.queryMetricsMetadata as jest.Mock).mockResolvedValue({
|
||||
ALERTS: { type: 'gauge', help: 'Test alerts help' },
|
||||
test_metric: { type: 'counter', help: 'Test metric' },
|
||||
});
|
||||
|
||||
@@ -250,7 +266,7 @@ describe('MetricsModalContext', () => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.filteredMetricsData).toHaveLength(1);
|
||||
expect(result.current.filteredMetricsData).toHaveLength(2);
|
||||
expect(result.current.selectedTypes).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -318,7 +334,7 @@ describe('MetricsModalContext', () => {
|
||||
};
|
||||
|
||||
const { getByTestId } = render(
|
||||
<MetricsModalContextProvider languageProvider={mockLanguageProvider}>
|
||||
<MetricsModalContextProvider languageProvider={mockLanguageProvider} timeRange={getMockTimeRange()}>
|
||||
<TestComponent />
|
||||
</MetricsModalContextProvider>
|
||||
);
|
||||
|
||||
+13
-3
@@ -52,11 +52,13 @@ const MetricsModalContext = createContext<MetricsModalContextValue | undefined>(
|
||||
|
||||
type MetricsModalContextProviderProps = {
|
||||
languageProvider: PrometheusLanguageProviderInterface;
|
||||
timeRange: TimeRange;
|
||||
};
|
||||
|
||||
export const MetricsModalContextProvider: FC<PropsWithChildren<MetricsModalContextProviderProps>> = ({
|
||||
children,
|
||||
languageProvider,
|
||||
timeRange,
|
||||
}) => {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [metricsData, setMetricsData] = useState<MetricsData>([]);
|
||||
@@ -111,8 +113,16 @@ export const MetricsModalContextProvider: FC<PropsWithChildren<MetricsModalConte
|
||||
setIsLoading(true);
|
||||
const metadata = await languageProvider.queryMetricsMetadata(PROMETHEUS_QUERY_BUILDER_MAX_RESULTS);
|
||||
|
||||
if (Object.keys(metadata).length === 0) {
|
||||
setMetricsData([]);
|
||||
// We receive ALERTS metadata in any case
|
||||
if (Object.keys(metadata).length <= 1) {
|
||||
const fetchedMetrics = await languageProvider.queryLabelValues(
|
||||
timeRange,
|
||||
METRIC_LABEL,
|
||||
undefined,
|
||||
PROMETHEUS_QUERY_BUILDER_MAX_RESULTS
|
||||
);
|
||||
const processedData = fetchedMetrics.map((m) => generateMetricData(m, languageProvider));
|
||||
setMetricsData(processedData);
|
||||
} else {
|
||||
const processedData = Object.keys(metadata).map((m) => generateMetricData(m, languageProvider));
|
||||
setMetricsData(processedData);
|
||||
@@ -122,7 +132,7 @@ export const MetricsModalContextProvider: FC<PropsWithChildren<MetricsModalConte
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [languageProvider]);
|
||||
}, [languageProvider, timeRange]);
|
||||
|
||||
const debouncedBackendSearch = useMemo(
|
||||
() =>
|
||||
|
||||
Generated
+2
@@ -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;
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package auditing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Sinkable is a log entry abstraction that can be sent to an audit log sink through the different implementing methods.
|
||||
type Sinkable interface {
|
||||
json.Marshaler
|
||||
KVPairs() []any
|
||||
Time() time.Time
|
||||
}
|
||||
|
||||
// Logger specifies the contract for a specific audit logger.
|
||||
type Logger interface {
|
||||
Log(entry Sinkable) error
|
||||
Close() error
|
||||
Type() string
|
||||
}
|
||||
|
||||
// Implementation inspired by https://github.com/grafana/grafana-app-sdk/blob/main/logging/logger.go
|
||||
type loggerContextKey struct{}
|
||||
|
||||
var (
|
||||
// DefaultLogger is the default Logger if one hasn't been provided in the context.
|
||||
// You may use this to add arbitrary audit logging outside of an API request lifecycle.
|
||||
DefaultLogger Logger = &NoopLogger{}
|
||||
|
||||
contextKey = loggerContextKey{}
|
||||
)
|
||||
|
||||
// FromContext returns the Logger set in the context with Context(), or the DefaultLogger if no Logger is set in the context.
|
||||
// If DefaultLogger is nil, it returns a *NoopLogger so that the return is always valid to call methods on without nil-checking.
|
||||
// You may use this to add arbitrary audit logging outside of an API request lifecycle.
|
||||
func FromContext(ctx context.Context) Logger {
|
||||
if l := ctx.Value(contextKey); l != nil {
|
||||
if logger, ok := l.(Logger); ok {
|
||||
return logger
|
||||
}
|
||||
}
|
||||
|
||||
if DefaultLogger != nil {
|
||||
return DefaultLogger
|
||||
}
|
||||
|
||||
return &NoopLogger{}
|
||||
}
|
||||
|
||||
// Context returns a new context built from the provided context with the provided logger in it.
|
||||
// The Logger added with Context() can be retrieved with FromContext()
|
||||
func Context(ctx context.Context, logger Logger) context.Context {
|
||||
return context.WithValue(ctx, contextKey, logger)
|
||||
}
|
||||
@@ -11,9 +11,9 @@ type NoopBackend struct{}
|
||||
|
||||
func ProvideNoopBackend() audit.Backend { return &NoopBackend{} }
|
||||
|
||||
func (b *NoopBackend) ProcessEvents(k8sEvents ...*auditinternal.Event) bool { return false }
|
||||
func (NoopBackend) ProcessEvents(...*auditinternal.Event) bool { return false }
|
||||
|
||||
func (NoopBackend) Run(stopCh <-chan struct{}) error { return nil }
|
||||
func (NoopBackend) Run(<-chan struct{}) error { return nil }
|
||||
|
||||
func (NoopBackend) Shutdown() {}
|
||||
|
||||
@@ -34,3 +34,14 @@ type NoopPolicyRuleEvaluator struct{}
|
||||
func (NoopPolicyRuleEvaluator) EvaluatePolicyRule(authorizer.Attributes) audit.RequestAuditConfig {
|
||||
return audit.RequestAuditConfig{Level: auditinternal.LevelNone}
|
||||
}
|
||||
|
||||
// NoopLogger is a no-op implementation of Logger
|
||||
type NoopLogger struct{}
|
||||
|
||||
func ProvideNoopLogger() Logger { return &NoopLogger{} }
|
||||
|
||||
func (NoopLogger) Type() string { return "noop" }
|
||||
|
||||
func (NoopLogger) Log(Sinkable) error { return nil }
|
||||
|
||||
func (NoopLogger) Close() error { return nil }
|
||||
|
||||
@@ -46,14 +46,23 @@ func (defaultGrafanaPolicyRuleEvaluator) EvaluatePolicyRule(attrs authorizer.Att
|
||||
}
|
||||
}
|
||||
|
||||
// Logging the response object allows us to get the resource name for create requests.
|
||||
level := auditinternal.LevelMetadata
|
||||
if attrs.GetVerb() == utils.VerbCreate {
|
||||
level = auditinternal.LevelRequestResponse
|
||||
}
|
||||
|
||||
return audit.RequestAuditConfig{
|
||||
Level: auditinternal.LevelMetadata,
|
||||
Level: level,
|
||||
|
||||
// Only log on StageResponseComplete, to avoid noisy logs.
|
||||
OmitStages: []auditinternal.Stage{
|
||||
// Only log on StageResponseComplete
|
||||
auditinternal.StageRequestReceived,
|
||||
auditinternal.StageResponseStarted,
|
||||
auditinternal.StagePanic,
|
||||
},
|
||||
OmitManagedFields: false, // Setting it to true causes extra copying/unmarshalling.
|
||||
|
||||
// Setting it to true causes extra copying/unmarshalling.
|
||||
OmitManagedFields: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ func TestDefaultGrafanaPolicyRuleEvaluator(t *testing.T) {
|
||||
require.Equal(t, auditinternal.LevelNone, config.Level)
|
||||
})
|
||||
|
||||
t.Run("return audit level metadata for other resource requests", func(t *testing.T) {
|
||||
t.Run("return audit level request+response for create requests", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
attrs := authorizer.AttributesRecord{
|
||||
@@ -67,6 +67,22 @@ func TestDefaultGrafanaPolicyRuleEvaluator(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
config := evaluator.EvaluatePolicyRule(attrs)
|
||||
require.Equal(t, auditinternal.LevelRequestResponse, config.Level)
|
||||
})
|
||||
|
||||
t.Run("return audit level metadata for other resource requests", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
attrs := authorizer.AttributesRecord{
|
||||
ResourceRequest: true,
|
||||
Verb: utils.VerbGet,
|
||||
User: &user.DefaultInfo{
|
||||
Name: "test-user",
|
||||
Groups: []string{"test-group"},
|
||||
},
|
||||
}
|
||||
|
||||
config := evaluator.EvaluatePolicyRule(attrs)
|
||||
require.Equal(t, auditinternal.LevelMetadata, config.Level)
|
||||
})
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana/pkg/configprovider"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
@@ -62,7 +63,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/quota"
|
||||
"github.com/grafana/grafana/pkg/services/search/sort"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/storage/legacysql"
|
||||
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/apistore"
|
||||
@@ -128,7 +128,6 @@ type DashboardsAPIBuilder struct {
|
||||
}
|
||||
|
||||
func RegisterAPIService(
|
||||
cfg *setting.Cfg,
|
||||
features featuremgmt.FeatureToggles,
|
||||
apiregistration builder.APIRegistrar,
|
||||
dashboardService dashboards.DashboardService,
|
||||
@@ -154,7 +153,14 @@ func RegisterAPIService(
|
||||
publicDashboardService publicdashboards.Service,
|
||||
snapshotService dashboardsnapshots.Service,
|
||||
dashboardActivityChannel live.DashboardActivityChannel,
|
||||
configProvider configprovider.ConfigProvider,
|
||||
) *DashboardsAPIBuilder {
|
||||
cfg, err := configProvider.Get(context.Background())
|
||||
if err != nil {
|
||||
logging.DefaultLogger.Error("failed to load settings configuration instance", "stackId", cfg.StackID, "err", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
dbp := legacysql.NewDatabaseProvider(sql)
|
||||
namespacer := request.GetNamespaceMapper(cfg)
|
||||
legacyDashboardSearcher := legacysearcher.NewDashboardSearchClient(dashStore, sorter)
|
||||
@@ -237,7 +243,7 @@ func NewAPIService(ac authlib.AccessClient, features featuremgmt.FeatureToggles,
|
||||
}
|
||||
|
||||
func (b *DashboardsAPIBuilder) GetGroupVersions() []schema.GroupVersion {
|
||||
if featuremgmt.AnyEnabled(b.features, featuremgmt.FlagDashboardNewLayouts, featuremgmt.FlagKubernetesDashboardsV2) {
|
||||
if featuremgmt.AnyEnabled(b.features, featuremgmt.FlagDashboardNewLayouts) {
|
||||
// If dashboards v2 is enabled, we want to use v2beta1 as the default API version.
|
||||
return []schema.GroupVersion{
|
||||
dashv2beta1.DashboardResourceInfo.GroupVersion(),
|
||||
@@ -747,7 +753,6 @@ func (b *DashboardsAPIBuilder) storageForVersion(
|
||||
ResourceInfo: *snapshots,
|
||||
Service: b.snapshotService,
|
||||
Namespacer: b.namespacer,
|
||||
Options: b.snapshotOptions,
|
||||
}
|
||||
storage[snapshots.StoragePath()] = snapshotLegacyStore
|
||||
storage[snapshots.StoragePath("dashboard")], err = snapshot.NewDashboardREST(dashboards, b.snapshotService)
|
||||
|
||||
@@ -29,6 +29,8 @@ func GetRoutes(service dashboardsnapshots.Service, options dashv0.SnapshotSharin
|
||||
createCmd := defs["github.com/grafana/grafana/apps/dashboard/pkg/apissnapshot/v0alpha1.DashboardCreateCommand"].Schema
|
||||
createExample := `{"dashboard":{"annotations":{"list":[{"name":"Annotations & Alerts","enable":true,"iconColor":"rgba(0, 211, 255, 1)","snapshotData":[],"type":"dashboard","builtIn":1,"hide":true}]},"editable":true,"fiscalYearStartMonth":0,"graphTooltip":0,"id":203,"links":[],"liveNow":false,"panels":[{"datasource":null,"fieldConfig":{"defaults":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisLabel":"","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":43,"gradientMode":"opacity","hideFrom":{"legend":false,"tooltip":false,"viz":false},"insertNulls":false,"lineInterpolation":"smooth","lineWidth":1,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"auto","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"off"}},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"red","value":80}]},"unitScale":true},"overrides":[]},"gridPos":{"h":8,"w":12,"x":0,"y":0},"id":1,"options":{"legend":{"calcs":[],"displayMode":"list","placement":"bottom","showLegend":true},"tooltip":{"mode":"single","sort":"none"}},"pluginVersion":"10.4.0-pre","snapshotData":[{"fields":[{"config":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":43,"gradientMode":"opacity","hideFrom":{"legend":false,"tooltip":false,"viz":false},"lineInterpolation":"smooth","lineWidth":1,"pointSize":5,"showPoints":"auto","thresholdsStyle":{"mode":"off"}},"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"red","value":80}]},"unitScale":true},"name":"time","type":"time","values":[1706030536378,1706034856378,1706039176378,1706043496378,1706047816378,1706052136378]},{"config":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisLabel":"","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":43,"gradientMode":"opacity","hideFrom":{"legend":false,"tooltip":false,"viz":false},"insertNulls":false,"lineInterpolation":"smooth","lineWidth":1,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"auto","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"off"}},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"red","value":80}]},"unitScale":true},"name":"A-series","type":"number","values":[1,20,90,30,50,0]}],"refId":"A"}],"targets":[],"title":"Simple example","type":"timeseries","links":[]}],"refresh":"","schemaVersion":39,"snapshot":{"timestamp":"2024-01-23T23:22:16.377Z"},"tags":[],"templating":{"list":[]},"time":{"from":"2024-01-23T17:22:20.380Z","to":"2024-01-23T23:22:20.380Z","raw":{"from":"now-6h","to":"now"}},"timepicker":{},"timezone":"","title":"simple and small","uid":"b22ec8db-399b-403b-b6c7-b0fb30ccb2a5","version":1,"weekStart":""},"name":"simple and small","expires":86400}`
|
||||
createRsp := defs["github.com/grafana/grafana/apps/dashboard/pkg/apissnapshot/v0alpha1.DashboardCreateResponse"].Schema
|
||||
getSettingsRsp := defs["github.com/grafana/grafana/apps/dashboard/pkg/apissnapshot/v0alpha1.SnapshotSharingOptions"].Schema
|
||||
getSettingsRspExample := `{"snapshotsEnabled":true,"externalSnapshotURL":"https://externalurl.com","externalSnapshotName":"external","externalEnabled":true}`
|
||||
|
||||
return &builder.APIRoutes{
|
||||
Namespace: []builder.APIRouteHandler{
|
||||
@@ -167,5 +169,84 @@ func GetRoutes(service dashboardsnapshots.Service, options dashv0.SnapshotSharin
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: prefix + "/settings",
|
||||
Spec: &spec3.PathProps{
|
||||
Get: &spec3.Operation{
|
||||
VendorExtensible: spec.VendorExtensible{
|
||||
Extensions: map[string]any{
|
||||
"x-grafana-action": "get",
|
||||
"x-kubernetes-group-version-kind": metav1.GroupVersionKind{
|
||||
Group: dashv0.GROUP,
|
||||
Version: dashv0.VERSION,
|
||||
Kind: "SnapshotSharingOptions",
|
||||
},
|
||||
},
|
||||
},
|
||||
OperationProps: spec3.OperationProps{
|
||||
Tags: tags,
|
||||
OperationId: "getSnapshotSettings",
|
||||
Description: "Get Snapshot sharing settings",
|
||||
Parameters: []*spec3.Parameter{
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "namespace",
|
||||
In: "path",
|
||||
Required: true,
|
||||
Example: "default",
|
||||
Description: "workspace",
|
||||
Schema: spec.StringProperty(),
|
||||
},
|
||||
},
|
||||
},
|
||||
Responses: &spec3.Responses{
|
||||
ResponsesProps: spec3.ResponsesProps{
|
||||
StatusCodeResponses: map[int]*spec3.Response{
|
||||
200: {
|
||||
ResponseProps: spec3.ResponseProps{
|
||||
Content: map[string]*spec3.MediaType{
|
||||
"application/json": {
|
||||
MediaTypeProps: spec3.MediaTypeProps{
|
||||
Schema: &getSettingsRsp,
|
||||
Example: getSettingsRspExample,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := identity.GetRequester(r.Context())
|
||||
if err != nil {
|
||||
errhttp.Write(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
wrap := &contextmodel.ReqContext{
|
||||
Context: &web.Context{
|
||||
Req: r,
|
||||
Resp: web.NewResponseWriter(r.Method, w),
|
||||
},
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
info, err := authlib.ParseNamespace(vars["namespace"])
|
||||
if err != nil {
|
||||
wrap.JsonApiErr(http.StatusBadRequest, "expected namespace", nil)
|
||||
return
|
||||
}
|
||||
if info.OrgID != user.GetOrgID() {
|
||||
wrap.JsonApiErr(http.StatusBadRequest,
|
||||
fmt.Sprintf("user orgId does not match namespace (%d != %d)", info.OrgID, user.GetOrgID()), nil)
|
||||
return
|
||||
}
|
||||
|
||||
wrap.JSON(http.StatusOK, options)
|
||||
},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package snapshot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
@@ -29,7 +28,6 @@ type SnapshotLegacyStore struct {
|
||||
ResourceInfo utils.ResourceInfo
|
||||
Service dashboardsnapshots.Service
|
||||
Namespacer request.NamespaceMapper
|
||||
Options dashV0.SnapshotSharingOptions
|
||||
}
|
||||
|
||||
func (s *SnapshotLegacyStore) New() runtime.Object {
|
||||
@@ -117,15 +115,6 @@ func (s *SnapshotLegacyStore) List(ctx context.Context, options *internalversion
|
||||
}
|
||||
|
||||
func (s *SnapshotLegacyStore) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
|
||||
info, err := request.NamespaceInfoFrom(ctx, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = s.checkEnabled(info.Value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query := dashboardsnapshots.GetDashboardSnapshotQuery{
|
||||
Key: name,
|
||||
}
|
||||
@@ -140,10 +129,3 @@ func (s *SnapshotLegacyStore) Get(ctx context.Context, name string, options *met
|
||||
}
|
||||
return nil, s.ResourceInfo.NewNotFound(name)
|
||||
}
|
||||
|
||||
func (s *SnapshotLegacyStore) checkEnabled(ns string) error {
|
||||
if !s.Options.SnapshotsEnabled {
|
||||
return fmt.Errorf("snapshots not enabled")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
-1
@@ -5,7 +5,6 @@
|
||||
"uid": "boDNh7zU3nXj46rOXIJI7r44qaxjs8yy9I9dOj1MyBoX"
|
||||
},
|
||||
"spec": {
|
||||
"jsonData": null,
|
||||
"title": "Hello testdata"
|
||||
}
|
||||
}
|
||||
Generated
+2
-2
@@ -875,7 +875,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
|
||||
ldapImpl := service12.ProvideService(cfg, featureToggles, ssosettingsimplService)
|
||||
apiService := api4.ProvideService(cfg, routeRegisterImpl, accessControl, userService, authinfoimplService, ossGroups, identitySynchronizer, orgService, ldapImpl, userAuthTokenService, bundleregistryService)
|
||||
dashboardActivityChannel := live.ProvideDashboardActivityChannel(grafanaLive)
|
||||
dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService, libraryElementService, publicDashboardServiceImpl, serviceImpl, dashboardActivityChannel)
|
||||
dashboardsAPIBuilder := dashboard.RegisterAPIService(featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService, libraryElementService, publicDashboardServiceImpl, serviceImpl, dashboardActivityChannel, configProvider)
|
||||
dataSourceAPIBuilder, err := datasource.RegisterAPIService(featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, accessControl, registerer, sourcesService)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1537,7 +1537,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
|
||||
ldapImpl := service12.ProvideService(cfg, featureToggles, ssosettingsimplService)
|
||||
apiService := api4.ProvideService(cfg, routeRegisterImpl, accessControl, userService, authinfoimplService, ossGroups, identitySynchronizer, orgService, ldapImpl, userAuthTokenService, bundleregistryService)
|
||||
dashboardActivityChannel := live.ProvideDashboardActivityChannel(grafanaLive)
|
||||
dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService, libraryElementService, publicDashboardServiceImpl, serviceImpl, dashboardActivityChannel)
|
||||
dashboardsAPIBuilder := dashboard.RegisterAPIService(featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService, libraryElementService, publicDashboardServiceImpl, serviceImpl, dashboardActivityChannel, configProvider)
|
||||
dataSourceAPIBuilder, err := datasource.RegisterAPIService(featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, accessControl, registerer, sourcesService)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -15,6 +15,8 @@ var _ authorizer.Authorizer = &roleAuthorizer{}
|
||||
|
||||
var orgRoleNoneAsViewerAPIGroups = []string{
|
||||
"productactivation.ext.grafana.com",
|
||||
// playlist can be removed after this issue is resolved: https://github.com/grafana/grafana/issues/115712
|
||||
"playlist.grafana.app",
|
||||
}
|
||||
|
||||
type roleAuthorizer struct{}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -20,9 +20,10 @@ const (
|
||||
|
||||
// Typed errors
|
||||
var (
|
||||
ErrUserTokenNotFound = errors.New("user token not found")
|
||||
ErrInvalidSessionToken = usertoken.ErrInvalidSessionToken
|
||||
ErrExternalSessionNotFound = errors.New("external session not found")
|
||||
ErrUserTokenNotFound = errors.New("user token not found")
|
||||
ErrInvalidSessionToken = usertoken.ErrInvalidSessionToken
|
||||
ErrExternalSessionNotFound = errors.New("external session not found")
|
||||
ErrExternalSessionTokenNotFound = errors.New("session token was nil")
|
||||
)
|
||||
|
||||
type (
|
||||
|
||||
@@ -572,13 +572,6 @@ var (
|
||||
FrontendOnly: false, // The restore backend feature changes behavior based on this flag
|
||||
Owner: grafanaDashboardsSquad,
|
||||
},
|
||||
{
|
||||
Name: "kubernetesDashboardsV2",
|
||||
Description: "Use the v2 kubernetes API in the frontend for dashboards",
|
||||
Stage: FeatureStageExperimental,
|
||||
FrontendOnly: false,
|
||||
Owner: grafanaDashboardsSquad,
|
||||
},
|
||||
{
|
||||
Name: "dashboardUndoRedo",
|
||||
Description: "Enables undo/redo in dynamic dashboards",
|
||||
@@ -688,6 +681,14 @@ var (
|
||||
HideFromDocs: true,
|
||||
RequiresRestart: true,
|
||||
},
|
||||
{
|
||||
Name: "auditLoggingAppPlatform",
|
||||
Description: "Enable audit logging with Kubernetes under app platform",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaOperatorExperienceSquad,
|
||||
HideFromDocs: true,
|
||||
RequiresRestart: true,
|
||||
},
|
||||
{
|
||||
Name: "secretsManagementAppPlatform",
|
||||
Description: "Enable the secrets management API and services under app platform",
|
||||
|
||||
Generated
+1
-1
@@ -79,7 +79,6 @@ dashboardSceneForViewers,GA,@grafana/dashboards-squad,false,false,true
|
||||
dashboardSceneSolo,GA,@grafana/dashboards-squad,false,false,true
|
||||
dashboardScene,GA,@grafana/dashboards-squad,false,false,true
|
||||
dashboardNewLayouts,experimental,@grafana/dashboards-squad,false,false,false
|
||||
kubernetesDashboardsV2,experimental,@grafana/dashboards-squad,false,false,false
|
||||
dashboardUndoRedo,experimental,@grafana/dashboards-squad,false,false,true
|
||||
unlimitedLayoutsNesting,experimental,@grafana/dashboards-squad,false,false,true
|
||||
drilldownRecommendations,experimental,@grafana/dashboards-squad,false,false,true
|
||||
@@ -95,6 +94,7 @@ kubernetesFeatureToggles,experimental,@grafana/grafana-operator-experience-squad
|
||||
cloudRBACRoles,preview,@grafana/identity-access-team,false,true,false
|
||||
alertingQueryOptimization,GA,@grafana/alerting-squad,false,false,false
|
||||
jitterAlertRulesWithinGroups,preview,@grafana/alerting-squad,false,true,false
|
||||
auditLoggingAppPlatform,experimental,@grafana/grafana-operator-experience-squad,false,true,false
|
||||
secretsManagementAppPlatform,experimental,@grafana/grafana-operator-experience-squad,false,false,false
|
||||
secretsManagementAppPlatformUI,experimental,@grafana/grafana-operator-experience-squad,false,false,false
|
||||
alertingSaveStatePeriodic,privatePreview,@grafana/alerting-squad,false,false,false
|
||||
|
||||
|
Generated
+4
-4
@@ -259,10 +259,6 @@ const (
|
||||
// Enables experimental new dashboard layouts
|
||||
FlagDashboardNewLayouts = "dashboardNewLayouts"
|
||||
|
||||
// FlagKubernetesDashboardsV2
|
||||
// Use the v2 kubernetes API in the frontend for dashboards
|
||||
FlagKubernetesDashboardsV2 = "kubernetesDashboardsV2"
|
||||
|
||||
// FlagPdfTables
|
||||
// Enables generating table data as PDF in reporting
|
||||
FlagPdfTables = "pdfTables"
|
||||
@@ -279,6 +275,10 @@ const (
|
||||
// Distributes alert rule evaluations more evenly over time, including spreading out rules within the same group. Disables sequential evaluation if enabled.
|
||||
FlagJitterAlertRulesWithinGroups = "jitterAlertRulesWithinGroups"
|
||||
|
||||
// FlagAuditLoggingAppPlatform
|
||||
// Enable audit logging with Kubernetes under app platform
|
||||
FlagAuditLoggingAppPlatform = "auditLoggingAppPlatform"
|
||||
|
||||
// FlagSecretsManagementAppPlatform
|
||||
// Enable the secrets management API and services under app platform
|
||||
FlagSecretsManagementAppPlatform = "secretsManagementAppPlatform"
|
||||
|
||||
+17
-2
@@ -658,6 +658,20 @@
|
||||
"frontend": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "auditLoggingAppPlatform",
|
||||
"resourceVersion": "1767013056996",
|
||||
"creationTimestamp": "2025-12-29T12:57:36Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enable audit logging with Kubernetes under app platform",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/grafana-operator-experience-squad",
|
||||
"requiresRestart": true,
|
||||
"hideFromDocs": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "authZGRPCServer",
|
||||
@@ -2003,8 +2017,9 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "kubernetesDashboardsV2",
|
||||
"resourceVersion": "1764664939750",
|
||||
"creationTimestamp": "2025-12-02T08:42:19Z"
|
||||
"resourceVersion": "1764236054307",
|
||||
"creationTimestamp": "2025-11-27T09:34:14Z",
|
||||
"deletionTimestamp": "2025-12-05T13:43:57Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Use the v2 kubernetes API in the frontend for dashboards",
|
||||
|
||||
@@ -660,6 +660,10 @@ func (o *Service) getExternalSession(ctx context.Context, usr identity.Requester
|
||||
return externalSessions[0], nil
|
||||
}
|
||||
|
||||
if sessionToken == nil {
|
||||
return nil, auth.ErrExternalSessionTokenNotFound
|
||||
}
|
||||
|
||||
// For regular users, we use the session token ID to fetch the external session
|
||||
return o.sessionService.GetExternalSession(ctx, sessionToken.ExternalSessionId)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -2169,6 +2169,43 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/apis/dashboard.grafana.app/v0alpha1/namespaces/{namespace}/snapshots/settings": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Snapshot"
|
||||
],
|
||||
"description": "Get Snapshot sharing settings",
|
||||
"operationId": "getSnapshotSettings",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "namespace",
|
||||
"in": "path",
|
||||
"description": "workspace",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": "default"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {},
|
||||
"example": "{\"snapshotsEnabled\":true,\"externalSnapshotURL\":\"https://externalurl.com\",\"externalSnapshotName\":\"external\",\"externalEnabled\":true}"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-grafana-action": "get",
|
||||
"x-kubernetes-group-version-kind": {
|
||||
"group": "dashboard.grafana.app",
|
||||
"version": "v0alpha1",
|
||||
"kind": "SnapshotSharingOptions"
|
||||
}
|
||||
}
|
||||
},
|
||||
"/apis/dashboard.grafana.app/v0alpha1/namespaces/{namespace}/snapshots/{name}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
||||
+466
-22
@@ -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": [
|
||||
|
||||
@@ -426,6 +426,45 @@ func doPlaylistTests(t *testing.T, helper *apis.K8sTestHelper) *apis.K8sTestHelp
|
||||
require.Equal(t, metav1.StatusReasonForbidden, rsp.Status.Reason)
|
||||
})
|
||||
|
||||
t.Run("Check CRUD operations with None role", func(t *testing.T) {
|
||||
// Create a playlist with admin user
|
||||
clientAdmin := helper.GetResourceClient(apis.ResourceClientArgs{
|
||||
User: helper.Org1.Admin,
|
||||
GVR: gvr,
|
||||
})
|
||||
created, err := clientAdmin.Resource.Create(context.Background(),
|
||||
helper.LoadYAMLOrJSONFile("testdata/playlist-generate.yaml"),
|
||||
metav1.CreateOptions{},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
clientNone := helper.GetResourceClient(apis.ResourceClientArgs{
|
||||
User: helper.Org1.None,
|
||||
GVR: gvr,
|
||||
})
|
||||
|
||||
// Now check if None user can perform a Get to start a playlist
|
||||
_, err = clientNone.Resource.Get(context.Background(), created.GetName(), metav1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
// None role can get but can not create edit or delete a playlist
|
||||
_, err = clientNone.Resource.Create(context.Background(),
|
||||
helper.LoadYAMLOrJSONFile("testdata/playlist-generate.yaml"),
|
||||
metav1.CreateOptions{},
|
||||
)
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = clientNone.Resource.Update(context.Background(), created, metav1.UpdateOptions{})
|
||||
require.Error(t, err)
|
||||
|
||||
err = clientNone.Resource.Delete(context.Background(), created.GetName(), metav1.DeleteOptions{})
|
||||
require.Error(t, err)
|
||||
|
||||
// delete created resource
|
||||
err = clientAdmin.Resource.Delete(context.Background(), created.GetName(), metav1.DeleteOptions{})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("Check k8s client-go List from different org users", func(t *testing.T) {
|
||||
// Check Org1 Viewer
|
||||
client := helper.GetResourceClient(apis.ResourceClientArgs{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+72
@@ -60,4 +60,76 @@ describe('LogRecordViewerByTimestamp', () => {
|
||||
expect(within(errorRows[1]).getByText(/Error message:/)).toBeInTheDocument();
|
||||
expect(within(errorRows[1]).getByText(/explicit message/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('Numeric Value Formatting', () => {
|
||||
it('should format numeric values correctly in AlertInstanceValues', () => {
|
||||
const records: LogRecord[] = [
|
||||
{
|
||||
timestamp: 1681739580000,
|
||||
line: {
|
||||
current: 'Alerting',
|
||||
previous: 'Pending',
|
||||
labels: {},
|
||||
values: {
|
||||
cpu_usage: 42.987654321,
|
||||
memory_mb: 1234567.89,
|
||||
disk_io: 0.001234,
|
||||
request_count: 10000,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
render(<LogRecordViewerByTimestamp records={records} commonLabels={[]} />);
|
||||
|
||||
expect(screen.getByText(/cpu_usage/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/4\.299e\+1/i)).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/memory_mb/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1\.235e\+6/i)).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/disk_io/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1\.234e-3/i)).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/request_count/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/10000/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should format various numeric ranges correctly', () => {
|
||||
const records: LogRecord[] = [
|
||||
{
|
||||
timestamp: 1681739580000,
|
||||
line: {
|
||||
current: 'Alerting',
|
||||
previous: 'Pending',
|
||||
labels: {},
|
||||
values: {
|
||||
small: 0.001,
|
||||
normal: 42.5,
|
||||
large: 123456,
|
||||
boundary_low: 0.01,
|
||||
boundary_high: 10000,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
render(<LogRecordViewerByTimestamp records={records} commonLabels={[]} />);
|
||||
|
||||
expect(screen.getByText(/small/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1\.000e-3/i)).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/normal/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/42\.5/)).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/large/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1\.235e\+5/i)).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/boundary_low/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/0\.01/)).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/boundary_high/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/10000/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+2
-1
@@ -13,6 +13,7 @@ import { AlertStateTag } from '../AlertStateTag';
|
||||
|
||||
import { ErrorMessageRow } from './ErrorMessageRow';
|
||||
import { LogRecord, omitLabels } from './common';
|
||||
import { formatNumericValue } from './numberFormatter';
|
||||
|
||||
type LogRecordViewerProps = {
|
||||
records: LogRecord[];
|
||||
@@ -182,7 +183,7 @@ const AlertInstanceValues = memo(({ record }: { record: Record<string, number> }
|
||||
return (
|
||||
<>
|
||||
{values.map(([key, value]) => (
|
||||
<AlertLabel key={key} labelKey={key} value={String(value)} />
|
||||
<AlertLabel key={key} labelKey={key} value={formatNumericValue(value)} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
import { formatNumericValue } from './numberFormatter';
|
||||
|
||||
describe('formatNumericValue', () => {
|
||||
describe('Zero and special values', () => {
|
||||
it('should format zero correctly', () => {
|
||||
expect(formatNumericValue(0)).toBe('0');
|
||||
expect(formatNumericValue(-0)).toBe('0');
|
||||
});
|
||||
|
||||
it('should handle NaN', () => {
|
||||
expect(formatNumericValue(NaN)).toBe('NaN');
|
||||
});
|
||||
|
||||
it('should handle Infinity', () => {
|
||||
expect(formatNumericValue(Infinity)).toBe('Infinity');
|
||||
expect(formatNumericValue(-Infinity)).toBe('-Infinity');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Very small numbers (scientific notation)', () => {
|
||||
it('should use scientific notation for values less than 1e-2', () => {
|
||||
const result1 = formatNumericValue(1e-3);
|
||||
expect(result1).toMatch(/^1\.000e-3$/i);
|
||||
|
||||
const result2 = formatNumericValue(0.001);
|
||||
expect(result2).toMatch(/^1\.000e-3$/i);
|
||||
|
||||
const result3 = formatNumericValue(0.009);
|
||||
expect(result3).toMatch(/^9\.000e-3$/i);
|
||||
});
|
||||
|
||||
it('should use scientific notation for values just below 1e-2', () => {
|
||||
const result = formatNumericValue(0.00999);
|
||||
expect(result).toMatch(/^9\.990e-3$/i);
|
||||
});
|
||||
|
||||
it('should format the example from requirements correctly', () => {
|
||||
// 1.4153928131348452 has > 4 decimal places, so should use scientific notation
|
||||
const result = formatNumericValue(1.4153928131348452);
|
||||
expect(result).toMatch(/^1\.415e\+0$/i);
|
||||
});
|
||||
|
||||
it('should handle negative very small numbers', () => {
|
||||
const result = formatNumericValue(-1e-3);
|
||||
expect(result).toMatch(/^-1\.000e-3$/i);
|
||||
|
||||
const result2 = formatNumericValue(-0.001);
|
||||
expect(result2).toMatch(/^-1\.000e-3$/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Human-readable range (standard notation)', () => {
|
||||
it('should use standard notation for boundary value 1e-2', () => {
|
||||
expect(formatNumericValue(0.01)).toBe('0.01');
|
||||
});
|
||||
|
||||
it('should use standard notation for values in readable range', () => {
|
||||
expect(formatNumericValue(0.1)).toBe('0.1');
|
||||
expect(formatNumericValue(1)).toBe('1');
|
||||
expect(formatNumericValue(1.234)).toBe('1.234');
|
||||
expect(formatNumericValue(42.5)).toBe('42.5');
|
||||
});
|
||||
|
||||
it('should limit to 4 decimal places without rounding integer parts', () => {
|
||||
expect(formatNumericValue(123.456)).toBe('123.456');
|
||||
expect(formatNumericValue(1234.567)).toBe('1234.567');
|
||||
expect(formatNumericValue(9999.9)).toBe('9999.9');
|
||||
expect(formatNumericValue(9999.1234)).toBe('9999.1234');
|
||||
});
|
||||
|
||||
it('should use scientific notation for numbers with more than 4 decimal places', () => {
|
||||
// Numbers with > 4 decimals should use scientific notation even in readable range
|
||||
const result1 = formatNumericValue(123.456789);
|
||||
expect(result1).toMatch(/^1\.235e\+2$/i);
|
||||
|
||||
const result2 = formatNumericValue(1.23456789);
|
||||
expect(result2).toMatch(/^1\.235e\+0$/i);
|
||||
|
||||
const result3 = formatNumericValue(42.987654321);
|
||||
expect(result3).toMatch(/^4\.299e\+1$/i);
|
||||
});
|
||||
|
||||
it('should use standard notation for boundary value 1e4', () => {
|
||||
expect(formatNumericValue(10000)).toBe('10000');
|
||||
});
|
||||
|
||||
it('should handle negative numbers in readable range', () => {
|
||||
expect(formatNumericValue(-0.1)).toBe('-0.1');
|
||||
expect(formatNumericValue(-123.456)).toBe('-123.456');
|
||||
expect(formatNumericValue(-9999.9)).toBe('-9999.9');
|
||||
});
|
||||
|
||||
it('should use scientific notation for negative numbers with excessive precision', () => {
|
||||
const result = formatNumericValue(-42.987654321);
|
||||
expect(result).toMatch(/^-4\.299e\+1$/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Very large numbers (scientific notation)', () => {
|
||||
it('should use scientific notation for values greater than 1e4', () => {
|
||||
const result1 = formatNumericValue(10001);
|
||||
expect(result1).toMatch(/^1\.000e\+4$/i);
|
||||
|
||||
const result2 = formatNumericValue(123456);
|
||||
expect(result2).toMatch(/^1\.235e\+5$/i);
|
||||
});
|
||||
|
||||
it('should handle negative very large numbers', () => {
|
||||
const result = formatNumericValue(-1e5);
|
||||
expect(result).toMatch(/^-1\.000e\+5$/i);
|
||||
|
||||
const result2 = formatNumericValue(-123456);
|
||||
expect(result2).toMatch(/^-1\.235e\+5$/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle numbers exactly at boundaries', () => {
|
||||
expect(formatNumericValue(0.01)).toBe('0.01');
|
||||
|
||||
const justBelow = formatNumericValue(0.009999);
|
||||
expect(justBelow).toMatch(/^9\.999e-3$/i);
|
||||
|
||||
expect(formatNumericValue(10000)).toBe('10000');
|
||||
|
||||
const justAbove = formatNumericValue(10001);
|
||||
expect(justAbove).toMatch(/^1\.000e\+4$/i);
|
||||
});
|
||||
|
||||
it('should use scientific notation for very precise decimals with > 4 decimal places', () => {
|
||||
expect(formatNumericValue(1.23456789)).toMatch(/^1\.235e\+0$/i);
|
||||
expect(formatNumericValue(123.456789)).toMatch(/^1\.235e\+2$/i);
|
||||
expect(formatNumericValue(0.123456789)).toMatch(/^1\.235e-1$/i);
|
||||
});
|
||||
|
||||
it('should use standard notation for numbers with exactly 4 or fewer decimal places', () => {
|
||||
expect(formatNumericValue(1.2345)).toBe('1.2345');
|
||||
expect(formatNumericValue(0.1234)).toBe('0.1234');
|
||||
expect(formatNumericValue(123.4567)).toBe('123.4567');
|
||||
});
|
||||
});
|
||||
|
||||
describe('countDecimalPlaces edge cases', () => {
|
||||
it('should handle numbers that toString() would convert to scientific notation', () => {
|
||||
const result = formatNumericValue(1e-10);
|
||||
expect(result).toMatch(/^1\.000e-10$/i);
|
||||
|
||||
const result2 = formatNumericValue(1e10);
|
||||
expect(result2).toMatch(/^1\.000e\+10$/i);
|
||||
});
|
||||
|
||||
it('should correctly count decimals for numbers with trailing zeros', () => {
|
||||
expect(formatNumericValue(1.234)).toBe('1.234');
|
||||
expect(formatNumericValue(1.2)).toBe('1.2');
|
||||
expect(formatNumericValue(1.0)).toBe('1');
|
||||
});
|
||||
|
||||
it('should handle boundary values correctly', () => {
|
||||
expect(formatNumericValue(0.01)).toBe('0.01');
|
||||
expect(formatNumericValue(10000)).toBe('10000');
|
||||
|
||||
expect(formatNumericValue(0.01001)).toMatch(/^1\.001e-2$/i);
|
||||
expect(formatNumericValue(9999.1234)).toBe('9999.1234');
|
||||
expect(formatNumericValue(9999.12345)).toMatch(/^9\.999e\+3$/i);
|
||||
});
|
||||
|
||||
it('should handle numbers in readable range that have many decimals', () => {
|
||||
expect(formatNumericValue(1.4153928131348452)).toMatch(/^1\.415e\+0$/i);
|
||||
expect(formatNumericValue(42.987654321)).toMatch(/^4\.299e\+1$/i);
|
||||
expect(formatNumericValue(123.456789)).toMatch(/^1\.235e\+2$/i);
|
||||
});
|
||||
});
|
||||
});
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
const SCIENTIFIC_NOTATION_THRESHOLD_SMALL = 1e-2;
|
||||
const SCIENTIFIC_NOTATION_THRESHOLD_LARGE = 1e4;
|
||||
const MAX_DECIMAL_PLACES = 4;
|
||||
const EXPONENTIAL_DECIMALS = 3; // 4 significant digits = 1 digit + 3 decimals
|
||||
|
||||
const readableRangeFormatter = new Intl.NumberFormat(undefined, {
|
||||
maximumFractionDigits: MAX_DECIMAL_PLACES,
|
||||
useGrouping: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* Counts the number of decimal places in a number.
|
||||
* Only processes numbers in readable range (1e-2 to 1e4) to avoid
|
||||
* toString() scientific notation issues for very large/small numbers.
|
||||
*
|
||||
* Uses toFixed(10) to ensure standard notation representation.
|
||||
* 10 decimal places is sufficient to detect if a number has > 4 decimal places.
|
||||
*/
|
||||
function countDecimalPlaces(value: number): number {
|
||||
if (Number.isInteger(value)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const absValue = Math.abs(value);
|
||||
|
||||
// Only count decimals for numbers in readable range
|
||||
if (absValue < SCIENTIFIC_NOTATION_THRESHOLD_SMALL || absValue > SCIENTIFIC_NOTATION_THRESHOLD_LARGE) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const str = value.toFixed(10);
|
||||
const decimalIndex = str.indexOf('.');
|
||||
|
||||
if (decimalIndex === -1) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Count decimal places, removing trailing zeros
|
||||
const decimalPart = str.substring(decimalIndex + 1).replace(/0+$/, '');
|
||||
return decimalPart.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a numeric value for display in alert rule history.
|
||||
* - For values in human-readable range (1e-2 to 1e4) with ≤ 4 decimal places: shows up to 4 decimal places
|
||||
* - For very small values (< 1e-2): uses scientific notation with 4 significant digits
|
||||
* - For very large values (> 1e4): uses scientific notation with 4 significant digits
|
||||
* - For numbers with > 4 decimal places: uses scientific notation with 4 significant digits
|
||||
*
|
||||
* @param value - The number to format
|
||||
* @returns A formatted string representation of the number
|
||||
*/
|
||||
export function formatNumericValue(value: number): string {
|
||||
if (!Number.isFinite(value)) {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
if (value === 0) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
const absValue = Math.abs(value);
|
||||
|
||||
if (absValue < SCIENTIFIC_NOTATION_THRESHOLD_SMALL || absValue > SCIENTIFIC_NOTATION_THRESHOLD_LARGE) {
|
||||
return value.toExponential(EXPONENTIAL_DECIMALS);
|
||||
}
|
||||
|
||||
const decimalPlaces = countDecimalPlaces(value);
|
||||
|
||||
if (decimalPlaces > MAX_DECIMAL_PLACES) {
|
||||
return value.toExponential(EXPONENTIAL_DECIMALS);
|
||||
}
|
||||
|
||||
return readableRangeFormatter.format(value);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user