Compare commits

...
Author SHA1 Message Date
Isabella Siu d18e0d518a Cloudwatch: make cloudwatchBatchQueries GA 2025-12-30 15:40:14 -05:00
Paul Marbach 44e6ea3d8b Gauge: Fix issues found during bug bash (#115740)
* fix warning for VizRepeater styles

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

* Gauge: E2E tests

* add test for sparklines

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

* remove unused CODEOWNER declaration

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

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

* more test cases

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

* Action items width

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

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

* missed save

* prettier?

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

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

---------

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

* Update comment

---------

Co-authored-by: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com>
2025-12-30 07:35:43 -07:00
Matheus Macabu 75b2c905cd Auditing: Move sinkable/logger interfaces and add global default logger implementation (#115743)
* Auditing: Move sinkable and logger interfaces

* Auditing: Add global default logger implementation

* Chore: Fix enterprise imports
2025-12-30 14:05:23 +01:00
Ezequiel Victorero 45fc95cfc9 Snapshots: Use settings MT service (#115541) 2025-12-30 09:54:20 -03:00
Ezequiel Victorero 9c3cdd4814 Playlists: Support get with None role (#115713) 2025-12-30 08:46:43 -03:00
Marc M. 2dad8b7b5b DynamicDashboards: Add button to feedback form (#114980) 2025-12-30 10:54:00 +01:00
Matheus Macabu 9a831ab4e1 Auditing: Set default policy rule level for create to req+resp (#115727)
Auditing: Set default policy rule level to req+resp
2025-12-30 09:47:00 +01:00
Dominik ProkopandHaris Rozajac 759035a465 Remove kubernetesDashboardsV2 feature toggle (#114912)
Co-authored-by: Haris Rozajac <haris.rozajac12@gmail.com>
2025-12-30 09:45:33 +01:00
Todd TreeceandRyan McKinley 6e155523a3 Plugins App: Add basic README (#115507)
* Plugins App: Add basic README

* prettier:write

---------

Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
2025-12-30 08:14:06 +00:00
Lewis John McGibbney 5c0ee2d746 Documentation: Fix JSON file export relative link (#115650) 2025-12-30 10:46:57 +03:00
ismail simsek 0c6b97bee2 Prometheus: Fallback to fetch metric names when metadata returns nothing (#115369)
fallback to fetch metric names when metadata returns nothing
2025-12-29 19:11:44 +01:00
linoman 4c79775b57 auth: Protect from empty session token panic (#115728)
* Protect from empty session token panic

* Rename returned error
2025-12-29 17:19:49 +01:00
Matheus Macabu e088c9aac9 Auditing: Add feature flag (#115726) 2025-12-29 16:28:29 +01:00
Rodrigo Vasconcelos de Barros 7182511bcf Alerting: Auto-format numeric values in Alert Rule History (#115708)
* Add helper function to format numeric values in alert rule history

* Use formatting function in LogRecordViewer

* Refactor numerical formatting logic

* Handle edge cases when counting decimal places

* Cleanup tests and numberFormatter code
2025-12-29 10:18:42 -05:00
Paul Marbach 3023a72175 E2E: Use updated setVisualization from grafana/e2e (#115640) 2025-12-29 10:10:04 -05:00
Ivan Ortega AlbaandHaris Rozajac 30ad61e0e9 Dashboards: Fix adhoc filter click when panel has no panel-level datasource (#115576)
* V2: Panel datasource is defined only for mixed ds

* if getDatasourceFromQueryRunner only returns ds.type, resolve to full ds ref throgh ds service

---------

Co-authored-by: Haris Rozajac <haris.rozajac12@gmail.com>
2025-12-29 10:29:50 +01:00
Oscar Kilhed 0b58cd3900 Dashboard: Remove BOMs from links during conversion (#115689)
* Dashboard: Add test case for BOM characters in link URLs

This test demonstrates the issue where BOM (Byte Order Mark) characters
in dashboard link URLs cause CUE validation errors during v1 to v2
conversion ('illegal byte order mark').

The test input contains BOMs in various URL locations:
- Dashboard links
- Panel data links
- Field config override links
- Options dataLinks
- Field config default links

* Dashboard: Strip BOM characters from URLs during v1 to v2 conversion

BOM (Byte Order Mark) characters in dashboard link URLs cause CUE
validation errors ('illegal byte order mark') when opening v2 dashboards.

This fix strips BOMs from all URL fields during conversion:
- Dashboard links
- Panel data links
- Field config override links
- Options dataLinks
- Field config default links

The stripBOM helper recursively processes nested structures to ensure
all string values have BOMs removed.

* Dashboard: Strip BOM characters in frontend v2 conversion

Add stripBOMs parameter to sortedDeepCloneWithoutNulls utility to remove
Byte Order Mark (U+FEFF) characters from all strings when serializing
dashboards to v2 format.

This prevents CUE validation errors ('illegal byte order mark') that occur
when BOMs are present in any string field. BOMs can be introduced through
copy/paste from certain editors or text sources.

Applied at the final serialization step so it catches BOMs from:
- Existing v1 dashboards being converted
- New data entered during dashboard editing
2025-12-29 09:53:45 +01:00
Matheus Macabu 4ba2fe6cce Auditing: Add Event struct to map audit logs into (#115509) 2025-12-29 09:31:58 +01:00
134 changed files with 2482 additions and 460 deletions
-1
View File
@@ -501,7 +501,6 @@ i18next.config.ts @grafana/grafana-frontend-platform
/e2e-playwright/various-suite/filter-annotations.spec.ts @grafana/dashboards-squad
/e2e-playwright/various-suite/frontend-sandbox-app.spec.ts @grafana/plugins-platform-frontend
/e2e-playwright/various-suite/frontend-sandbox-datasource.spec.ts @grafana/plugins-platform-frontend
/e2e-playwright/various-suite/gauge.spec.ts @grafana/dataviz-squad
/e2e-playwright/various-suite/grafana-datasource-random-walk.spec.ts @grafana/grafana-frontend-platform
/e2e-playwright/various-suite/graph-auto-migrate.spec.ts @grafana/dataviz-squad
/e2e-playwright/various-suite/inspect-drawer.spec.ts @grafana/dashboards-squad
@@ -180,12 +180,15 @@ func countAnnotationsV0V1(spec map[string]interface{}) int {
return 0
}
annotationList, ok := annotations["list"].([]interface{})
if !ok {
return 0
// Handle both []interface{} (from JSON unmarshaling) and []map[string]interface{} (from programmatic creation)
if annotationList, ok := annotations["list"].([]interface{}); ok {
return len(annotationList)
}
if annotationList, ok := annotations["list"].([]map[string]interface{}); ok {
return len(annotationList)
}
return len(annotationList)
return 0
}
// countLinksV0V1 counts dashboard links in v0alpha1 or v1beta1 dashboard spec
@@ -194,12 +197,15 @@ func countLinksV0V1(spec map[string]interface{}) int {
return 0
}
links, ok := spec["links"].([]interface{})
if !ok {
return 0
// Handle both []interface{} (from JSON unmarshaling) and []map[string]interface{} (from programmatic creation)
if links, ok := spec["links"].([]interface{}); ok {
return len(links)
}
if links, ok := spec["links"].([]map[string]interface{}); ok {
return len(links)
}
return len(links)
return 0
}
// countVariablesV0V1 counts template variables in v0alpha1 or v1beta1 dashboard spec
@@ -213,12 +219,15 @@ func countVariablesV0V1(spec map[string]interface{}) int {
return 0
}
variableList, ok := templating["list"].([]interface{})
if !ok {
return 0
// Handle both []interface{} (from JSON unmarshaling) and []map[string]interface{} (from programmatic creation)
if variableList, ok := templating["list"].([]interface{}); ok {
return len(variableList)
}
if variableList, ok := templating["list"].([]map[string]interface{}); ok {
return len(variableList)
}
return len(variableList)
return 0
}
// collectStatsV0V1 collects statistics from v0alpha1 or v1beta1 dashboard
@@ -0,0 +1,142 @@
{
"kind": "Dashboard",
"apiVersion": "dashboard.grafana.app/v1beta1",
"metadata": {
"name": "bom-in-links-test",
"namespace": "org-1",
"labels": {
"test": "bom-stripping"
}
},
"spec": {
"title": "BOM Stripping Test Dashboard",
"description": "Testing that BOM characters are stripped from URLs during conversion",
"schemaVersion": 42,
"tags": ["test", "bom"],
"editable": true,
"links": [
{
"title": "Dashboard link with BOM",
"type": "link",
"url": "http://example.com?var=${datasource}&other=value",
"targetBlank": true,
"icon": "external link"
}
],
"panels": [
{
"id": 1,
"type": "table",
"title": "Panel with BOM in field config override links",
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"fieldConfig": {
"defaults": {
"custom": {},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "green"},
{"color": "red", "value": 80}
]
}
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "server"
},
"properties": [
{
"id": "links",
"value": [
{
"title": "Override link with BOM",
"url": "http://localhost:3000/d/test?var-datacenter=${__data.fields[datacenter]}&var-server=${__value.raw}"
}
]
}
]
}
]
},
"links": [
{
"title": "Panel data link with BOM",
"url": "http://example.com/${__data.fields.cluster}&var=value",
"targetBlank": true
}
],
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "test-ds"
}
}
]
},
{
"id": 2,
"type": "timeseries",
"title": "Panel with BOM in options dataLinks",
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 0
},
"options": {
"legend": {
"showLegend": true,
"displayMode": "list",
"placement": "bottom"
},
"dataLinks": [
{
"title": "Options data link with BOM",
"url": "http://example.com?series=${__series.name}&time=${__value.time}",
"targetBlank": true
}
]
},
"fieldConfig": {
"defaults": {
"links": [
{
"title": "Field config default link with BOM",
"url": "http://example.com?field=${__field.name}&value=${__value.raw}",
"targetBlank": false
}
]
},
"overrides": []
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "test-ds"
}
}
]
}
],
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {
"refresh_intervals": ["5s", "10s", "30s", "1m", "5m"]
}
}
}
@@ -120,7 +120,7 @@
"value": [
{
"title": "filter",
"url": "http://localhost:3000/d/-Y-tnEDWk/templating-nested-template-variables?var-datacenter=${__data.fields[datacenter]}\u0026var-server=${__value.raw}"
"url": "http://localhost:3000/d/-Y-tnEDWk/templating-nested-template-variables?var-datacenter=${__data.fields[datacenter]}\u0026var-server=${__value.raw}"
}
]
}
@@ -124,7 +124,7 @@
"value": [
{
"title": "filter",
"url": "http://localhost:3000/d/-Y-tnEDWk/templating-nested-template-variables?var-datacenter=${__data.fields[datacenter]}\u0026var-server=${__value.raw}"
"url": "http://localhost:3000/d/-Y-tnEDWk/templating-nested-template-variables?var-datacenter=${__data.fields[datacenter]}\u0026var-server=${__value.raw}"
}
]
}
@@ -2051,4 +2051,4 @@
"storedVersion": "v0alpha1"
}
}
}
}
@@ -2691,4 +2691,4 @@
"storedVersion": "v0alpha1"
}
}
}
}
@@ -2764,4 +2764,4 @@
"storedVersion": "v0alpha1"
}
}
}
}
@@ -1173,4 +1173,4 @@
"storedVersion": "v0alpha1"
}
}
}
}
@@ -1618,4 +1618,4 @@
"storedVersion": "v0alpha1"
}
}
}
}
@@ -1670,4 +1670,4 @@
"storedVersion": "v0alpha1"
}
}
}
}
@@ -0,0 +1,161 @@
{
"kind": "Dashboard",
"apiVersion": "dashboard.grafana.app/v0alpha1",
"metadata": {
"name": "bom-in-links-test",
"namespace": "org-1",
"labels": {
"test": "bom-stripping"
}
},
"spec": {
"description": "Testing that BOM characters are stripped from URLs during conversion",
"editable": true,
"links": [
{
"icon": "external link",
"targetBlank": true,
"title": "Dashboard link with BOM",
"type": "link",
"url": "http://example.com?var=${datasource}\u0026other=value"
}
],
"panels": [
{
"fieldConfig": {
"defaults": {
"custom": {},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green"
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "server"
},
"properties": [
{
"id": "links",
"value": [
{
"title": "Override link with BOM",
"url": "http://localhost:3000/d/test?var-datacenter=${__data.fields[datacenter]}\u0026var-server=${__value.raw}"
}
]
}
]
}
]
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"id": 1,
"links": [
{
"targetBlank": true,
"title": "Panel data link with BOM",
"url": "http://example.com/${__data.fields.cluster}\u0026var=value"
}
],
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "test-ds"
},
"refId": "A"
}
],
"title": "Panel with BOM in field config override links",
"type": "table"
},
{
"fieldConfig": {
"defaults": {
"links": [
{
"targetBlank": false,
"title": "Field config default link with BOM",
"url": "http://example.com?field=${__field.name}\u0026value=${__value.raw}"
}
]
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 0
},
"id": 2,
"options": {
"dataLinks": [
{
"targetBlank": true,
"title": "Options data link with BOM",
"url": "http://example.com?series=${__series.name}\u0026time=${__value.time}"
}
],
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": true
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "test-ds"
},
"refId": "A"
}
],
"title": "Panel with BOM in options dataLinks",
"type": "timeseries"
}
],
"schemaVersion": 42,
"tags": [
"test",
"bom"
],
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {
"refresh_intervals": [
"5s",
"10s",
"30s",
"1m",
"5m"
]
},
"title": "BOM Stripping Test Dashboard"
},
"status": {
"conversion": {
"failed": false,
"storedVersion": "v1beta1"
}
}
}
@@ -0,0 +1,242 @@
{
"kind": "Dashboard",
"apiVersion": "dashboard.grafana.app/v2alpha1",
"metadata": {
"name": "bom-in-links-test",
"namespace": "org-1",
"labels": {
"test": "bom-stripping"
}
},
"spec": {
"annotations": [],
"cursorSync": "Off",
"description": "Testing that BOM characters are stripped from URLs during conversion",
"editable": true,
"elements": {
"panel-1": {
"kind": "Panel",
"spec": {
"id": 1,
"title": "Panel with BOM in field config override links",
"description": "",
"links": [
{
"title": "Panel data link with BOM",
"url": "http://example.com/${__data.fields.cluster}\u0026var=value",
"targetBlank": true
}
],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "prometheus",
"spec": {}
},
"datasource": {
"type": "prometheus",
"uid": "test-ds"
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "table",
"spec": {
"pluginVersion": "",
"options": {},
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{
"value": null,
"color": "green"
},
{
"value": 80,
"color": "red"
}
]
}
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "server"
},
"properties": [
{
"id": "links",
"value": [
{
"title": "Override link with BOM",
"url": "http://localhost:3000/d/test?var-datacenter=${__data.fields[datacenter]}\u0026var-server=${__value.raw}"
}
]
}
]
}
]
}
}
}
}
},
"panel-2": {
"kind": "Panel",
"spec": {
"id": 2,
"title": "Panel with BOM in options dataLinks",
"description": "",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "prometheus",
"spec": {}
},
"datasource": {
"type": "prometheus",
"uid": "test-ds"
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "timeseries",
"spec": {
"pluginVersion": "",
"options": {
"dataLinks": [
{
"targetBlank": true,
"title": "Options data link with BOM",
"url": "http://example.com?series=${__series.name}\u0026time=${__value.time}"
}
],
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": true
}
},
"fieldConfig": {
"defaults": {
"links": [
{
"targetBlank": false,
"title": "Field config default link with BOM",
"url": "http://example.com?field=${__field.name}\u0026value=${__value.raw}"
}
]
},
"overrides": []
}
}
}
}
}
},
"layout": {
"kind": "GridLayout",
"spec": {
"items": [
{
"kind": "GridLayoutItem",
"spec": {
"x": 0,
"y": 0,
"width": 12,
"height": 8,
"element": {
"kind": "ElementReference",
"name": "panel-1"
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
"x": 12,
"y": 0,
"width": 12,
"height": 8,
"element": {
"kind": "ElementReference",
"name": "panel-2"
}
}
}
]
}
},
"links": [
{
"title": "Dashboard link with BOM",
"type": "link",
"icon": "external link",
"tooltip": "",
"url": "http://example.com?var=${datasource}\u0026other=value",
"tags": [],
"asDropdown": false,
"targetBlank": true,
"includeVars": false,
"keepTime": false
}
],
"liveNow": false,
"preload": false,
"tags": [
"test",
"bom"
],
"timeSettings": {
"timezone": "browser",
"from": "now-6h",
"to": "now",
"autoRefresh": "",
"autoRefreshIntervals": [
"5s",
"10s",
"30s",
"1m",
"5m"
],
"hideTimepicker": false,
"fiscalYearStartMonth": 0
},
"title": "BOM Stripping Test Dashboard",
"variables": []
},
"status": {
"conversion": {
"failed": false,
"storedVersion": "v1beta1"
}
}
}
@@ -0,0 +1,246 @@
{
"kind": "Dashboard",
"apiVersion": "dashboard.grafana.app/v2beta1",
"metadata": {
"name": "bom-in-links-test",
"namespace": "org-1",
"labels": {
"test": "bom-stripping"
}
},
"spec": {
"annotations": [],
"cursorSync": "Off",
"description": "Testing that BOM characters are stripped from URLs during conversion",
"editable": true,
"elements": {
"panel-1": {
"kind": "Panel",
"spec": {
"id": 1,
"title": "Panel with BOM in field config override links",
"description": "",
"links": [
{
"title": "Panel data link with BOM",
"url": "http://example.com/${__data.fields.cluster}\u0026var=value",
"targetBlank": true
}
],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "DataQuery",
"group": "prometheus",
"version": "v0",
"datasource": {
"name": "test-ds"
},
"spec": {}
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "VizConfig",
"group": "table",
"version": "",
"spec": {
"options": {},
"fieldConfig": {
"defaults": {
"thresholds": {
"mode": "absolute",
"steps": [
{
"value": null,
"color": "green"
},
{
"value": 80,
"color": "red"
}
]
}
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "server"
},
"properties": [
{
"id": "links",
"value": [
{
"title": "Override link with BOM",
"url": "http://localhost:3000/d/test?var-datacenter=${__data.fields[datacenter]}\u0026var-server=${__value.raw}"
}
]
}
]
}
]
}
}
}
}
},
"panel-2": {
"kind": "Panel",
"spec": {
"id": 2,
"title": "Panel with BOM in options dataLinks",
"description": "",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "DataQuery",
"group": "prometheus",
"version": "v0",
"datasource": {
"name": "test-ds"
},
"spec": {}
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "VizConfig",
"group": "timeseries",
"version": "",
"spec": {
"options": {
"dataLinks": [
{
"targetBlank": true,
"title": "Options data link with BOM",
"url": "http://example.com?series=${__series.name}\u0026time=${__value.time}"
}
],
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": true
}
},
"fieldConfig": {
"defaults": {
"links": [
{
"targetBlank": false,
"title": "Field config default link with BOM",
"url": "http://example.com?field=${__field.name}\u0026value=${__value.raw}"
}
]
},
"overrides": []
}
}
}
}
}
},
"layout": {
"kind": "GridLayout",
"spec": {
"items": [
{
"kind": "GridLayoutItem",
"spec": {
"x": 0,
"y": 0,
"width": 12,
"height": 8,
"element": {
"kind": "ElementReference",
"name": "panel-1"
}
}
},
{
"kind": "GridLayoutItem",
"spec": {
"x": 12,
"y": 0,
"width": 12,
"height": 8,
"element": {
"kind": "ElementReference",
"name": "panel-2"
}
}
}
]
}
},
"links": [
{
"title": "Dashboard link with BOM",
"type": "link",
"icon": "external link",
"tooltip": "",
"url": "http://example.com?var=${datasource}\u0026other=value",
"tags": [],
"asDropdown": false,
"targetBlank": true,
"includeVars": false,
"keepTime": false
}
],
"liveNow": false,
"preload": false,
"tags": [
"test",
"bom"
],
"timeSettings": {
"timezone": "browser",
"from": "now-6h",
"to": "now",
"autoRefresh": "",
"autoRefreshIntervals": [
"5s",
"10s",
"30s",
"1m",
"5m"
],
"hideTimepicker": false,
"fiscalYearStartMonth": 0
},
"title": "BOM Stripping Test Dashboard",
"variables": []
},
"status": {
"conversion": {
"failed": false,
"storedVersion": "v1beta1"
}
}
}
@@ -229,6 +229,36 @@ func getBoolField(m map[string]interface{}, key string, defaultValue bool) bool
return defaultValue
}
// stripBOM removes Byte Order Mark (BOM) characters from a string.
// BOMs (U+FEFF) can be introduced through copy/paste from certain editors
// and cause CUE validation errors ("illegal byte order mark").
func stripBOM(s string) string {
return strings.ReplaceAll(s, "\ufeff", "")
}
// stripBOMFromInterface recursively strips BOM characters from all strings
// in an interface{} value (map, slice, or string).
func stripBOMFromInterface(v interface{}) interface{} {
switch val := v.(type) {
case string:
return stripBOM(val)
case map[string]interface{}:
result := make(map[string]interface{}, len(val))
for k, v := range val {
result[k] = stripBOMFromInterface(v)
}
return result
case []interface{}:
result := make([]interface{}, len(val))
for i, item := range val {
result[i] = stripBOMFromInterface(item)
}
return result
default:
return v
}
}
func getUnionField[T ~string](m map[string]interface{}, key string) *T {
if val, ok := m[key]; ok {
if str, ok := val.(string); ok && str != "" {
@@ -393,7 +423,8 @@ func transformLinks(dashboard map[string]interface{}) []dashv2alpha1.DashboardDa
// Optional field - only set if present
if url, exists := linkMap["url"]; exists {
if urlStr, ok := url.(string); ok {
dashLink.Url = &urlStr
cleanUrl := stripBOM(urlStr)
dashLink.Url = &cleanUrl
}
}
@@ -2239,7 +2270,7 @@ func transformDataLinks(panelMap map[string]interface{}) []dashv2alpha1.Dashboar
if linkMap, ok := link.(map[string]interface{}); ok {
dataLink := dashv2alpha1.DashboardDataLink{
Title: schemaversion.GetStringValue(linkMap, "title"),
Url: schemaversion.GetStringValue(linkMap, "url"),
Url: stripBOM(schemaversion.GetStringValue(linkMap, "url")),
}
if _, exists := linkMap["targetBlank"]; exists {
targetBlank := getBoolField(linkMap, "targetBlank", false)
@@ -2331,6 +2362,12 @@ func buildVizConfig(panelMap map[string]interface{}) dashv2alpha1.DashboardVizCo
}
}
// Strip BOMs from options (may contain dataLinks with URLs that have BOMs)
cleanedOptions := stripBOMFromInterface(options)
if cleanedMap, ok := cleanedOptions.(map[string]interface{}); ok {
options = cleanedMap
}
// Build field config by mapping each field individually
fieldConfigSource := extractFieldConfigSource(fieldConfig)
@@ -2474,9 +2511,14 @@ func extractFieldConfigDefaults(defaults map[string]interface{}) dashv2alpha1.Da
hasDefaults = true
}
// Extract array field
// Extract array field - strip BOMs from link URLs
if linksArray, ok := extractArrayField(defaults, "links"); ok {
fieldConfigDefaults.Links = linksArray
cleanedLinks := stripBOMFromInterface(linksArray)
if cleanedArray, ok := cleanedLinks.([]interface{}); ok {
fieldConfigDefaults.Links = cleanedArray
} else {
fieldConfigDefaults.Links = linksArray
}
hasDefaults = true
}
@@ -2762,9 +2804,11 @@ func extractFieldConfigOverrides(fieldConfig map[string]interface{}) []dashv2alp
fieldOverride.Properties = make([]dashv2alpha1.DashboardDynamicConfigValue, 0, len(propertiesArray))
for _, property := range propertiesArray {
if propertyMap, ok := property.(map[string]interface{}); ok {
// Strip BOMs from property values (may contain links with URLs)
cleanedValue := stripBOMFromInterface(propertyMap["value"])
fieldOverride.Properties = append(fieldOverride.Properties, dashv2alpha1.DashboardDynamicConfigValue{
Id: schemaversion.GetStringValue(propertyMap, "id"),
Value: propertyMap["value"],
Value: cleanedValue,
})
}
}
@@ -628,6 +628,20 @@
}
],
"title": "Only nulls and no user set min \u0026 max",
"transformations": [
{
"id": "convertFieldType",
"options": {
"conversions": [
{
"destinationType": "number",
"targetField": "A-series"
}
],
"fields": {}
}
}
],
"type": "gauge"
},
{
@@ -1179,4 +1193,4 @@
"title": "Panel Tests - Gauge",
"uid": "_5rDmaQiz",
"weekStart": ""
}
}
@@ -1760,6 +1760,22 @@
"startValue": 0
}
],
"transformations": [
{
"id": "calculateField",
"options": {
"mode": "unary",
"reduce": {
"reducer": "sum"
},
"replaceFields": true,
"unary": {
"operator": "round",
"fieldName": "A-series"
}
}
}
],
"title": "Active gateways",
"type": "radialbar"
},
@@ -1843,6 +1859,22 @@
"startValue": 0
}
],
"transformations": [
{
"id": "calculateField",
"options": {
"mode": "unary",
"reduce": {
"reducer": "sum"
},
"replaceFields": true,
"unary": {
"operator": "round",
"fieldName": "A-series"
}
}
}
],
"title": "Active pods",
"type": "radialbar"
},
@@ -485,6 +485,7 @@
},
"id": 12,
"options": {
"displayName": "My gauge",
"minVizHeight": 75,
"minVizWidth": 75,
"orientation": "auto",
+20
View File
@@ -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",
@@ -36,6 +36,7 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
| `awsAsyncQueryCaching` | Enable caching for async queries for Redshift and Athena. Requires that the datasource has caching and async query support enabled | Yes |
| `dashgpt` | Enable AI powered features in dashboards | Yes |
| `kubernetesDashboards` | Use the kubernetes API in the frontend for dashboards | Yes |
| `cloudWatchBatchQueries` | Runs CloudWatch metrics queries as separate batches | Yes |
| `annotationPermissionUpdate` | Change the way annotation permissions work by scoping them to folders and dashboards. | Yes |
| `dashboardSceneForViewers` | Enables dashboard rendering using Scenes for viewer roles | Yes |
| `dashboardSceneSolo` | Enables rendering dashboards using scenes for solo panels | Yes |
@@ -83,7 +84,6 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
| `enableDatagridEditing` | Enables the edit functionality in the datagrid panel |
| `reportingRetries` | Enables rendering retries for the reporting feature |
| `externalServiceAccounts` | Automatic service account and token setup for plugins |
| `cloudWatchBatchQueries` | Runs CloudWatch metrics queries as separate batches |
| `pdfTables` | Enables generating table data as PDF in reporting |
| `canvasPanelPanZoom` | Allow pan and zoom in canvas panel |
| `alertingSaveStateCompressed` | Enables the compressed protobuf-based alert state storage. Default is enabled. |
@@ -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 });
+101
View File
@@ -0,0 +1,101 @@
import { test, expect } from '@grafana/plugin-e2e';
// this test requires a larger viewport so all gauge panels load properly
test.use({
featureToggles: { newGauge: true },
viewport: { width: 1280, height: 3000 },
});
const OLD_GAUGES_DASHBOARD_UID = '_5rDmaQiz';
const NEW_GAUGES_DASHBOARD_UID = 'panel-tests-gauge-new';
test.describe(
'Gauge Panel',
{
tag: ['@panels', '@gauge'],
},
() => {
test('successfully migrates all gauge panels', async ({ gotoDashboardPage, selectors }) => {
const dashboardPage = await gotoDashboardPage({ uid: OLD_GAUGES_DASHBOARD_UID });
// check that gauges are rendered
const gaugeElements = dashboardPage.getByGrafanaSelector(
selectors.components.Panels.Visualization.Gauge.Container
);
await expect(gaugeElements).toHaveCount(16);
// check that no panel errors exist
const errorInfo = dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.headerCornerInfo('error'));
await expect(errorInfo).toBeHidden();
});
test('renders new gauge panels', async ({ gotoDashboardPage, selectors }) => {
// open Panel Tests - Gauge
const dashboardPage = await gotoDashboardPage({ uid: NEW_GAUGES_DASHBOARD_UID });
// check that gauges are rendered
const gaugeElements = dashboardPage.getByGrafanaSelector(
selectors.components.Panels.Visualization.Gauge.Container
);
await expect(gaugeElements).toHaveCount(32);
// check that no panel errors exist
const errorInfo = dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.headerCornerInfo('error'));
await expect(errorInfo).toBeHidden();
});
test('renders sparklines in gauge panels', async ({ gotoDashboardPage, page }) => {
await gotoDashboardPage({
uid: NEW_GAUGES_DASHBOARD_UID,
queryParams: new URLSearchParams({ editPanel: '11' }),
});
await expect(page.locator('.uplot')).toHaveCount(5);
});
test('"no data"', async ({ gotoDashboardPage, selectors }) => {
const dashboardPage = await gotoDashboardPage({
uid: NEW_GAUGES_DASHBOARD_UID,
queryParams: new URLSearchParams({ editPanel: '36' }),
});
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Visualization.Gauge.Container),
'that the gauge does not appear'
).toBeHidden();
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.PanelDataErrorMessage),
'that the empty text appears'
).toHaveText('No data');
// update the "No value" option and see if the panel updates
const noValueOption = dashboardPage
.getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.fieldLabel('Standard options No value'))
.locator('input');
await noValueOption.fill('My empty value');
await noValueOption.blur();
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Visualization.Gauge.Container),
'that the empty text shows up in an empty gauge'
).toHaveText('My empty value');
// test the "no numeric fields" message on the next panel
const dashboardPage2 = await gotoDashboardPage({
uid: NEW_GAUGES_DASHBOARD_UID,
queryParams: new URLSearchParams({ editPanel: '37' }),
});
await expect(
dashboardPage2.getByGrafanaSelector(selectors.components.Panels.Visualization.Gauge.Container),
'that the gauge does not appear'
).toBeHidden();
await expect(
dashboardPage2.getByGrafanaSelector(selectors.components.Panels.Panel.PanelDataErrorMessage),
'that the empty text appears'
).toHaveText('Data is missing a number field');
});
}
);
@@ -1,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);
};
@@ -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(
@@ -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();
});
}
);
@@ -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,
+5 -4
View File
@@ -305,6 +305,7 @@ export interface FeatureToggles {
queryServiceFromUI?: boolean;
/**
* Runs CloudWatch metrics queries as separate batches
* @default true
*/
cloudWatchBatchQueries?: boolean;
/**
@@ -356,10 +357,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 +418,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: {
@@ -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);
@@ -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>
);
@@ -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>
);
@@ -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(
() =>
@@ -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;
+88
View File
@@ -0,0 +1,88 @@
package auditing
import (
"encoding/json"
"time"
)
type Event struct {
// The namespace the action was performed in.
Namespace string `json:"namespace"`
// When it happened.
ObservedAt time.Time `json:"-"` // see MarshalJSON for why this is omitted
// Who/what performed the action.
SubjectName string `json:"subjectName"`
SubjectUID string `json:"subjectUID"`
// What was performed.
Verb string `json:"verb"`
// The object the action was performed on. For verbs like "list" this will be empty.
Object string `json:"object,omitempty"`
// API information.
APIGroup string `json:"apiGroup,omitempty"`
APIVersion string `json:"apiVersion,omitempty"`
Kind string `json:"kind,omitempty"`
// Outcome of the action.
Outcome EventOutcome `json:"outcome"`
// Extra fields to add more context to the event.
Extra map[string]string `json:"extra,omitempty"`
}
func (e Event) Time() time.Time {
return e.ObservedAt
}
func (e Event) MarshalJSON() ([]byte, error) {
type Alias Event
return json.Marshal(&struct {
FormattedTimestamp string `json:"observedAt"`
Alias
}{
FormattedTimestamp: e.ObservedAt.UTC().Format(time.RFC3339Nano),
Alias: (Alias)(e),
})
}
func (e Event) KVPairs() []any {
args := []any{
"audit", true,
"namespace", e.Namespace,
"observedAt", e.ObservedAt.UTC().Format(time.RFC3339Nano),
"subjectName", e.SubjectName,
"subjectUID", e.SubjectUID,
"verb", e.Verb,
"object", e.Object,
"apiGroup", e.APIGroup,
"apiVersion", e.APIVersion,
"kind", e.Kind,
"outcome", e.Outcome,
}
if len(e.Extra) > 0 {
extraArgs := make([]any, 0, len(e.Extra)*2)
for k, v := range e.Extra {
extraArgs = append(extraArgs, "extra_"+k, v)
}
args = append(args, extraArgs...)
}
return args
}
type EventOutcome string
const (
EventOutcomeUnknown EventOutcome = "unknown"
EventOutcomeSuccess EventOutcome = "success"
EventOutcomeFailureUnauthorized EventOutcome = "failure_unauthorized"
EventOutcomeFailureNotFound EventOutcome = "failure_not_found"
EventOutcomeFailureGeneric EventOutcome = "failure_generic"
)
+64
View File
@@ -0,0 +1,64 @@
package auditing_test
import (
"encoding/json"
"strconv"
"strings"
"testing"
"time"
"github.com/grafana/grafana/pkg/apiserver/auditing"
"github.com/stretchr/testify/require"
)
func TestEvent_MarshalJSON(t *testing.T) {
t.Parallel()
t.Run("marshals the event", func(t *testing.T) {
t.Parallel()
now := time.Now()
event := auditing.Event{
ObservedAt: now,
Extra: map[string]string{"k1": "v1", "k2": "v2"},
}
data, err := json.Marshal(event)
require.NoError(t, err)
var result map[string]any
require.NoError(t, json.Unmarshal(data, &result))
require.Equal(t, event.Time().UTC().Format(time.RFC3339Nano), result["observedAt"])
require.NotNil(t, result["extra"])
require.Len(t, result["extra"], 2)
})
}
func TestEvent_KVPairs(t *testing.T) {
t.Parallel()
t.Run("records extra fields", func(t *testing.T) {
t.Parallel()
extraFields := 2
extra := make(map[string]string, 0)
for i := 0; i < extraFields; i++ {
extra[strconv.Itoa(i)] = "value"
}
event := auditing.Event{Extra: extra}
kvPairs := event.KVPairs()
extraCount := 0
for i := 0; i < len(kvPairs); i += 2 {
if strings.HasPrefix(kvPairs[i].(string), "extra_") {
extraCount++
}
}
require.Equal(t, extraCount, extraFields)
})
}
+55
View File
@@ -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)
}
+13 -2
View File
@@ -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 }
+12 -3
View File
@@ -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,
}
}
+17 -1
View File
@@ -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)
})
+9 -4
View File
@@ -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
}
+2 -2
View File
@@ -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 -3
View File
@@ -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 (
+10 -8
View File
@@ -490,8 +490,9 @@ var (
{
Name: "cloudWatchBatchQueries",
Description: "Runs CloudWatch metrics queries as separate batches",
Stage: FeatureStagePublicPreview,
Stage: FeatureStageGeneralAvailability,
Owner: awsDatasourcesSquad,
Expression: "true",
},
{
Name: "cachingOptimizeSerializationMemoryUsage",
@@ -572,13 +573,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 +682,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",
+2 -2
View File
@@ -67,7 +67,7 @@ queryService,experimental,@grafana/grafana-datasources-core-services,false,true,
queryServiceWithConnections,experimental,@grafana/grafana-datasources-core-services,false,true,false
queryServiceRewrite,experimental,@grafana/grafana-datasources-core-services,false,true,false
queryServiceFromUI,experimental,@grafana/grafana-datasources-core-services,false,false,true
cloudWatchBatchQueries,preview,@grafana/aws-datasources,false,false,false
cloudWatchBatchQueries,GA,@grafana/aws-datasources,false,false,false
cachingOptimizeSerializationMemoryUsage,experimental,@grafana/grafana-operator-experience-squad,false,false,false
alertmanagerRemoteSecondary,experimental,@grafana/alerting-squad,false,false,false
alertingProvenanceLockWrites,experimental,@grafana/alerting-squad,false,false,false
@@ -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
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
67 queryServiceWithConnections experimental @grafana/grafana-datasources-core-services false true false
68 queryServiceRewrite experimental @grafana/grafana-datasources-core-services false true false
69 queryServiceFromUI experimental @grafana/grafana-datasources-core-services false false true
70 cloudWatchBatchQueries preview GA @grafana/aws-datasources false false false
71 cachingOptimizeSerializationMemoryUsage experimental @grafana/grafana-operator-experience-squad false false false
72 alertmanagerRemoteSecondary experimental @grafana/alerting-squad false false false
73 alertingProvenanceLockWrites experimental @grafana/alerting-squad false false false
79 dashboardSceneSolo GA @grafana/dashboards-squad false false true
80 dashboardScene GA @grafana/dashboards-squad false false true
81 dashboardNewLayouts experimental @grafana/dashboards-squad false false false
kubernetesDashboardsV2 experimental @grafana/dashboards-squad false false false
82 dashboardUndoRedo experimental @grafana/dashboards-squad false false true
83 unlimitedLayoutsNesting experimental @grafana/dashboards-squad false false true
84 drilldownRecommendations experimental @grafana/dashboards-squad false false true
94 cloudRBACRoles preview @grafana/identity-access-team false true false
95 alertingQueryOptimization GA @grafana/alerting-squad false false false
96 jitterAlertRulesWithinGroups preview @grafana/alerting-squad false true false
97 auditLoggingAppPlatform experimental @grafana/grafana-operator-experience-squad false true false
98 secretsManagementAppPlatform experimental @grafana/grafana-operator-experience-squad false false false
99 secretsManagementAppPlatformUI experimental @grafana/grafana-operator-experience-squad false false false
100 alertingSaveStatePeriodic privatePreview @grafana/alerting-squad false false false
+4 -4
View File
@@ -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"
+25 -6
View File
@@ -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",
@@ -860,13 +874,17 @@
{
"metadata": {
"name": "cloudWatchBatchQueries",
"resourceVersion": "1764664939750",
"creationTimestamp": "2023-10-20T19:09:41Z"
"resourceVersion": "1767124751522",
"creationTimestamp": "2023-10-20T19:09:41Z",
"annotations": {
"grafana.app/updatedTimestamp": "2025-12-30 19:59:11.522773 +0000 UTC"
}
},
"spec": {
"description": "Runs CloudWatch metrics queries as separate batches",
"stage": "preview",
"codeowner": "@grafana/aws-datasources"
"stage": "GA",
"codeowner": "@grafana/aws-datasources",
"expression": "true"
}
},
{
@@ -2003,8 +2021,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",
+4
View File
@@ -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)
}
@@ -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": [
+39
View File
@@ -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 {
+11 -5
View File
@@ -1,23 +1,29 @@
import { isArray, isPlainObject } from 'lodash';
import { isArray, isPlainObject, isString } from 'lodash';
/**
* @returns A deep clone of the object, but with any null value removed.
* @param value - The object to be cloned and cleaned.
* @param convertInfinity - If true, -Infinity or Infinity is converted to 0.
* This is because Infinity is not a valid JSON value, and sometimes we want to convert it to 0 instead of default null.
* @param stripBOMs - If true, strips Byte Order Mark (BOM) characters from all strings.
* BOMs (U+FEFF) can cause CUE validation errors ("illegal byte order mark").
*/
export function sortedDeepCloneWithoutNulls<T>(value: T, convertInfinity?: boolean): T {
export function sortedDeepCloneWithoutNulls<T>(value: T, convertInfinity?: boolean, stripBOMs?: boolean): T {
if (isArray(value)) {
return value.map((item) => sortedDeepCloneWithoutNulls(item, convertInfinity)) as unknown as T;
return value.map((item) => sortedDeepCloneWithoutNulls(item, convertInfinity, stripBOMs)) as unknown as T;
}
if (isPlainObject(value)) {
return Object.keys(value as { [key: string]: any })
.sort()
.reduce((acc: any, key) => {
const v = (value as any)[key];
let v = (value as any)[key];
// Remove null values
if (v != null) {
acc[key] = sortedDeepCloneWithoutNulls(v, convertInfinity);
// Strip BOMs from strings
if (stripBOMs && isString(v)) {
v = v.replace(/\ufeff/g, '');
}
acc[key] = sortedDeepCloneWithoutNulls(v, convertInfinity, stripBOMs);
}
if (convertInfinity && (v === Infinity || v === -Infinity)) {
@@ -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();
});
});
});
@@ -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)} />
))}
</>
);

Some files were not shown because too many files have changed in this diff Show More