Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 894f51a9db | |||
| 30ad61e0e9 | |||
| 0b58cd3900 | |||
| 4ba2fe6cce | |||
| a345f78ae0 | |||
| fa1e6cce5e | |||
| e57c30681d | |||
| b378907585 | |||
| 62bdae94ed | |||
| 0091b44b2a | |||
| 307e9cdce3 | |||
| 66eb5e35cd | |||
| a95de85062 |
+142
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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}"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+1
-1
@@ -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}"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+1
-1
@@ -2051,4 +2051,4 @@
|
||||
"storedVersion": "v0alpha1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -2691,4 +2691,4 @@
|
||||
"storedVersion": "v0alpha1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -2764,4 +2764,4 @@
|
||||
"storedVersion": "v0alpha1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1173,4 +1173,4 @@
|
||||
"storedVersion": "v0alpha1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1618,4 +1618,4 @@
|
||||
"storedVersion": "v0alpha1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1670,4 +1670,4 @@
|
||||
"storedVersion": "v0alpha1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+161
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+242
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+246
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { test, expect } from '@grafana/plugin-e2e';
|
||||
|
||||
import testV2DashWithRepeats from '../dashboards/V2DashWithRepeats.json';
|
||||
|
||||
import { test, expect } from './fixtures';
|
||||
import {
|
||||
checkRepeatedPanelTitles,
|
||||
verifyChanges,
|
||||
movePanel,
|
||||
getPanelPosition,
|
||||
saveDashboard,
|
||||
importTestDashboard,
|
||||
goToEmbeddedPanel,
|
||||
} from './utils';
|
||||
|
||||
@@ -32,8 +34,8 @@ test.describe(
|
||||
tag: ['@dashboards'],
|
||||
},
|
||||
() => {
|
||||
test('can enable repeats', async ({ dashboardPage, selectors, page, importDashboard }) => {
|
||||
await importDashboard('Custom grid repeats - add repeats');
|
||||
test('can enable repeats', async ({ dashboardPage, selectors, page }) => {
|
||||
await importTestDashboard(page, selectors, 'Custom grid repeats - add repeats');
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
|
||||
|
||||
@@ -60,8 +62,13 @@ test.describe(
|
||||
await checkRepeatedPanelTitles(dashboardPage, selectors, repeatTitleBase, repeatOptions);
|
||||
});
|
||||
|
||||
test('can update repeats with variable change', async ({ dashboardPage, selectors, page, importDashboard }) => {
|
||||
await importDashboard('Custom grid repeats - update on variable change', JSON.stringify(testV2DashWithRepeats));
|
||||
test('can update repeats with variable change', async ({ dashboardPage, selectors, page }) => {
|
||||
await importTestDashboard(
|
||||
page,
|
||||
selectors,
|
||||
'Custom grid repeats - update on variable change',
|
||||
JSON.stringify(testV2DashWithRepeats)
|
||||
);
|
||||
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(
|
||||
@@ -87,8 +94,13 @@ test.describe(
|
||||
)
|
||||
).toBeHidden();
|
||||
});
|
||||
test('can update repeats in edit pane', async ({ dashboardPage, selectors, page, importDashboard }) => {
|
||||
await importDashboard('Custom grid repeats - update through edit pane', JSON.stringify(testV2DashWithRepeats));
|
||||
test('can update repeats in edit pane', async ({ dashboardPage, selectors, page }) => {
|
||||
await importTestDashboard(
|
||||
page,
|
||||
selectors,
|
||||
'Custom grid repeats - update through edit pane',
|
||||
JSON.stringify(testV2DashWithRepeats)
|
||||
);
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
|
||||
|
||||
@@ -110,8 +122,13 @@ test.describe(
|
||||
await checkRepeatedPanelTitles(dashboardPage, selectors, newTitleBase, repeatOptions);
|
||||
});
|
||||
|
||||
test('can update repeats in panel editor', async ({ dashboardPage, selectors, page, importDashboard }) => {
|
||||
await importDashboard('Custom grid repeats - update through panel editor', JSON.stringify(testV2DashWithRepeats));
|
||||
test('can update repeats in panel editor', async ({ dashboardPage, selectors, page }) => {
|
||||
await importTestDashboard(
|
||||
page,
|
||||
selectors,
|
||||
'Custom grid repeats - update through panel editor',
|
||||
JSON.stringify(testV2DashWithRepeats)
|
||||
);
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
|
||||
|
||||
@@ -164,13 +181,10 @@ test.describe(
|
||||
await checkRepeatedPanelTitles(dashboardPage, selectors, newTitleBase, repeatOptions);
|
||||
});
|
||||
|
||||
test('can update repeats in panel editor when loaded directly', async ({
|
||||
dashboardPage,
|
||||
selectors,
|
||||
page,
|
||||
importDashboard,
|
||||
}) => {
|
||||
await importDashboard(
|
||||
test('can update repeats in panel editor when loaded directly', async ({ dashboardPage, selectors, page }) => {
|
||||
await importTestDashboard(
|
||||
page,
|
||||
selectors,
|
||||
'Custom grid repeats - update through directly loaded panel editor',
|
||||
JSON.stringify(testV2DashWithRepeats)
|
||||
);
|
||||
@@ -218,8 +232,13 @@ test.describe(
|
||||
|
||||
await checkRepeatedPanelTitles(dashboardPage, selectors, newTitleBase, repeatOptions);
|
||||
});
|
||||
test('can move repeated panels', async ({ dashboardPage, selectors, page, importDashboard }) => {
|
||||
await importDashboard('Custom grid repeats - move repeated panels', JSON.stringify(testV2DashWithRepeats));
|
||||
test('can move repeated panels', async ({ dashboardPage, selectors, page }) => {
|
||||
await importTestDashboard(
|
||||
page,
|
||||
selectors,
|
||||
'Custom grid repeats - move repeated panels',
|
||||
JSON.stringify(testV2DashWithRepeats)
|
||||
);
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
|
||||
|
||||
@@ -257,8 +276,13 @@ test.describe(
|
||||
`${repeatTitleBase}${repeatOptions.at(-1)}`
|
||||
);
|
||||
});
|
||||
test('can view repeated panel', async ({ dashboardPage, selectors, page, importDashboard }) => {
|
||||
await importDashboard('Custom grid repeats - move repeated panels', JSON.stringify(testV2DashWithRepeats));
|
||||
test('can view repeated panel', async ({ dashboardPage, selectors, page }) => {
|
||||
await importTestDashboard(
|
||||
page,
|
||||
selectors,
|
||||
'Custom grid repeats - move repeated panels',
|
||||
JSON.stringify(testV2DashWithRepeats)
|
||||
);
|
||||
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.components.Panels.Panel.title(`${repeatTitleBase}${repeatOptions.at(-1)}`))
|
||||
@@ -308,8 +332,10 @@ test.describe(
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('can view embedded repeated panel', async ({ dashboardPage, selectors, page, importDashboard }) => {
|
||||
await importDashboard(
|
||||
test('can view embedded repeated panel', async ({ dashboardPage, selectors, page }) => {
|
||||
await importTestDashboard(
|
||||
page,
|
||||
selectors,
|
||||
'Custom grid repeats - view embedded repeated panel',
|
||||
JSON.stringify(testV2DashWithRepeats)
|
||||
);
|
||||
@@ -327,8 +353,13 @@ test.describe(
|
||||
)
|
||||
).toBeVisible();
|
||||
});
|
||||
test('can remove repeats', async ({ dashboardPage, selectors, page, importDashboard }) => {
|
||||
await importDashboard('Custom grid repeats - remove repeats', JSON.stringify(testV2DashWithRepeats));
|
||||
test('can remove repeats', async ({ dashboardPage, selectors, page }) => {
|
||||
await importTestDashboard(
|
||||
page,
|
||||
selectors,
|
||||
'Custom grid repeats - remove repeats',
|
||||
JSON.stringify(testV2DashWithRepeats)
|
||||
);
|
||||
|
||||
// verify 6 panels are present (4 repeats and 2 normal)
|
||||
expect(
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { test, expect } from '@grafana/plugin-e2e';
|
||||
|
||||
import V2DashWithTabRepeats from '../dashboards/V2DashWithTabRepeats.json';
|
||||
|
||||
import { test, expect } from './fixtures';
|
||||
import {
|
||||
verifyChanges,
|
||||
saveDashboard,
|
||||
importTestDashboard,
|
||||
goToEmbeddedPanel,
|
||||
checkRepeatedTabTitles,
|
||||
groupIntoTab,
|
||||
@@ -33,8 +35,8 @@ test.describe(
|
||||
tag: ['@dashboards'],
|
||||
},
|
||||
() => {
|
||||
test('can enable tab repeats', async ({ dashboardPage, selectors, page, importDashboard }) => {
|
||||
await importDashboard('Tabs layout repeats - add repeats');
|
||||
test('can enable tab repeats', async ({ dashboardPage, selectors, page }) => {
|
||||
await importTestDashboard(page, selectors, 'Tabs layout repeats - add repeats');
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
|
||||
|
||||
@@ -67,8 +69,13 @@ test.describe(
|
||||
await checkRepeatedTabTitles(dashboardPage, selectors, repeatTitleBase, repeatOptions);
|
||||
});
|
||||
|
||||
test('can update tab repeats with variable change', async ({ dashboardPage, selectors, page, importDashboard }) => {
|
||||
await importDashboard('Tabs layout repeats - update on variable change', JSON.stringify(V2DashWithTabRepeats));
|
||||
test('can update tab repeats with variable change', async ({ dashboardPage, selectors, page }) => {
|
||||
await importTestDashboard(
|
||||
page,
|
||||
selectors,
|
||||
'Tabs layout repeats - update on variable change',
|
||||
JSON.stringify(V2DashWithTabRepeats)
|
||||
);
|
||||
|
||||
const c1Var = dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.SubMenu.submenuItemLabels('c1'));
|
||||
await c1Var
|
||||
@@ -90,8 +97,13 @@ test.describe(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Tab.title(`${repeatTitleBase}${repeatOptions.at(-1)}`))
|
||||
).toBeHidden();
|
||||
});
|
||||
test('can update repeats in edit pane', async ({ dashboardPage, selectors, page, importDashboard }) => {
|
||||
await importDashboard('Tabs layout repeats - update through edit pane', JSON.stringify(V2DashWithTabRepeats));
|
||||
test('can update repeats in edit pane', async ({ dashboardPage, selectors, page }) => {
|
||||
await importTestDashboard(
|
||||
page,
|
||||
selectors,
|
||||
'Tabs layout repeats - update through edit pane',
|
||||
JSON.stringify(V2DashWithTabRepeats)
|
||||
);
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
|
||||
// select first/original repeat tab to activate edit pane
|
||||
@@ -113,8 +125,10 @@ test.describe(
|
||||
await checkRepeatedTabTitles(dashboardPage, selectors, newTitleBase, repeatOptions);
|
||||
});
|
||||
|
||||
test('can update repeats after panel change', async ({ dashboardPage, selectors, page, importDashboard }) => {
|
||||
await importDashboard(
|
||||
test('can update repeats after panel change', async ({ dashboardPage, selectors, page }) => {
|
||||
await importTestDashboard(
|
||||
page,
|
||||
selectors,
|
||||
'Tabs layout repeats - update repeats after panel change',
|
||||
JSON.stringify(V2DashWithTabRepeats)
|
||||
);
|
||||
@@ -151,13 +165,10 @@ test.describe(
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('can update repeats after panel change in editor', async ({
|
||||
dashboardPage,
|
||||
selectors,
|
||||
page,
|
||||
importDashboard,
|
||||
}) => {
|
||||
await importDashboard(
|
||||
test('can update repeats after panel change in editor', async ({ dashboardPage, selectors, page }) => {
|
||||
await importTestDashboard(
|
||||
page,
|
||||
selectors,
|
||||
'Tabs layout repeats - update repeats after panel change in editor',
|
||||
JSON.stringify(V2DashWithTabRepeats)
|
||||
);
|
||||
@@ -214,13 +225,10 @@ test.describe(
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('can hide canvas grid add row action in repeats', async ({
|
||||
dashboardPage,
|
||||
selectors,
|
||||
page,
|
||||
importDashboard,
|
||||
}) => {
|
||||
await importDashboard(
|
||||
test('can hide canvas grid add row action in repeats', async ({ dashboardPage, selectors, page }) => {
|
||||
await importTestDashboard(
|
||||
page,
|
||||
selectors,
|
||||
'Tabs layout repeats - hide canvas add action in repeats',
|
||||
JSON.stringify(V2DashWithTabRepeats)
|
||||
);
|
||||
@@ -236,8 +244,13 @@ test.describe(
|
||||
await expect(dashboardPage.getByGrafanaSelector(selectors.components.CanvasGridAddActions.addRow)).toBeHidden();
|
||||
});
|
||||
|
||||
test('can move repeated tabs', async ({ dashboardPage, selectors, page, importDashboard }) => {
|
||||
await importDashboard('Tabs layout repeats - move repeated tabs', JSON.stringify(V2DashWithTabRepeats));
|
||||
test('can move repeated tabs', async ({ dashboardPage, selectors, page }) => {
|
||||
await importTestDashboard(
|
||||
page,
|
||||
selectors,
|
||||
'Tabs layout repeats - move repeated tabs',
|
||||
JSON.stringify(V2DashWithTabRepeats)
|
||||
);
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
|
||||
await moveTab(dashboardPage, page, selectors, `${repeatTitleBase}${repeatOptions.at(0)}`, 'New tab');
|
||||
|
||||
@@ -256,8 +269,13 @@ test.describe(
|
||||
expect(normalTab2?.x).toBeLessThan(repeatedTab2?.x || 0);
|
||||
});
|
||||
|
||||
test('can load into repeated tab', async ({ dashboardPage, selectors, page, importDashboard }) => {
|
||||
await importDashboard('Tabs layout repeats - can load into repeated tab', JSON.stringify(V2DashWithTabRepeats));
|
||||
test('can load into repeated tab', async ({ dashboardPage, selectors, page }) => {
|
||||
await importTestDashboard(
|
||||
page,
|
||||
selectors,
|
||||
'Tabs layout repeats - can load into repeated tab',
|
||||
JSON.stringify(V2DashWithTabRepeats)
|
||||
);
|
||||
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.components.Tab.title(`${repeatTitleBase}${repeatOptions.at(2)}`))
|
||||
@@ -274,8 +292,13 @@ test.describe(
|
||||
).toBe('true');
|
||||
});
|
||||
|
||||
test('can view panels in repeated tab', async ({ dashboardPage, selectors, page, importDashboard }) => {
|
||||
await importDashboard('Tabs layout repeats - view panels in repeated tabs', JSON.stringify(V2DashWithTabRepeats));
|
||||
test('can view panels in repeated tab', async ({ dashboardPage, selectors, page }) => {
|
||||
await importTestDashboard(
|
||||
page,
|
||||
selectors,
|
||||
'Tabs layout repeats - view panels in repeated tabs',
|
||||
JSON.stringify(V2DashWithTabRepeats)
|
||||
);
|
||||
|
||||
// non repeated panel in repeated tab
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel')).first().hover();
|
||||
@@ -344,8 +367,10 @@ test.describe(
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('can view embedded panels in repeated tab', async ({ dashboardPage, selectors, page, importDashboard }) => {
|
||||
await importDashboard(
|
||||
test('can view embedded panels in repeated tab', async ({ dashboardPage, selectors, page }) => {
|
||||
await importTestDashboard(
|
||||
page,
|
||||
selectors,
|
||||
'Tabs layout repeats - view embedded panels in repeated tabs',
|
||||
JSON.stringify(V2DashWithTabRepeats)
|
||||
);
|
||||
@@ -392,8 +417,13 @@ test.describe(
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('can remove repeats', async ({ dashboardPage, selectors, page, importDashboard }) => {
|
||||
await importDashboard('Tabs layout repeats - remove repeats', JSON.stringify(V2DashWithTabRepeats));
|
||||
test('can remove repeats', async ({ dashboardPage, selectors, page }) => {
|
||||
await importTestDashboard(
|
||||
page,
|
||||
selectors,
|
||||
'Tabs layout repeats - remove repeats',
|
||||
JSON.stringify(V2DashWithTabRepeats)
|
||||
);
|
||||
|
||||
// verify 5 tabs are present (4 repeats and 1 normal)
|
||||
await checkRepeatedTabTitles(dashboardPage, selectors, repeatTitleBase, repeatOptions);
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { test as base } from '@grafana/plugin-e2e';
|
||||
|
||||
import { importTestDashboard } from './utils';
|
||||
|
||||
type ImportDashboardFn = (title: string, dashJSON?: string) => Promise<string>;
|
||||
|
||||
/**
|
||||
* Extended test fixtures for dashboard-new-layouts tests.
|
||||
* Provides `importDashboard` - a wrapped version of `importTestDashboard` that
|
||||
* automatically cleans up dashboards after each test.
|
||||
*/
|
||||
export const test = base.extend<{ importDashboard: ImportDashboardFn }>({
|
||||
// imports dashboard and cleans it up after the test
|
||||
importDashboard: async ({ page, selectors, request }, use) => {
|
||||
const importedUIDs: string[] = [];
|
||||
|
||||
const importDashboard: ImportDashboardFn = async (title, dashJSON) => {
|
||||
const uid = await importTestDashboard(page, selectors, title, dashJSON);
|
||||
importedUIDs.push(uid);
|
||||
return uid;
|
||||
};
|
||||
|
||||
await use(importDashboard);
|
||||
|
||||
for (const uid of importedUIDs) {
|
||||
await request.delete(`/api/dashboards/uid/${uid}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export { expect } from '@grafana/plugin-e2e';
|
||||
@@ -160,12 +160,7 @@ export async function verifyChanges(
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.Drawer.General.close).click();
|
||||
}
|
||||
|
||||
export async function importTestDashboard(
|
||||
page: Page,
|
||||
selectors: E2ESelectorGroups,
|
||||
title: string,
|
||||
dashInput?: string
|
||||
): Promise<string> {
|
||||
export async function importTestDashboard(page: Page, selectors: E2ESelectorGroups, title: string, dashInput?: string) {
|
||||
await page.goto(selectors.pages.ImportDashboard.url);
|
||||
await page
|
||||
.getByTestId(selectors.components.DashboardImportPage.textarea)
|
||||
@@ -182,15 +177,6 @@ export async function importTestDashboard(
|
||||
}
|
||||
|
||||
await expect(page.locator('[data-testid="uplot-main-div"]').first()).toBeVisible();
|
||||
|
||||
if (testV2Dashboard.metadata.uid) {
|
||||
return testV2Dashboard.metadata.uid;
|
||||
}
|
||||
// else extract from url
|
||||
const url = new URL(page.url());
|
||||
const pathParts = url.pathname.split('/');
|
||||
const dIndex = pathParts.indexOf('d');
|
||||
return dIndex !== -1 ? pathParts[dIndex + 1] : '';
|
||||
}
|
||||
|
||||
export async function goToEmbeddedPanel(page: Page) {
|
||||
|
||||
+3
@@ -249,6 +249,7 @@ const injectedRtkApi = api
|
||||
permission: queryArg.permission,
|
||||
sort: queryArg.sort,
|
||||
limit: queryArg.limit,
|
||||
ownerReference: queryArg.ownerReference,
|
||||
explain: queryArg.explain,
|
||||
},
|
||||
}),
|
||||
@@ -676,6 +677,8 @@ export type SearchDashboardsAndFoldersApiArg = {
|
||||
sort?: string;
|
||||
/** number of results to return */
|
||||
limit?: number;
|
||||
/** filter by owner reference in the format {Group}/{Kind}/{Name} */
|
||||
ownerReference?: string;
|
||||
/** add debugging info that may help explain why the result matched */
|
||||
explain?: boolean;
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -190,6 +190,32 @@ func (s *SearchHandler) GetAPIRoutes(defs map[string]common.OpenAPIDefinition) *
|
||||
Schema: spec.Int64Property(),
|
||||
},
|
||||
},
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "ownerReference", // singular
|
||||
In: "query",
|
||||
Description: "filter by owner reference in the format {Group}/{Kind}/{Name}",
|
||||
Required: false,
|
||||
Schema: spec.StringProperty(),
|
||||
Examples: map[string]*spec3.Example{
|
||||
"": {
|
||||
ExampleProps: spec3.ExampleProps{},
|
||||
},
|
||||
"team": {
|
||||
ExampleProps: spec3.ExampleProps{
|
||||
Summary: "Team owner reference",
|
||||
Value: "iam.grafana.app/Team/xyz",
|
||||
},
|
||||
},
|
||||
"user": {
|
||||
ExampleProps: spec3.ExampleProps{
|
||||
Summary: "User owner reference",
|
||||
Value: "iam.grafana.app/User/abc",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "explain",
|
||||
@@ -458,6 +484,15 @@ func convertHttpSearchRequestToResourceSearchRequest(queryParams url.Values, use
|
||||
})
|
||||
}
|
||||
|
||||
// The ownerReferences filter
|
||||
if vals, ok := queryParams["ownerReference"]; ok {
|
||||
searchRequest.Options.Fields = append(searchRequest.Options.Fields, &resourcepb.Requirement{
|
||||
Key: resource.SEARCH_FIELD_OWNER_REFERENCES,
|
||||
Operator: "=",
|
||||
Values: vals,
|
||||
})
|
||||
}
|
||||
|
||||
// The libraryPanel filter
|
||||
if libraryPanel, ok := queryParams["libraryPanel"]; ok {
|
||||
searchRequest.Options.Fields = append(searchRequest.Options.Fields, &resourcepb.Requirement{
|
||||
|
||||
@@ -129,6 +129,23 @@ func (b *FolderAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
|
||||
Version: runtime.APIVersionInternal,
|
||||
})
|
||||
|
||||
// Allow searching by owner reference
|
||||
gvk := gv.WithKind("Folder")
|
||||
err := scheme.AddFieldLabelConversionFunc(
|
||||
gvk,
|
||||
func(label, value string) (string, string, error) {
|
||||
if label == "metadata.name" || label == "metadata.namespace" {
|
||||
return label, value, nil
|
||||
}
|
||||
if label == "search.ownerReference" { // TODO: this should become more general
|
||||
return label, value, nil
|
||||
}
|
||||
return "", "", fmt.Errorf("field label not supported for %s: %s", gvk, label)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If multiple versions exist, then register conversions from zz_generated.conversion.go
|
||||
// if err := playlist.RegisterConversions(scheme); err != nil {
|
||||
// return err
|
||||
|
||||
@@ -161,7 +161,7 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) {
|
||||
authz: ruleAuthzService,
|
||||
evaluator: api.EvaluatorFactory,
|
||||
cfg: &api.Cfg.UnifiedAlerting,
|
||||
backtesting: backtesting.NewEngine(api.AppUrl, api.EvaluatorFactory, api.Tracer),
|
||||
backtesting: backtesting.NewEngine(api.AppUrl, api.EvaluatorFactory, api.Tracer, api.Cfg.UnifiedAlerting, api.FeatureManager),
|
||||
featureManager: api.FeatureManager,
|
||||
appUrl: api.AppUrl,
|
||||
tracer: api.Tracer,
|
||||
|
||||
@@ -34,7 +34,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/state"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/store"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
|
||||
type folderService interface {
|
||||
@@ -230,54 +229,27 @@ func (srv TestingApiSrv) BacktestAlertRule(c *contextmodel.ReqContext, cmd apimo
|
||||
return ErrResp(http.StatusNotFound, nil, "Backgtesting API is not enabled")
|
||||
}
|
||||
|
||||
if cmd.From.After(cmd.To) {
|
||||
return ErrResp(400, nil, "From cannot be greater than To")
|
||||
}
|
||||
|
||||
noDataState, err := ngmodels.NoDataStateFromString(string(cmd.NoDataState))
|
||||
|
||||
rule, err := apivalidation.ValidateBacktestConfig(c.GetOrgID(), cmd, apivalidation.RuleLimitsFromConfig(srv.cfg, srv.featureManager))
|
||||
if err != nil {
|
||||
return ErrResp(400, err, "")
|
||||
}
|
||||
forInterval := time.Duration(cmd.For)
|
||||
if forInterval < 0 {
|
||||
return ErrResp(400, nil, "Bad For interval")
|
||||
return ErrResp(http.StatusBadRequest, err, "")
|
||||
}
|
||||
|
||||
intervalSeconds, err := apivalidation.ValidateInterval(time.Duration(cmd.Interval), srv.cfg.BaseInterval)
|
||||
if err != nil {
|
||||
return ErrResp(400, err, "")
|
||||
}
|
||||
|
||||
queries := AlertQueriesFromApiAlertQueries(cmd.Data)
|
||||
if err := srv.authz.AuthorizeDatasourceAccessForRule(c.Req.Context(), c.SignedInUser, &ngmodels.AlertRule{Data: queries}); err != nil {
|
||||
if err := srv.authz.AuthorizeDatasourceAccessForRule(c.Req.Context(), c.SignedInUser, rule); err != nil {
|
||||
return errorToResponse(err)
|
||||
}
|
||||
|
||||
rule := &ngmodels.AlertRule{
|
||||
// ID: 0,
|
||||
// Updated: time.Time{},
|
||||
// Version: 0,
|
||||
// NamespaceUID: "",
|
||||
// DashboardUID: nil,
|
||||
// PanelID: nil,
|
||||
// RuleGroup: "",
|
||||
// RuleGroupIndex: 0,
|
||||
// ExecErrState: "",
|
||||
Title: cmd.Title,
|
||||
// prefix backtesting- is to distinguish between executions of regular rule and backtesting in logs (like expression engine, evaluator, state manager etc)
|
||||
UID: "backtesting-" + util.GenerateShortUID(),
|
||||
OrgID: c.GetOrgID(),
|
||||
Condition: cmd.Condition,
|
||||
Data: queries,
|
||||
IntervalSeconds: intervalSeconds,
|
||||
NoDataState: noDataState,
|
||||
For: forInterval,
|
||||
Annotations: cmd.Annotations,
|
||||
Labels: cmd.Labels,
|
||||
// Fetch folder path for alert labels, fallback to "Backtesting" if not available
|
||||
var folderTitle string
|
||||
if cmd.NamespaceUID != "" {
|
||||
f, err := srv.folderService.GetNamespaceByUID(c.Req.Context(), cmd.NamespaceUID, c.OrgID, c.SignedInUser)
|
||||
if err != nil {
|
||||
srv.log.FromContext(c.Req.Context()).Warn("Failed to fetch folder path for alert labels", "error", err)
|
||||
} else {
|
||||
folderTitle = f.Fullpath
|
||||
}
|
||||
}
|
||||
|
||||
result, err := srv.backtesting.Test(c.Req.Context(), c.SignedInUser, rule, cmd.From, cmd.To)
|
||||
result, err := srv.backtesting.Test(c.Req.Context(), c.SignedInUser, rule, cmd.From, cmd.To, folderTitle)
|
||||
if err != nil {
|
||||
if errors.Is(err, backtesting.ErrInvalidInputData) {
|
||||
return ErrResp(400, err, "Failed to evaluate")
|
||||
@@ -285,9 +257,5 @@ func (srv TestingApiSrv) BacktestAlertRule(c *contextmodel.ReqContext, cmd apimo
|
||||
return ErrResp(500, err, "Failed to evaluate")
|
||||
}
|
||||
|
||||
body, err := data.FrameToJSON(result, data.IncludeAll)
|
||||
if err != nil {
|
||||
return ErrResp(500, err, "Failed to convert frame to JSON")
|
||||
}
|
||||
return response.JSON(http.StatusOK, body)
|
||||
return response.JSONStreaming(http.StatusOK, result)
|
||||
}
|
||||
|
||||
@@ -81,9 +81,15 @@ func (api *API) authorize(method, path string) web.Handler {
|
||||
// additional authorization is done in the request handler
|
||||
eval = ac.EvalPermission(ac.ActionAlertingRuleRead)
|
||||
// Grafana Rules Testing Paths
|
||||
case http.MethodPost + "/api/v1/rule/backtest":
|
||||
case http.MethodPost + "/api/v1/rule/backtest": // TODO (yuri) this should be protected by dedicated permission
|
||||
// additional authorization is done in the request handler
|
||||
eval = ac.EvalPermission(ac.ActionAlertingRuleRead)
|
||||
eval = ac.EvalAll(
|
||||
ac.EvalPermission(ac.ActionAlertingRuleRead),
|
||||
ac.EvalAny(
|
||||
ac.EvalPermission(ac.ActionAlertingRuleUpdate),
|
||||
ac.EvalPermission(ac.ActionAlertingRuleCreate),
|
||||
),
|
||||
)
|
||||
case http.MethodPost + "/api/v1/eval":
|
||||
// additional authorization is done in the request handler
|
||||
eval = ac.EvalPermission(ac.ActionAlertingRuleRead)
|
||||
|
||||
@@ -221,15 +221,21 @@ type BacktestConfig struct {
|
||||
To time.Time `json:"to"`
|
||||
Interval model.Duration `json:"interval,omitempty"`
|
||||
|
||||
Condition string `json:"condition"`
|
||||
Data []AlertQuery `json:"data"`
|
||||
For model.Duration `json:"for,omitempty"`
|
||||
Condition string `json:"condition"`
|
||||
Data []AlertQuery `json:"data"`
|
||||
For *model.Duration `json:"for,omitempty"`
|
||||
KeepFiringFor *model.Duration `json:"keep_firing_for,omitempty"`
|
||||
|
||||
Title string `json:"title"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
Annotations map[string]string `json:"annotations,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
|
||||
NoDataState NoDataState `json:"no_data_state"`
|
||||
NoDataState NoDataState `json:"no_data_state"`
|
||||
ExecErrState ExecutionErrorState `json:"exec_err_state"`
|
||||
MissingSeriesEvalsToResolve *int64 `json:"missing_series_evals_to_resolve,omitempty"`
|
||||
|
||||
UID string `json:"uid,omitempty"`
|
||||
RuleGroup string `json:"rule_group,omitempty"`
|
||||
NamespaceUID string `json:"namespace_uid,omitempty"`
|
||||
}
|
||||
|
||||
// swagger:model
|
||||
|
||||
@@ -249,6 +249,21 @@ func ValidateCondition(condition string, queries []apimodels.AlertQuery, canPatc
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateGroupInterval(incoming prommodels.Duration, limits RuleLimits) (time.Duration, error) {
|
||||
interval := time.Duration(incoming)
|
||||
if interval == 0 {
|
||||
// if group interval is 0 (undefined) then we automatically fall back to the default interval
|
||||
interval = limits.DefaultRuleEvaluationInterval
|
||||
}
|
||||
|
||||
if interval < 0 || int64(interval.Seconds())%int64(limits.BaseInterval.Seconds()) != 0 {
|
||||
return 0, fmt.Errorf("rule evaluation interval (%d second) should be positive number that is multiple of the base interval of %d seconds", int64(interval.Seconds()), int64(limits.BaseInterval.Seconds()))
|
||||
}
|
||||
|
||||
// TODO should we validate that interval is >= cfg.MinInterval? Currently, we allow to save but fix the specified interval if it is < cfg.MinInterval
|
||||
return interval, nil
|
||||
}
|
||||
|
||||
func ValidateInterval(interval, baseInterval time.Duration) (int64, error) {
|
||||
intervalSeconds := int64(interval.Seconds())
|
||||
|
||||
@@ -336,18 +351,11 @@ func ValidateRuleGroup(
|
||||
return nil, fmt.Errorf("rule group name is too long. Max length is %d", store.AlertRuleMaxRuleGroupNameLength)
|
||||
}
|
||||
|
||||
interval := time.Duration(ruleGroupConfig.Interval)
|
||||
if interval == 0 {
|
||||
// if group interval is 0 (undefined) then we automatically fall back to the default interval
|
||||
interval = limits.DefaultRuleEvaluationInterval
|
||||
interval, err := validateGroupInterval(ruleGroupConfig.Interval, limits)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if interval < 0 || int64(interval.Seconds())%int64(limits.BaseInterval.Seconds()) != 0 {
|
||||
return nil, fmt.Errorf("rule evaluation interval (%d second) should be positive number that is multiple of the base interval of %d seconds", int64(interval.Seconds()), int64(limits.BaseInterval.Seconds()))
|
||||
}
|
||||
|
||||
// TODO should we validate that interval is >= cfg.MinInterval? Currently, we allow to save but fix the specified interval if it is < cfg.MinInterval
|
||||
|
||||
// If the rule group is reserved for no-group rules, we cannot have multiple rules in it.
|
||||
if isNoGroupRuleGroup && len(ruleGroupConfig.Rules) > 1 {
|
||||
return nil, fmt.Errorf("rule group %s is reserved for no-group rules and cannot be used for rule groups with multiple rules", ruleGroupConfig.Name)
|
||||
@@ -410,3 +418,32 @@ func ValidateNotificationSettings(n *apimodels.AlertRuleNotificationSettings) ([
|
||||
s,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ValidateBacktestConfig(orgId int64, config apimodels.BacktestConfig, limits RuleLimits) (*ngmodels.AlertRule, error) {
|
||||
if config.From.After(config.To) {
|
||||
return nil, fmt.Errorf("invalid testing range: from %s must be before to %s", config.From, config.To)
|
||||
}
|
||||
|
||||
interval, err := validateGroupInterval(config.Interval, limits)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ValidateRuleNode(&apimodels.PostableExtendedRuleNode{
|
||||
ApiRuleNode: &apimodels.ApiRuleNode{
|
||||
For: config.For,
|
||||
KeepFiringFor: config.KeepFiringFor,
|
||||
Labels: config.Labels,
|
||||
Annotations: nil,
|
||||
},
|
||||
GrafanaManagedAlert: &apimodels.PostableGrafanaRule{
|
||||
Title: config.Title,
|
||||
Condition: config.Condition,
|
||||
Data: config.Data,
|
||||
UID: config.UID,
|
||||
NoDataState: config.NoDataState,
|
||||
ExecErrState: config.ExecErrState,
|
||||
MissingSeriesEvalsToResolve: config.MissingSeriesEvalsToResolve,
|
||||
},
|
||||
}, config.RuleGroup, interval, orgId, config.NamespaceUID, limits)
|
||||
}
|
||||
|
||||
@@ -15,10 +15,16 @@ import (
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/eval"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/schedule"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/schedule/ticker"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/state"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/state/historian"
|
||||
history_model "github.com/grafana/grafana/pkg/services/ngalert/state/historian/model"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -28,7 +34,7 @@ var (
|
||||
backtestingEvaluatorFactory = newBacktestingEvaluator
|
||||
)
|
||||
|
||||
type callbackFunc = func(evaluationIndex int, now time.Time, results eval.Results) error
|
||||
type callbackFunc = func(evaluationIndex int, now time.Time, results eval.Results) (bool, error)
|
||||
|
||||
type backtestingEvaluator interface {
|
||||
Eval(ctx context.Context, from time.Time, interval time.Duration, evaluations int, callback callbackFunc) error
|
||||
@@ -40,11 +46,17 @@ type stateManager interface {
|
||||
}
|
||||
|
||||
type Engine struct {
|
||||
evalFactory eval.EvaluatorFactory
|
||||
createStateManager func() stateManager
|
||||
evalFactory eval.EvaluatorFactory
|
||||
createStateManager func() stateManager
|
||||
disableGrafanaFolder bool
|
||||
featureToggles featuremgmt.FeatureToggles
|
||||
minInterval time.Duration
|
||||
baseInterval time.Duration
|
||||
jitterStrategy schedule.JitterStrategy
|
||||
maxEvaluations int
|
||||
}
|
||||
|
||||
func NewEngine(appUrl *url.URL, evalFactory eval.EvaluatorFactory, tracer tracing.Tracer) *Engine {
|
||||
func NewEngine(appUrl *url.URL, evalFactory eval.EvaluatorFactory, tracer tracing.Tracer, cfg setting.UnifiedAlertingSettings, toggles featuremgmt.FeatureToggles) *Engine {
|
||||
return &Engine{
|
||||
evalFactory: evalFactory,
|
||||
createStateManager: func() stateManager {
|
||||
@@ -60,74 +72,139 @@ func NewEngine(appUrl *url.URL, evalFactory eval.EvaluatorFactory, tracer tracin
|
||||
}
|
||||
return state.NewManager(cfg, state.NewNoopPersister())
|
||||
},
|
||||
disableGrafanaFolder: false,
|
||||
featureToggles: toggles,
|
||||
minInterval: cfg.MinInterval,
|
||||
baseInterval: cfg.BaseInterval,
|
||||
maxEvaluations: cfg.BacktestingMaxEvaluations,
|
||||
jitterStrategy: schedule.JitterStrategyFrom(cfg, toggles),
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) Test(ctx context.Context, user identity.Requester, rule *models.AlertRule, from, to time.Time) (*data.Frame, error) {
|
||||
ruleCtx := models.WithRuleKey(ctx, rule.GetKey())
|
||||
logger := logger.FromContext(ctx)
|
||||
|
||||
func (e *Engine) Test(ctx context.Context, user identity.Requester, rule *models.AlertRule, from, to time.Time, folderTitle string) (res *data.Frame, err error) {
|
||||
if rule == nil {
|
||||
return nil, fmt.Errorf("%w: rule is not defined", ErrInvalidInputData)
|
||||
}
|
||||
if !from.Before(to) {
|
||||
return nil, fmt.Errorf("%w: invalid interval of the backtesting [%d,%d]", ErrInvalidInputData, from.Unix(), to.Unix())
|
||||
return nil, fmt.Errorf("%w: invalid interval [%d,%d]", ErrInvalidInputData, from.Unix(), to.Unix())
|
||||
}
|
||||
if to.Sub(from).Seconds() < float64(rule.IntervalSeconds) {
|
||||
return nil, fmt.Errorf("%w: interval of the backtesting [%d,%d] is less than evaluation interval [%ds]", ErrInvalidInputData, from.Unix(), to.Unix(), rule.IntervalSeconds)
|
||||
|
||||
ruleCtx := models.WithRuleKey(ctx, rule.GetKey())
|
||||
logger := logger.FromContext(ruleCtx).New("backtesting", util.GenerateShortUID())
|
||||
|
||||
var warns []string
|
||||
if rule.GetInterval() < e.minInterval {
|
||||
logger.Warn("Interval adjusted to minimal interval", "originalInterval", rule.GetInterval(), "adjustedInterval", e.minInterval)
|
||||
rule = rule.Copy()
|
||||
rule.IntervalSeconds = int64(e.minInterval.Seconds())
|
||||
warns = append(warns, fmt.Sprintf("Interval adjusted to minimal interval %ds", rule.IntervalSeconds))
|
||||
}
|
||||
length := int(to.Sub(from).Seconds()) / int(rule.IntervalSeconds)
|
||||
|
||||
stateManager := e.createStateManager()
|
||||
effectiveStrategy := e.jitterStrategy
|
||||
if e.jitterStrategy == schedule.JitterByGroup && (rule.RuleGroup == "" || rule.NamespaceUID == "") ||
|
||||
e.jitterStrategy == schedule.JitterByRule && rule.UID == "" {
|
||||
logger.Warn(fmt.Sprintf("Jitter strategy is set to %s, but rule group or namespace is not set. Ignore jitter", e.jitterStrategy))
|
||||
warns = append(warns, fmt.Sprintf("Jitter strategy is set to %s, but rule group or namespace is not set. Ignore jitter. The results of testing will be different than real evaluations", e.jitterStrategy))
|
||||
effectiveStrategy = schedule.JitterNever
|
||||
}
|
||||
jitterOffset := schedule.JitterOffsetInDuration(rule, e.baseInterval, effectiveStrategy)
|
||||
firstEval, err := getFirstEvaluationTime(from, rule, e.baseInterval, jitterOffset)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %s", ErrInvalidInputData, err)
|
||||
}
|
||||
|
||||
evaluator, err := backtestingEvaluatorFactory(ruleCtx, e.evalFactory, user, rule.GetEvalCondition().WithSource("backtesting"), &schedule.AlertingResultsFromRuleState{
|
||||
Manager: stateManager,
|
||||
Rule: rule,
|
||||
})
|
||||
evaluations := calculateNumberOfEvaluations(firstEval, to, rule.GetInterval())
|
||||
if e.maxEvaluations > 0 && evaluations > e.maxEvaluations {
|
||||
logger.Warn("Evaluations adjusted to maximal number", "originalEvaluations", evaluations, "adjustedEvaluations", e.maxEvaluations)
|
||||
warns = append(warns, fmt.Sprintf("Number of evaluations are adjusted to the limit of %d evaluations. Requested: %d", e.maxEvaluations, evaluations))
|
||||
evaluations = e.maxEvaluations
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
if err == nil {
|
||||
logger.Info("Rule testing finished successfully", "duration", time.Since(start))
|
||||
} else {
|
||||
logger.Error("Rule testing finished with error", "duration", time.Since(start), "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
stateMgr := e.createStateManager()
|
||||
|
||||
evaluator, err := backtestingEvaluatorFactory(ruleCtx,
|
||||
e.evalFactory,
|
||||
user,
|
||||
rule.GetEvalCondition().WithSource("backtesting"),
|
||||
&schedule.AlertingResultsFromRuleState{
|
||||
Manager: stateMgr,
|
||||
Rule: rule,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, errors.Join(ErrInvalidInputData, err)
|
||||
}
|
||||
|
||||
logger.Info("Start testing alert rule", "from", from, "to", to, "interval", rule.IntervalSeconds, "evaluations", length)
|
||||
logger.Info("Start testing alert rule", "from", from, "to", to, "interval", rule.GetInterval(), "firstTick", firstEval, "evaluations", evaluations, "jitterOffset", jitterOffset, "jitterStrategy", effectiveStrategy)
|
||||
|
||||
start := time.Now()
|
||||
var builder *historian.QueryResultBuilder
|
||||
|
||||
tsField := data.NewField("Time", nil, make([]time.Time, length))
|
||||
valueFields := make(map[data.Fingerprint]*data.Field)
|
||||
|
||||
err = evaluator.Eval(ruleCtx, from, time.Duration(rule.IntervalSeconds)*time.Second, length, func(idx int, currentTime time.Time, results eval.Results) error {
|
||||
if idx >= length {
|
||||
logger.Info("Unexpected evaluation. Skipping", "from", from, "to", to, "interval", rule.IntervalSeconds, "evaluationTime", currentTime, "evaluationIndex", idx, "expectedEvaluations", length)
|
||||
return nil
|
||||
}
|
||||
states := stateManager.ProcessEvalResults(ruleCtx, currentTime, rule, results, nil, nil)
|
||||
tsField.Set(idx, currentTime)
|
||||
for _, s := range states {
|
||||
field, ok := valueFields[s.CacheID]
|
||||
if !ok {
|
||||
field = data.NewField("", s.Labels, make([]*string, length))
|
||||
valueFields[s.CacheID] = field
|
||||
}
|
||||
if s.State.State != eval.NoData { // set nil if NoData
|
||||
value := s.State.State.String()
|
||||
if s.StateReason != "" {
|
||||
value += " (" + s.StateReason + ")"
|
||||
}
|
||||
field.Set(idx, &value)
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
fields := make([]*data.Field, 0, len(valueFields)+1)
|
||||
fields = append(fields, tsField)
|
||||
for _, f := range valueFields {
|
||||
fields = append(fields, f)
|
||||
ruleMeta := history_model.RuleMeta{
|
||||
ID: rule.ID,
|
||||
OrgID: rule.OrgID,
|
||||
UID: rule.UID,
|
||||
Title: rule.Title,
|
||||
Group: rule.RuleGroup,
|
||||
NamespaceUID: rule.NamespaceUID,
|
||||
// DashboardUID: "",
|
||||
// PanelID: 0,
|
||||
Condition: rule.Condition,
|
||||
}
|
||||
result := data.NewFrame("Testing results", fields...)
|
||||
|
||||
labels := map[string]string{
|
||||
historian.OrgIDLabel: fmt.Sprint(ruleMeta.OrgID),
|
||||
historian.GroupLabel: fmt.Sprint(ruleMeta.Group),
|
||||
historian.FolderUIDLabel: fmt.Sprint(rule.NamespaceUID),
|
||||
}
|
||||
labelsBytes, err := json.Marshal(labels)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logger.Info("Rule testing finished successfully", "duration", time.Since(start))
|
||||
return result, nil
|
||||
|
||||
// Ensure fallback if empty string is passed
|
||||
if folderTitle == "" {
|
||||
folderTitle = "Backtesting"
|
||||
}
|
||||
extraLabels := state.GetRuleExtraLabels(logger, rule, folderTitle, !e.disableGrafanaFolder, e.featureToggles)
|
||||
|
||||
processFn := func(idx int, currentTime time.Time, results eval.Results) (bool, error) {
|
||||
// init the builder. Do the best guess for the size of the result
|
||||
if builder == nil {
|
||||
builder = historian.NewQueryResultBuilder(evaluations * len(results))
|
||||
for _, warn := range warns {
|
||||
builder.AddWarn(warn)
|
||||
}
|
||||
}
|
||||
states := stateMgr.ProcessEvalResults(ruleCtx, currentTime, rule, results, extraLabels, nil)
|
||||
for _, s := range states {
|
||||
if !historian.ShouldRecord(s) {
|
||||
continue
|
||||
}
|
||||
entry := historian.StateTransitionToLokiEntry(ruleMeta, s)
|
||||
err := builder.AddRow(currentTime, entry, labelsBytes)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
return idx <= evaluations, nil
|
||||
}
|
||||
|
||||
err = evaluator.Eval(ruleCtx, firstEval, rule.GetInterval(), evaluations, processFn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if builder == nil {
|
||||
return nil, errors.New("no results were produced")
|
||||
}
|
||||
return builder.ToFrame(), nil
|
||||
}
|
||||
|
||||
func newBacktestingEvaluator(ctx context.Context, evalFactory eval.EvaluatorFactory, user identity.Requester, condition models.Condition, reader eval.AlertingResultsReader) (backtestingEvaluator, error) {
|
||||
@@ -173,3 +250,53 @@ type NoopImageService struct{}
|
||||
func (s *NoopImageService) NewImage(_ context.Context, _ *models.AlertRule) (*models.Image, error) {
|
||||
return &models.Image{}, nil
|
||||
}
|
||||
|
||||
func getNextEvaluationTime(currentTime time.Time, rule *models.AlertRule, baseInterval time.Duration, jitterOffset time.Duration) (time.Time, error) {
|
||||
if rule.IntervalSeconds%int64(baseInterval.Seconds()) != 0 {
|
||||
return time.Time{}, fmt.Errorf("interval %ds is not divisible by base interval %ds", rule.IntervalSeconds, int64(baseInterval.Seconds()))
|
||||
}
|
||||
|
||||
freq := rule.IntervalSeconds / int64(baseInterval.Seconds())
|
||||
|
||||
firstTickNum := currentTime.Unix() / int64(baseInterval.Seconds())
|
||||
|
||||
jitterOffsetTicks := int64(jitterOffset / baseInterval)
|
||||
|
||||
firstEvalTickNum := firstTickNum + (jitterOffsetTicks-(firstTickNum%freq)+freq)%freq
|
||||
|
||||
return time.Unix(firstEvalTickNum*int64(baseInterval.Seconds()), 0), nil
|
||||
}
|
||||
|
||||
func getFirstEvaluationTime(from time.Time, rule *models.AlertRule, baseInterval time.Duration, jitterOffset time.Duration) (time.Time, error) {
|
||||
// Now calculate the time of the tick the same way as in the scheduler
|
||||
firstTick := ticker.GetStartTick(from, baseInterval)
|
||||
|
||||
// calculate time of the first evaluation that is at or after the first tick
|
||||
firstEval, err := getNextEvaluationTime(firstTick, rule, baseInterval, jitterOffset)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
|
||||
// Ensure firstEval is at or after from
|
||||
// Calculate how many intervals to skip to get past 'from'
|
||||
if firstEval.Before(from) {
|
||||
diff := from.Sub(firstEval)
|
||||
interval := rule.GetInterval()
|
||||
// Ceiling division: how many intervals needed to cover the difference
|
||||
intervalsToAdd := (diff + interval - 1) / interval
|
||||
firstEval = firstEval.Add(interval * intervalsToAdd)
|
||||
}
|
||||
|
||||
return firstEval, nil
|
||||
}
|
||||
|
||||
func calculateNumberOfEvaluations(firstEval, to time.Time, interval time.Duration) int {
|
||||
var evaluations int
|
||||
if to.After(firstEval) {
|
||||
evaluations = int(to.Sub(firstEval).Seconds()) / int(interval.Seconds())
|
||||
}
|
||||
if evaluations == 0 {
|
||||
evaluations = 1
|
||||
}
|
||||
return evaluations
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -14,9 +13,11 @@ import (
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/eval"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/eval/eval_mocks"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/schedule"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/state"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
@@ -158,16 +159,6 @@ func TestNewBacktestingEvaluator(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEvaluatorTest(t *testing.T) {
|
||||
states := []eval.State{eval.Normal, eval.Alerting, eval.Pending}
|
||||
generateState := func(prefix string) *state.State {
|
||||
labels := models.GenerateAlertLabels(rand.Intn(5)+1, prefix+"-")
|
||||
return &state.State{
|
||||
CacheID: labels.Fingerprint(),
|
||||
Labels: labels,
|
||||
State: states[rand.Intn(len(states))],
|
||||
}
|
||||
}
|
||||
|
||||
randomResultCallback := func(now time.Time) (eval.Results, error) {
|
||||
return eval.GenerateResults(rand.Intn(5)+1, eval.ResultGen()), nil
|
||||
}
|
||||
@@ -189,84 +180,17 @@ func TestEvaluatorTest(t *testing.T) {
|
||||
createStateManager: func() stateManager {
|
||||
return manager
|
||||
},
|
||||
disableGrafanaFolder: false,
|
||||
featureToggles: featuremgmt.WithFeatures(),
|
||||
minInterval: 1 * time.Second,
|
||||
baseInterval: 1 * time.Second,
|
||||
jitterStrategy: schedule.JitterNever,
|
||||
maxEvaluations: 10000,
|
||||
}
|
||||
gen := models.RuleGen
|
||||
rule := gen.With(gen.WithInterval(time.Second)).GenerateRef()
|
||||
ruleInterval := time.Duration(rule.IntervalSeconds) * time.Second
|
||||
|
||||
t.Run("should return data frame in specific format", func(t *testing.T) {
|
||||
from := time.Unix(0, 0)
|
||||
to := from.Add(5 * ruleInterval)
|
||||
allStates := [...]eval.State{eval.Normal, eval.Alerting, eval.Pending, eval.NoData, eval.Error}
|
||||
|
||||
var states []state.StateTransition
|
||||
|
||||
for _, s := range allStates {
|
||||
labels := models.GenerateAlertLabels(rand.Intn(5)+1, s.String()+"-")
|
||||
states = append(states, state.StateTransition{
|
||||
State: &state.State{
|
||||
CacheID: labels.Fingerprint(),
|
||||
Labels: labels,
|
||||
State: s,
|
||||
StateReason: util.GenerateShortUID(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
manager.stateCallback = func(now time.Time) []state.StateTransition {
|
||||
return states
|
||||
}
|
||||
|
||||
frame, err := engine.Test(context.Background(), nil, rule, from, to)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, frame.Fields, len(states)+1) // +1 - timestamp
|
||||
|
||||
t.Run("should contain field Time", func(t *testing.T) {
|
||||
timestampField, _ := frame.FieldByName("Time")
|
||||
require.NotNil(t, timestampField, "frame does not contain field 'Time'")
|
||||
require.Equal(t, data.FieldTypeTime, timestampField.Type())
|
||||
})
|
||||
|
||||
fieldByState := make(map[data.Fingerprint]*data.Field, len(states))
|
||||
|
||||
t.Run("should contain a field per state", func(t *testing.T) {
|
||||
for _, s := range states {
|
||||
var f *data.Field
|
||||
for _, field := range frame.Fields {
|
||||
if field.Labels.String() == s.Labels.String() {
|
||||
f = field
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNilf(t, f, "Cannot find a field by state labels")
|
||||
fieldByState[s.CacheID] = f
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("should be populated with correct values", func(t *testing.T) {
|
||||
timestampField, _ := frame.FieldByName("Time")
|
||||
expectedLength := timestampField.Len()
|
||||
for _, field := range frame.Fields {
|
||||
require.Equalf(t, expectedLength, field.Len(), "Field %s should have the size %d", field.Name, expectedLength)
|
||||
}
|
||||
for i := 0; i < expectedLength; i++ {
|
||||
expectedTime := from.Add(time.Duration(int64(i)*rule.IntervalSeconds) * time.Second)
|
||||
require.Equal(t, expectedTime, timestampField.At(i).(time.Time))
|
||||
for _, s := range states {
|
||||
f := fieldByState[s.CacheID]
|
||||
if s.State.State == eval.NoData {
|
||||
require.Nil(t, f.At(i))
|
||||
} else {
|
||||
v := f.At(i).(*string)
|
||||
require.NotNilf(t, v, "Field [%s] value at index %d should not be nil", s.CacheID, i)
|
||||
require.Equal(t, fmt.Sprintf("%s (%s)", s.State.State, s.StateReason), *v)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("should not fail if 'to-from' is not times of interval", func(t *testing.T) {
|
||||
from := time.Unix(0, 0)
|
||||
to := from.Add(5 * ruleInterval)
|
||||
@@ -287,84 +211,26 @@ func TestEvaluatorTest(t *testing.T) {
|
||||
return states
|
||||
}
|
||||
|
||||
frame, err := engine.Test(context.Background(), nil, rule, from, to)
|
||||
frame, err := engine.Test(context.Background(), nil, rule, from, to, "")
|
||||
require.NoError(t, err)
|
||||
expectedLen := frame.Rows()
|
||||
for i := 0; i < 100; i++ {
|
||||
jitter := time.Duration(rand.Int63n(ruleInterval.Milliseconds())) * time.Millisecond
|
||||
frame, err = engine.Test(context.Background(), nil, rule, from, to.Add(jitter))
|
||||
frame, err = engine.Test(context.Background(), nil, rule, from, to.Add(jitter), "")
|
||||
require.NoError(t, err)
|
||||
require.Equalf(t, expectedLen, frame.Rows(), "jitter %v caused result to be different that base-line", jitter)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("should backfill field with nulls if a new dimension created in the middle", func(t *testing.T) {
|
||||
from := time.Unix(0, 0)
|
||||
|
||||
state1 := state.StateTransition{
|
||||
State: generateState("1"),
|
||||
}
|
||||
state2 := state.StateTransition{
|
||||
State: generateState("2"),
|
||||
}
|
||||
state3 := state.StateTransition{
|
||||
State: generateState("3"),
|
||||
}
|
||||
stateByTime := map[time.Time][]state.StateTransition{
|
||||
from: {state1, state2},
|
||||
from.Add(1 * ruleInterval): {state1, state2},
|
||||
from.Add(2 * ruleInterval): {state1, state2},
|
||||
from.Add(3 * ruleInterval): {state1, state2, state3},
|
||||
from.Add(4 * ruleInterval): {state1, state2, state3},
|
||||
}
|
||||
to := from.Add(time.Duration(len(stateByTime)) * ruleInterval)
|
||||
|
||||
manager.stateCallback = func(now time.Time) []state.StateTransition {
|
||||
return stateByTime[now]
|
||||
}
|
||||
|
||||
frame, err := engine.Test(context.Background(), nil, rule, from, to)
|
||||
require.NoError(t, err)
|
||||
|
||||
var field3 *data.Field
|
||||
for _, field := range frame.Fields {
|
||||
if field.Labels.String() == state3.Labels.String() {
|
||||
field3 = field
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNilf(t, field3, "Result for state 3 was not found")
|
||||
require.Equalf(t, len(stateByTime), field3.Len(), "State3 result has unexpected number of values")
|
||||
|
||||
idx := 0
|
||||
for curTime, states := range stateByTime {
|
||||
value := field3.At(idx).(*string)
|
||||
if len(states) == 2 {
|
||||
require.Nilf(t, value, "The result should be nil if state3 was not available for time %v", curTime)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("should fail", func(t *testing.T) {
|
||||
manager.stateCallback = func(now time.Time) []state.StateTransition {
|
||||
return nil
|
||||
}
|
||||
|
||||
t.Run("when interval is not correct", func(t *testing.T) {
|
||||
from := time.Now()
|
||||
t.Run("when from=to", func(t *testing.T) {
|
||||
to := from
|
||||
_, err := engine.Test(context.Background(), nil, rule, from, to)
|
||||
require.ErrorIs(t, err, ErrInvalidInputData)
|
||||
})
|
||||
t.Run("when from > to", func(t *testing.T) {
|
||||
to := from.Add(-ruleInterval)
|
||||
_, err := engine.Test(context.Background(), nil, rule, from, to)
|
||||
require.ErrorIs(t, err, ErrInvalidInputData)
|
||||
})
|
||||
t.Run("when to-from < interval", func(t *testing.T) {
|
||||
to := from.Add(ruleInterval).Add(-time.Millisecond)
|
||||
_, err := engine.Test(context.Background(), nil, rule, from, to)
|
||||
_, err := engine.Test(context.Background(), nil, rule, from, to, "")
|
||||
require.ErrorIs(t, err, ErrInvalidInputData)
|
||||
})
|
||||
})
|
||||
@@ -376,7 +242,7 @@ func TestEvaluatorTest(t *testing.T) {
|
||||
}
|
||||
from := time.Now()
|
||||
to := from.Add(ruleInterval)
|
||||
_, err := engine.Test(context.Background(), nil, rule, from, to)
|
||||
_, err := engine.Test(context.Background(), nil, rule, from, to, "")
|
||||
require.ErrorIs(t, err, expectedError)
|
||||
})
|
||||
})
|
||||
@@ -404,10 +270,188 @@ func (f *fakeBacktestingEvaluator) Eval(_ context.Context, from time.Time, inter
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = callback(idx, now, results)
|
||||
c, err := callback(idx, now, results)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !c {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestGetNextEvaluationTime(t *testing.T) {
|
||||
baseInterval := 10 * time.Second
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
ruleInterval int64
|
||||
currentTimestamp int64
|
||||
jitterOffset time.Duration
|
||||
expectError bool
|
||||
expectedNext int64
|
||||
}{
|
||||
{
|
||||
name: "interval not divisible by base interval",
|
||||
ruleInterval: 15,
|
||||
currentTimestamp: 0,
|
||||
jitterOffset: 0,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "no jitter - from tick 0",
|
||||
ruleInterval: 20,
|
||||
currentTimestamp: 0,
|
||||
jitterOffset: 0,
|
||||
expectedNext: 0,
|
||||
},
|
||||
{
|
||||
name: "no jitter - from tick 1",
|
||||
ruleInterval: 20,
|
||||
currentTimestamp: 10,
|
||||
jitterOffset: 0,
|
||||
expectedNext: 20,
|
||||
},
|
||||
{
|
||||
name: "no jitter - from tick 2",
|
||||
ruleInterval: 20,
|
||||
currentTimestamp: 20,
|
||||
jitterOffset: 0,
|
||||
expectedNext: 20,
|
||||
},
|
||||
{
|
||||
name: "with 20s jitter - from tick 0",
|
||||
ruleInterval: 60,
|
||||
currentTimestamp: 0,
|
||||
jitterOffset: 20 * time.Second,
|
||||
expectedNext: 20,
|
||||
},
|
||||
{
|
||||
name: "with 20s jitter - from tick 2",
|
||||
ruleInterval: 60,
|
||||
currentTimestamp: 20,
|
||||
jitterOffset: 20 * time.Second,
|
||||
expectedNext: 20,
|
||||
},
|
||||
{
|
||||
name: "with 20s jitter - from tick 3",
|
||||
ruleInterval: 60,
|
||||
currentTimestamp: 30,
|
||||
jitterOffset: 20 * time.Second,
|
||||
expectedNext: 80,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rule := &models.AlertRule{IntervalSeconds: tc.ruleInterval}
|
||||
currentTime := time.Unix(tc.currentTimestamp, 0)
|
||||
result, err := getNextEvaluationTime(currentTime, rule, baseInterval, tc.jitterOffset)
|
||||
|
||||
if tc.expectError {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "is not divisible by base interval")
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expectedNext, result.Unix())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFirstEvaluationTime(t *testing.T) {
|
||||
baseInterval := 10 * time.Second
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
ruleInterval int64
|
||||
fromUnix int64
|
||||
jitterOffset time.Duration
|
||||
expectError bool
|
||||
expectedUnix int64
|
||||
}{
|
||||
{
|
||||
name: "interval not divisible by base interval",
|
||||
ruleInterval: 15,
|
||||
fromUnix: 0,
|
||||
jitterOffset: 0,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "no jitter - from at tick 0",
|
||||
ruleInterval: 20,
|
||||
fromUnix: 0,
|
||||
jitterOffset: 0,
|
||||
expectedUnix: 0,
|
||||
},
|
||||
{
|
||||
name: "no jitter - from at tick 1",
|
||||
ruleInterval: 20,
|
||||
fromUnix: 10,
|
||||
jitterOffset: 0,
|
||||
expectedUnix: 20,
|
||||
},
|
||||
{
|
||||
name: "no jitter - from before first tick",
|
||||
ruleInterval: 20,
|
||||
fromUnix: 5,
|
||||
jitterOffset: 0,
|
||||
expectedUnix: 20,
|
||||
},
|
||||
{
|
||||
name: "no jitter - from after first aligned tick",
|
||||
ruleInterval: 20,
|
||||
fromUnix: 25,
|
||||
jitterOffset: 0,
|
||||
expectedUnix: 40,
|
||||
},
|
||||
{
|
||||
name: "no jitter - from at tick boundary",
|
||||
ruleInterval: 10,
|
||||
fromUnix: 10,
|
||||
jitterOffset: 0,
|
||||
expectedUnix: 10,
|
||||
},
|
||||
{
|
||||
name: "with 20s jitter - from epoch",
|
||||
ruleInterval: 60,
|
||||
fromUnix: 0,
|
||||
jitterOffset: 20 * time.Second,
|
||||
expectedUnix: 20,
|
||||
},
|
||||
{
|
||||
name: "with 20s jitter - from 70s",
|
||||
ruleInterval: 60,
|
||||
fromUnix: 70,
|
||||
jitterOffset: 20 * time.Second,
|
||||
expectedUnix: 80,
|
||||
},
|
||||
{
|
||||
name: "with 50s jitter - from 25s",
|
||||
ruleInterval: 60,
|
||||
fromUnix: 25,
|
||||
jitterOffset: 50 * time.Second,
|
||||
expectedUnix: 50,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rule := &models.AlertRule{IntervalSeconds: tc.ruleInterval}
|
||||
from := time.Unix(tc.fromUnix, 0)
|
||||
result, err := getFirstEvaluationTime(from, rule, baseInterval, tc.jitterOffset)
|
||||
|
||||
if tc.expectError {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "is not divisible by base interval")
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expectedUnix, result.Unix())
|
||||
require.GreaterOrEqual(t, result.Unix(), from.Unix(), "first eval should be at or after from")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,10 +85,13 @@ func (d *dataEvaluator) Eval(_ context.Context, from time.Time, interval time.Du
|
||||
EvaluatedAt: now,
|
||||
})
|
||||
}
|
||||
err := callback(i, now, result)
|
||||
cont, err := callback(i, now, result)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !cont {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -100,11 +100,11 @@ func TestDataEvaluator_Eval(t *testing.T) {
|
||||
|
||||
resultsCount := int(to.Sub(from).Seconds() / interval.Seconds())
|
||||
|
||||
err = evaluator.Eval(context.Background(), from, time.Second, resultsCount, func(idx int, now time.Time, res eval.Results) error {
|
||||
err = evaluator.Eval(context.Background(), from, time.Second, resultsCount, func(idx int, now time.Time, res eval.Results) (bool, error) {
|
||||
r = append(r, results{
|
||||
now, res,
|
||||
})
|
||||
return nil
|
||||
return true, nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -164,11 +164,11 @@ func TestDataEvaluator_Eval(t *testing.T) {
|
||||
size := to.Sub(from).Milliseconds() / interval.Milliseconds()
|
||||
r := make([]results, 0, size)
|
||||
|
||||
err = evaluator.Eval(context.Background(), from, interval, int(size), func(idx int, now time.Time, res eval.Results) error {
|
||||
err = evaluator.Eval(context.Background(), from, interval, int(size), func(idx int, now time.Time, res eval.Results) (bool, error) {
|
||||
r = append(r, results{
|
||||
now, res,
|
||||
})
|
||||
return nil
|
||||
return true, nil
|
||||
})
|
||||
|
||||
currentRowIdx := 0
|
||||
@@ -195,11 +195,11 @@ func TestDataEvaluator_Eval(t *testing.T) {
|
||||
size := int(to.Sub(from).Seconds() / interval.Seconds())
|
||||
r := make([]results, 0, size)
|
||||
|
||||
err = evaluator.Eval(context.Background(), from, interval, size, func(idx int, now time.Time, res eval.Results) error {
|
||||
err = evaluator.Eval(context.Background(), from, interval, size, func(idx int, now time.Time, res eval.Results) (bool, error) {
|
||||
r = append(r, results{
|
||||
now, res,
|
||||
})
|
||||
return nil
|
||||
return true, nil
|
||||
})
|
||||
|
||||
currentRowIdx := 0
|
||||
@@ -230,11 +230,11 @@ func TestDataEvaluator_Eval(t *testing.T) {
|
||||
t.Run("should be noData until the frame interval", func(t *testing.T) {
|
||||
newFrom := from.Add(-10 * time.Second)
|
||||
r := make([]results, 0, int(to.Sub(newFrom).Seconds()))
|
||||
err = evaluator.Eval(context.Background(), newFrom, time.Second, cap(r), func(idx int, now time.Time, res eval.Results) error {
|
||||
err = evaluator.Eval(context.Background(), newFrom, time.Second, cap(r), func(idx int, now time.Time, res eval.Results) (bool, error) {
|
||||
r = append(r, results{
|
||||
now, res,
|
||||
})
|
||||
return nil
|
||||
return true, nil
|
||||
})
|
||||
|
||||
rowIdx := 0
|
||||
@@ -258,11 +258,11 @@ func TestDataEvaluator_Eval(t *testing.T) {
|
||||
t.Run("should be the last value after the frame interval", func(t *testing.T) {
|
||||
newTo := to.Add(10 * time.Second)
|
||||
r := make([]results, 0, int(newTo.Sub(from).Seconds()))
|
||||
err = evaluator.Eval(context.Background(), from, time.Second, cap(r), func(idx int, now time.Time, res eval.Results) error {
|
||||
err = evaluator.Eval(context.Background(), from, time.Second, cap(r), func(idx int, now time.Time, res eval.Results) (bool, error) {
|
||||
r = append(r, results{
|
||||
now, res,
|
||||
})
|
||||
return nil
|
||||
return true, nil
|
||||
})
|
||||
|
||||
rowIdx := 0
|
||||
@@ -282,12 +282,21 @@ func TestDataEvaluator_Eval(t *testing.T) {
|
||||
})
|
||||
t.Run("should stop if callback error", func(t *testing.T) {
|
||||
expectedError := errors.New("error")
|
||||
err = evaluator.Eval(context.Background(), from, time.Second, 6, func(idx int, now time.Time, res eval.Results) error {
|
||||
err = evaluator.Eval(context.Background(), from, time.Second, 6, func(idx int, now time.Time, res eval.Results) (bool, error) {
|
||||
if idx == 5 {
|
||||
return expectedError
|
||||
return false, expectedError
|
||||
}
|
||||
return nil
|
||||
return true, nil
|
||||
})
|
||||
require.ErrorIs(t, err, expectedError)
|
||||
})
|
||||
t.Run("should stop if callback does not want to continue", func(t *testing.T) {
|
||||
evaluated := 0
|
||||
err = evaluator.Eval(context.Background(), from, time.Second, 6, func(idx int, now time.Time, res eval.Results) (bool, error) {
|
||||
evaluated++
|
||||
return evaluated < 2, nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, evaluated)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,10 +18,13 @@ func (d *queryEvaluator) Eval(ctx context.Context, from time.Time, interval time
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = callback(idx, now, results)
|
||||
cont, err := callback(idx, now, results)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !cont {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -31,9 +31,9 @@ func TestQueryEvaluator_Eval(t *testing.T) {
|
||||
|
||||
intervals := make([]time.Time, times)
|
||||
|
||||
err := evaluator.Eval(ctx, from, interval, times, func(idx int, now time.Time, results eval.Results) error {
|
||||
err := evaluator.Eval(ctx, from, interval, times, func(idx int, now time.Time, results eval.Results) (bool, error) {
|
||||
intervals[idx] = now
|
||||
return nil
|
||||
return true, nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, intervals, times)
|
||||
@@ -49,7 +49,7 @@ func TestQueryEvaluator_Eval(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("should stop evaluation if error", func(t *testing.T) {
|
||||
t.Run("should stop evaluation", func(t *testing.T) {
|
||||
t.Run("when evaluation fails", func(t *testing.T) {
|
||||
m := &eval_mocks.ConditionEvaluatorMock{}
|
||||
expectedResults := eval.Results{}
|
||||
@@ -62,9 +62,9 @@ func TestQueryEvaluator_Eval(t *testing.T) {
|
||||
|
||||
intervals := make([]time.Time, 0, times)
|
||||
|
||||
err := evaluator.Eval(ctx, from, interval, times, func(idx int, now time.Time, results eval.Results) error {
|
||||
err := evaluator.Eval(ctx, from, interval, times, func(idx int, now time.Time, results eval.Results) (bool, error) {
|
||||
intervals = append(intervals, now)
|
||||
return nil
|
||||
return true, nil
|
||||
})
|
||||
require.ErrorIs(t, err, expectedError)
|
||||
require.Len(t, intervals, 3)
|
||||
@@ -81,14 +81,31 @@ func TestQueryEvaluator_Eval(t *testing.T) {
|
||||
|
||||
intervals := make([]time.Time, 0, times)
|
||||
|
||||
err := evaluator.Eval(ctx, from, interval, times, func(idx int, now time.Time, results eval.Results) error {
|
||||
err := evaluator.Eval(ctx, from, interval, times, func(idx int, now time.Time, results eval.Results) (bool, error) {
|
||||
if len(intervals) > 3 {
|
||||
return expectedError
|
||||
return false, expectedError
|
||||
}
|
||||
intervals = append(intervals, now)
|
||||
return nil
|
||||
return true, nil
|
||||
})
|
||||
require.ErrorIs(t, err, expectedError)
|
||||
})
|
||||
|
||||
t.Run("when callback does not want to continue", func(t *testing.T) {
|
||||
m := &eval_mocks.ConditionEvaluatorMock{}
|
||||
expectedResults := eval.Results{}
|
||||
m.EXPECT().Evaluate(mock.Anything, mock.Anything).Return(expectedResults, nil)
|
||||
evaluator := queryEvaluator{
|
||||
eval: m,
|
||||
}
|
||||
|
||||
evaluated := 0
|
||||
err := evaluator.Eval(ctx, from, interval, times, func(idx int, now time.Time, results eval.Results) (bool, error) {
|
||||
evaluated++
|
||||
return evaluated <= 2, nil
|
||||
})
|
||||
require.NoError(t, err, nil)
|
||||
require.Equal(t, 3, evaluated)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -480,6 +480,10 @@ func (alertRule *AlertRule) GetPanelID() int64 {
|
||||
return -1
|
||||
}
|
||||
|
||||
func (alertRule *AlertRule) GetInterval() time.Duration {
|
||||
return time.Duration(alertRule.IntervalSeconds) * time.Second
|
||||
}
|
||||
|
||||
type LabelOption func(map[string]string)
|
||||
|
||||
func WithoutInternalLabels() LabelOption {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
@@ -13,6 +14,10 @@ import (
|
||||
// JitterStrategy represents a modifier to alert rule timing that affects how evaluations are distributed.
|
||||
type JitterStrategy int
|
||||
|
||||
func (s JitterStrategy) String() string {
|
||||
return [...]string{"never", "by group", "by rule"}[s]
|
||||
}
|
||||
|
||||
const (
|
||||
JitterNever JitterStrategy = iota
|
||||
JitterByGroup
|
||||
@@ -57,6 +62,11 @@ func jitterOffsetInTicks(r *ngmodels.AlertRule, baseInterval time.Duration, stra
|
||||
return res
|
||||
}
|
||||
|
||||
// JitterOffsetInDuration gives the jitter offset for a rule, in terms of a duration relative to its interval and a base interval.
|
||||
func JitterOffsetInDuration(r *ngmodels.AlertRule, baseInterval time.Duration, strategy JitterStrategy) time.Duration {
|
||||
return time.Duration(jitterOffsetInTicks(r, baseInterval, strategy)) * baseInterval
|
||||
}
|
||||
|
||||
func jitterHash(r *ngmodels.AlertRule, strategy JitterStrategy) uint64 {
|
||||
ls := data.Labels{
|
||||
"name": r.RuleGroup,
|
||||
|
||||
@@ -44,7 +44,11 @@ func New(c clock.Clock, interval time.Duration, metric *Metrics, logger log.Logg
|
||||
}
|
||||
|
||||
func getStartTick(clk clock.Clock, interval time.Duration) time.Time {
|
||||
nano := clk.Now().UnixNano()
|
||||
return GetStartTick(clk.Now(), interval)
|
||||
}
|
||||
|
||||
func GetStartTick(t time.Time, interval time.Duration) time.Time {
|
||||
nano := t.UnixNano()
|
||||
return time.Unix(0, nano-(nano%interval.Nanoseconds()))
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
|
||||
const StateHistoryWriteTimeout = time.Minute
|
||||
|
||||
func shouldRecord(transition state.StateTransition) bool {
|
||||
func ShouldRecord(transition state.StateTransition) bool {
|
||||
if !transition.Changed() {
|
||||
return false
|
||||
}
|
||||
@@ -35,9 +35,9 @@ func shouldRecord(transition state.StateTransition) bool {
|
||||
}
|
||||
|
||||
// ShouldRecordAnnotation returns true if an annotation should be created for a given state transition.
|
||||
// This is stricter than shouldRecord to avoid cluttering panels with state transitions.
|
||||
// This is stricter than ShouldRecord to avoid cluttering panels with state transitions.
|
||||
func ShouldRecordAnnotation(t state.StateTransition) bool {
|
||||
if !shouldRecord(t) {
|
||||
if !ShouldRecord(t) {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ func TestShouldRecord(t *testing.T) {
|
||||
}
|
||||
|
||||
t.Run(fmt.Sprintf("%s -> %s should be %v", trans.PreviousFormatted(), trans.Formatted(), !ok), func(t *testing.T) {
|
||||
require.Equal(t, !ok, shouldRecord(trans))
|
||||
require.Equal(t, !ok, ShouldRecord(trans))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,69 @@ const (
|
||||
dfLabels = "labels"
|
||||
)
|
||||
|
||||
// QueryResultBuilder is a builder for a data frame that represents query results from Loki.
|
||||
// It contains three fields: time (timestamp), line (JSON data), and labels (JSON labels).
|
||||
type QueryResultBuilder struct {
|
||||
frame *data.Frame
|
||||
}
|
||||
|
||||
// NewQueryResultBuilder creates a new QueryResultBuilder with the specified capacity.
|
||||
// The capacity is used to pre-allocate the underlying slices for better performance.
|
||||
func NewQueryResultBuilder(capacity int) *QueryResultBuilder {
|
||||
frame := data.NewFrame("states")
|
||||
lbls := data.Labels(map[string]string{})
|
||||
|
||||
// We represent state history as a single merged history, that roughly corresponds to what you get in the Grafana Explore tab when querying Loki directly.
|
||||
// The format is composed of the following vectors:
|
||||
// 1. `time` - timestamp - when the transition happened
|
||||
// 2. `line` - JSON - the full data of the transition
|
||||
// 3. `labels` - JSON - the labels associated with that state transition
|
||||
times := make([]time.Time, 0, capacity)
|
||||
lines := make([]json.RawMessage, 0, capacity)
|
||||
labels := make([]json.RawMessage, 0, capacity)
|
||||
|
||||
frame.Fields = append(frame.Fields, data.NewField(dfTime, lbls, times))
|
||||
frame.Fields = append(frame.Fields, data.NewField(dfLine, lbls, lines))
|
||||
frame.Fields = append(frame.Fields, data.NewField(dfLabels, lbls, labels))
|
||||
|
||||
return &QueryResultBuilder{frame: frame}
|
||||
}
|
||||
|
||||
func (qr QueryResultBuilder) AddRowRaw(timestamp time.Time, line json.RawMessage, labels json.RawMessage) {
|
||||
frame := qr.frame
|
||||
frame.Fields[0].Append(timestamp)
|
||||
frame.Fields[1].Append(line)
|
||||
frame.Fields[2].Append(labels)
|
||||
}
|
||||
|
||||
func (qr QueryResultBuilder) AddRow(timestamp time.Time, line LokiEntry, labels json.RawMessage) error {
|
||||
lineBytes, err := json.Marshal(line)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
qr.AddRowRaw(timestamp, lineBytes, labels)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToFrame converts the QueryResultBuilder back to a data.Frame.
|
||||
func (qr QueryResultBuilder) ToFrame() *data.Frame {
|
||||
return qr.frame
|
||||
}
|
||||
|
||||
func (qr QueryResultBuilder) AddWarn(s string) {
|
||||
m := qr.frame.Meta
|
||||
if m == nil {
|
||||
m = &data.FrameMeta{}
|
||||
qr.frame.SetMeta(m)
|
||||
}
|
||||
m.Notices = append(m.Notices, data.Notice{
|
||||
Severity: data.NoticeSeverityWarning,
|
||||
Text: s,
|
||||
Link: "",
|
||||
Inspect: 0,
|
||||
})
|
||||
}
|
||||
|
||||
const (
|
||||
StateHistoryLabelKey = "from"
|
||||
StateHistoryLabelValue = "state-history"
|
||||
@@ -191,20 +254,7 @@ func (h RemoteLokiBackend) merge(res []lokiclient.Stream, folderUIDToFilter []st
|
||||
totalLen += len(arr.Values)
|
||||
}
|
||||
|
||||
// Create a new slice to store the merged elements.
|
||||
frame := data.NewFrame("states")
|
||||
|
||||
// We merge all series into a single linear history.
|
||||
lbls := data.Labels(map[string]string{})
|
||||
|
||||
// We represent state history as a single merged history, that roughly corresponds to what you get in the Grafana Explore tab when querying Loki directly.
|
||||
// The format is composed of the following vectors:
|
||||
// 1. `time` - timestamp - when the transition happened
|
||||
// 2. `line` - JSON - the full data of the transition
|
||||
// 3. `labels` - JSON - the labels associated with that state transition
|
||||
times := make([]time.Time, 0, totalLen)
|
||||
lines := make([]json.RawMessage, 0, totalLen)
|
||||
labels := make([]json.RawMessage, 0, totalLen)
|
||||
queryResult := NewQueryResultBuilder(totalLen)
|
||||
|
||||
// Initialize a slice of pointers to the current position in each array.
|
||||
pointers := make([]int, len(res))
|
||||
@@ -259,17 +309,10 @@ func (h RemoteLokiBackend) merge(res []lokiclient.Stream, folderUIDToFilter []st
|
||||
pointers[minElStreamIdx]++
|
||||
continue
|
||||
}
|
||||
times = append(times, time.Unix(0, tsNano))
|
||||
labels = append(labels, lblsJson)
|
||||
lines = append(lines, json.RawMessage(entryBytes))
|
||||
queryResult.AddRowRaw(time.Unix(0, tsNano), entryBytes, lblsJson)
|
||||
pointers[minElStreamIdx]++
|
||||
}
|
||||
|
||||
frame.Fields = append(frame.Fields, data.NewField(dfTime, lbls, times))
|
||||
frame.Fields = append(frame.Fields, data.NewField(dfLine, lbls, lines))
|
||||
frame.Fields = append(frame.Fields, data.NewField(dfLabels, lbls, labels))
|
||||
|
||||
return frame, nil
|
||||
return queryResult.ToFrame(), nil
|
||||
}
|
||||
|
||||
func StatesToStream(rule history_model.RuleMeta, states []state.StateTransition, externalLabels map[string]string, logger log.Logger) lokiclient.Stream {
|
||||
@@ -282,28 +325,11 @@ func StatesToStream(rule history_model.RuleMeta, states []state.StateTransition,
|
||||
|
||||
samples := make([]lokiclient.Sample, 0, len(states))
|
||||
for _, state := range states {
|
||||
if !shouldRecord(state) {
|
||||
if !ShouldRecord(state) {
|
||||
continue
|
||||
}
|
||||
|
||||
sanitizedLabels := removePrivateLabels(state.Labels)
|
||||
entry := LokiEntry{
|
||||
SchemaVersion: 1,
|
||||
Previous: state.PreviousFormatted(),
|
||||
Current: state.Formatted(),
|
||||
Values: valuesAsDataBlob(state.State),
|
||||
Condition: rule.Condition,
|
||||
DashboardUID: rule.DashboardUID,
|
||||
PanelID: rule.PanelID,
|
||||
Fingerprint: labelFingerprint(sanitizedLabels),
|
||||
RuleTitle: rule.Title,
|
||||
RuleID: rule.ID,
|
||||
RuleUID: rule.UID,
|
||||
InstanceLabels: sanitizedLabels,
|
||||
}
|
||||
if state.State.State == eval.Error {
|
||||
entry.Error = state.Error.Error()
|
||||
}
|
||||
entry := StateTransitionToLokiEntry(rule, state)
|
||||
|
||||
jsn, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
@@ -324,6 +350,28 @@ func StatesToStream(rule history_model.RuleMeta, states []state.StateTransition,
|
||||
}
|
||||
}
|
||||
|
||||
func StateTransitionToLokiEntry(rule history_model.RuleMeta, state state.StateTransition) LokiEntry {
|
||||
sanitizedLabels := removePrivateLabels(state.Labels)
|
||||
entry := LokiEntry{
|
||||
SchemaVersion: 1,
|
||||
Previous: state.PreviousFormatted(),
|
||||
Current: state.Formatted(),
|
||||
Values: valuesAsDataBlob(state.State),
|
||||
Condition: rule.Condition,
|
||||
DashboardUID: rule.DashboardUID,
|
||||
PanelID: rule.PanelID,
|
||||
Fingerprint: labelFingerprint(sanitizedLabels),
|
||||
RuleTitle: rule.Title,
|
||||
RuleID: rule.ID,
|
||||
RuleUID: rule.UID,
|
||||
InstanceLabels: sanitizedLabels,
|
||||
}
|
||||
if state.State.State == eval.Error && state.Error != nil {
|
||||
entry.Error = state.Error.Error()
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
func (h *RemoteLokiBackend) recordStreams(ctx context.Context, stream lokiclient.Stream, logger log.Logger) error {
|
||||
if err := h.client.Push(ctx, []lokiclient.Stream{stream}); err != nil {
|
||||
return err
|
||||
|
||||
@@ -156,6 +156,8 @@ type UnifiedAlertingSettings struct {
|
||||
|
||||
// AlertmanagerMaxTemplateOutputSize specifies the maximum allowed size for rendered template output in bytes.
|
||||
AlertmanagerMaxTemplateOutputSize int64
|
||||
|
||||
BacktestingMaxEvaluations int
|
||||
}
|
||||
|
||||
type RecordingRuleSettings struct {
|
||||
@@ -594,6 +596,11 @@ func (cfg *Cfg) ReadUnifiedAlertingSettings(iniFile *ini.File) error {
|
||||
return fmt.Errorf("setting 'alertmanager_max_template_output_bytes' is invalid, only 0 or a positive integer are allowed")
|
||||
}
|
||||
|
||||
uaCfg.BacktestingMaxEvaluations = ua.Key("backtesting_max_evaluations").MustInt(100)
|
||||
if uaCfg.BacktestingMaxEvaluations < 0 {
|
||||
uaCfg.BacktestingMaxEvaluations = 100
|
||||
}
|
||||
|
||||
cfg.UnifiedAlerting = uaCfg
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/fields"
|
||||
"k8s.io/apimachinery/pkg/selection"
|
||||
"k8s.io/apiserver/pkg/storage"
|
||||
|
||||
@@ -120,6 +121,22 @@ func toListRequest(k *resourcepb.ResourceKey, opts storage.ListOptions) (*resour
|
||||
if opts.Predicate.Field != nil && !opts.Predicate.Field.Empty() {
|
||||
requirements := opts.Predicate.Field.Requirements()
|
||||
for _, r := range requirements {
|
||||
// NOTE: requires: scheme.AddFieldLabelConversionFunc(
|
||||
if r.Field == "search.ownerReference" {
|
||||
if len(requirements) > 1 {
|
||||
return nil, predicate, apierrors.NewBadRequest("search.ownerReference only supports one requirement")
|
||||
}
|
||||
req.Options.Fields = []*resourcepb.Requirement{{
|
||||
Key: r.Field,
|
||||
Operator: string(r.Operator),
|
||||
Values: []string{r.Value},
|
||||
}}
|
||||
|
||||
// with only one requirement, we do not need to transform the predicate to exclude this pseudo field
|
||||
predicate.Field = fields.Everything()
|
||||
break
|
||||
}
|
||||
|
||||
requirement := &resourcepb.Requirement{Key: r.Field, Operator: string(r.Operator)}
|
||||
if r.Value != "" {
|
||||
requirement.Values = append(requirement.Values, r.Value)
|
||||
|
||||
@@ -101,6 +101,11 @@ type IndexableDocument struct {
|
||||
// metadata, annotations, or external data linked at index time
|
||||
Fields map[string]any `json:"fields,omitempty"`
|
||||
|
||||
// The list of owner references,
|
||||
// each value is of the form {group}/{kind}/{name}
|
||||
// ex: iam.grafana.app/Team/abc-engineering
|
||||
OwnerReferences []string `json:"ownerReferences,omitempty"`
|
||||
|
||||
// Maintain a list of resource references.
|
||||
// Someday this will likely be part of https://github.com/grafana/gamma
|
||||
References ResourceReferences `json:"references,omitempty"`
|
||||
@@ -217,6 +222,10 @@ func NewIndexableDocument(key *resourcepb.ResourceKey, rv int64, obj utils.Grafa
|
||||
if err != nil && tt != nil {
|
||||
doc.Updated = tt.UnixMilli()
|
||||
}
|
||||
for _, owner := range obj.GetOwnerReferences() {
|
||||
gv, _ := schema.ParseGroupVersion(owner.APIVersion)
|
||||
doc.OwnerReferences = append(doc.OwnerReferences, fmt.Sprintf("%s/%s/%s", gv.Group, owner.Kind, owner.Name))
|
||||
}
|
||||
return doc.UpdateCopyFields()
|
||||
}
|
||||
|
||||
@@ -295,6 +304,7 @@ const SEARCH_FIELD_TITLE_PHRASE = "title_phrase" // filtering/sorting on title b
|
||||
const SEARCH_FIELD_DESCRIPTION = "description"
|
||||
const SEARCH_FIELD_TAGS = "tags"
|
||||
const SEARCH_FIELD_LABELS = "labels" // All labels, not a specific one
|
||||
const SEARCH_FIELD_OWNER_REFERENCES = "ownerReferences"
|
||||
|
||||
const SEARCH_FIELD_FOLDER = "folder"
|
||||
const SEARCH_FIELD_CREATED = "created"
|
||||
|
||||
@@ -48,6 +48,10 @@ func TestStandardDocumentBuilder(t *testing.T) {
|
||||
"id": "something"
|
||||
},
|
||||
"managedBy": "repo:something",
|
||||
"ownerReferences": [
|
||||
"iam.grafana.app/Team/engineering",
|
||||
"iam.grafana.app/User/test"
|
||||
],
|
||||
"source": {
|
||||
"path": "path/in/system.json",
|
||||
"checksum": "xyz"
|
||||
|
||||
+33
-5
@@ -16,10 +16,41 @@ func (s *server) tryFieldSelector(ctx context.Context, req *resourcepb.ListReque
|
||||
for _, v := range req.Options.Fields {
|
||||
if v.Key == "metadata.name" && v.Operator == `=` {
|
||||
names = v.Values
|
||||
continue
|
||||
}
|
||||
|
||||
// TODO: support other field selectors
|
||||
// Search by owner reference
|
||||
if v.Key == "search.ownerReference" {
|
||||
if len(req.Options.Fields) > 1 {
|
||||
return &resourcepb.ListResponse{
|
||||
Error: NewBadRequestError("multiple fields found"),
|
||||
}
|
||||
}
|
||||
|
||||
results, err := s.Search(ctx, &resourcepb.ResourceSearchRequest{
|
||||
Fields: []string{}, // no extra fields
|
||||
Options: &resourcepb.ListOptions{
|
||||
Key: req.Options.Key,
|
||||
Fields: []*resourcepb.Requirement{{
|
||||
Key: SEARCH_FIELD_OWNER_REFERENCES,
|
||||
Operator: v.Operator,
|
||||
Values: v.Values,
|
||||
}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return &resourcepb.ListResponse{
|
||||
Error: AsErrorResult(err),
|
||||
}
|
||||
}
|
||||
if len(results.Results.Rows) < 1 { // nothing found
|
||||
return &resourcepb.ListResponse{
|
||||
ResourceVersion: 1, // TODO, search result should include when it was indexed
|
||||
}
|
||||
}
|
||||
for _, res := range results.Results.Rows {
|
||||
names = append(names, res.Key.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The required names
|
||||
@@ -42,9 +73,6 @@ func (s *server) tryFieldSelector(ctx context.Context, req *resourcepb.ListReque
|
||||
Value: found.Value,
|
||||
ResourceVersion: found.ResourceVersion,
|
||||
})
|
||||
if found.ResourceVersion > rsp.ResourceVersion {
|
||||
rsp.ResourceVersion = found.ResourceVersion
|
||||
}
|
||||
}
|
||||
}
|
||||
return rsp
|
||||
@@ -22,7 +22,6 @@ import (
|
||||
|
||||
claims "github.com/grafana/authlib/types"
|
||||
"github.com/grafana/dskit/backoff"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/validation"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
|
||||
@@ -13,7 +13,16 @@
|
||||
"grafana.app/repoPath": "path/in/system.json",
|
||||
"grafana.app/repoHash": "xyz",
|
||||
"grafana.app/updatedTimestamp": "2024-07-01T10:11:12Z"
|
||||
}
|
||||
},
|
||||
"ownerReferences": [{
|
||||
"apiVersion": "iam.grafana.app/v1alpha1",
|
||||
"kind": "Team",
|
||||
"name": "engineering"
|
||||
}, {
|
||||
"apiVersion": "iam.grafana.app/v1alpha1",
|
||||
"kind": "User",
|
||||
"name": "test"
|
||||
}]
|
||||
},
|
||||
"spec": {
|
||||
"title": "Test Playlist from Unified Storage",
|
||||
|
||||
@@ -1559,17 +1559,20 @@ var termFields = []string{
|
||||
// Convert a "requirement" into a bleve query
|
||||
func requirementQuery(req *resourcepb.Requirement, prefix string) (query.Query, *resourcepb.ErrorResult) {
|
||||
switch selection.Operator(req.Operator) {
|
||||
case selection.Equals, selection.DoubleEquals:
|
||||
case selection.Equals:
|
||||
if len(req.Values) == 0 {
|
||||
return query.NewMatchAllQuery(), nil
|
||||
}
|
||||
|
||||
// FIXME: special case for login and email to use term query only because those fields are using keyword analyzer
|
||||
// This should be fixed by using the info from the schema
|
||||
if (req.Key == "login" || req.Key == "email") && len(req.Values) == 1 {
|
||||
tq := bleve.NewTermQuery(req.Values[0])
|
||||
tq.SetField(prefix + req.Key)
|
||||
return tq, nil
|
||||
if len(req.Values) == 1 {
|
||||
switch req.Key {
|
||||
case "login", "email", resource.SEARCH_FIELD_OWNER_REFERENCES:
|
||||
tq := bleve.NewTermQuery(req.Values[0])
|
||||
tq.SetField(prefix + req.Key)
|
||||
return tq, nil
|
||||
}
|
||||
}
|
||||
|
||||
if len(req.Values) == 1 {
|
||||
@@ -1585,11 +1588,6 @@ func requirementQuery(req *resourcepb.Requirement, prefix string) (query.Query,
|
||||
|
||||
return query.NewConjunctionQuery(conjuncts), nil
|
||||
|
||||
case selection.NotEquals:
|
||||
case selection.DoesNotExist:
|
||||
case selection.GreaterThan:
|
||||
case selection.LessThan:
|
||||
case selection.Exists:
|
||||
case selection.In:
|
||||
if len(req.Values) == 0 {
|
||||
return query.NewMatchAllQuery(), nil
|
||||
@@ -1622,6 +1620,14 @@ func requirementQuery(req *resourcepb.Requirement, prefix string) (query.Query,
|
||||
boolQuery.AddMust(notEmptyQuery)
|
||||
|
||||
return boolQuery, nil
|
||||
|
||||
// will fall through to the BadRequestError
|
||||
case selection.DoubleEquals:
|
||||
case selection.NotEquals:
|
||||
case selection.DoesNotExist:
|
||||
case selection.GreaterThan:
|
||||
case selection.LessThan:
|
||||
case selection.Exists:
|
||||
}
|
||||
return nil, resource.NewBadRequestError(
|
||||
fmt.Sprintf("unsupported query operation (%s %s %v)", req.Key, req.Operator, req.Values),
|
||||
|
||||
@@ -60,7 +60,7 @@ func getBleveDocMappings(fields resource.SearchableDocumentFields) *mapping.Docu
|
||||
}
|
||||
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_DESCRIPTION, descriptionMapping)
|
||||
|
||||
tagsMapping := &mapping.FieldMapping{
|
||||
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_TAGS, &mapping.FieldMapping{
|
||||
Name: resource.SEARCH_FIELD_TAGS,
|
||||
Type: "text",
|
||||
Analyzer: keyword.Name,
|
||||
@@ -69,8 +69,18 @@ func getBleveDocMappings(fields resource.SearchableDocumentFields) *mapping.Docu
|
||||
IncludeTermVectors: false,
|
||||
IncludeInAll: true,
|
||||
DocValues: false,
|
||||
}
|
||||
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_TAGS, tagsMapping)
|
||||
})
|
||||
|
||||
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_OWNER_REFERENCES, &mapping.FieldMapping{
|
||||
Name: resource.SEARCH_FIELD_OWNER_REFERENCES,
|
||||
Type: "text",
|
||||
Analyzer: keyword.Name,
|
||||
Store: false,
|
||||
Index: true,
|
||||
IncludeTermVectors: false,
|
||||
IncludeInAll: false,
|
||||
DocValues: false,
|
||||
})
|
||||
|
||||
folderMapping := &mapping.FieldMapping{
|
||||
Name: resource.SEARCH_FIELD_FOLDER,
|
||||
|
||||
@@ -36,6 +36,7 @@ func TestDocumentMapping(t *testing.T) {
|
||||
Checksum: "ooo",
|
||||
TimestampMillis: 1234,
|
||||
},
|
||||
OwnerReferences: []string{"iam.grafana.app/Team/devops", "iam.grafana.app/User/xyz"},
|
||||
}
|
||||
data.UpdateCopyFields()
|
||||
|
||||
@@ -49,5 +50,5 @@ func TestDocumentMapping(t *testing.T) {
|
||||
|
||||
fmt.Printf("DOC: fields %d\n", len(doc.Fields))
|
||||
fmt.Printf("DOC: size %d\n", doc.Size())
|
||||
require.Equal(t, 17, len(doc.Fields))
|
||||
require.Equal(t, 19, len(doc.Fields))
|
||||
}
|
||||
|
||||
@@ -16,5 +16,9 @@
|
||||
"kind": "repo",
|
||||
"id": "MyGIT"
|
||||
},
|
||||
"managedBy": "repo:MyGIT"
|
||||
"managedBy": "repo:MyGIT",
|
||||
"ownerReferences": [
|
||||
"iam.grafana.app/Team/engineering",
|
||||
"iam.grafana.app/User/test"
|
||||
]
|
||||
}
|
||||
@@ -9,7 +9,16 @@
|
||||
"annotations": {
|
||||
"grafana.app/createdBy": "user:1",
|
||||
"grafana.app/repoName": "MyGIT"
|
||||
}
|
||||
},
|
||||
"ownerReferences": [{
|
||||
"apiVersion": "iam.grafana.app/v1alpha1",
|
||||
"kind": "Team",
|
||||
"name": "engineering"
|
||||
}, {
|
||||
"apiVersion": "iam.grafana.app/v1alpha1",
|
||||
"kind": "User",
|
||||
"name": "test"
|
||||
}]
|
||||
},
|
||||
"spec": {
|
||||
"title": "test-aaa"
|
||||
|
||||
@@ -68,7 +68,7 @@ func TestBacktesting(t *testing.T) {
|
||||
require.Truef(t, ok, "The data file does not contain a field `data`")
|
||||
|
||||
status, body := apiCli.SubmitRuleForBacktesting(t, request)
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Equalf(t, http.StatusOK, status, "Response: %s", body)
|
||||
var result data.Frame
|
||||
require.NoErrorf(t, json.Unmarshal([]byte(body), &result), "cannot parse response to data frame")
|
||||
})
|
||||
@@ -107,6 +107,7 @@ func TestBacktesting(t *testing.T) {
|
||||
resourcepermissions.SetResourcePermissionCommand{
|
||||
Actions: []string{
|
||||
accesscontrol.ActionAlertingRuleRead,
|
||||
accesscontrol.ActionAlertingRuleUpdate,
|
||||
},
|
||||
Resource: "folders",
|
||||
ResourceID: "*",
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
},
|
||||
"condition": "A",
|
||||
"no_data_state": "Alerting",
|
||||
"title": "test-rule-backtesting-data",
|
||||
"rule_group": "test-group",
|
||||
"namespace_uid": "test-namespace",
|
||||
"data": [
|
||||
{
|
||||
"refId": "A",
|
||||
@@ -193,6 +196,9 @@
|
||||
},
|
||||
"condition": "C",
|
||||
"no_data_state": "Alerting",
|
||||
"title": "test-rule-backtesting-data",
|
||||
"rule_group": "test-group",
|
||||
"namespace_uid": "test-namespace",
|
||||
"data": [
|
||||
{
|
||||
"refId": "A",
|
||||
|
||||
@@ -2202,3 +2202,79 @@ func TestIntegrationProvisionedFolderPropagatesLabelsAndAnnotations(t *testing.T
|
||||
require.Equal(t, expectedLabels, accessor.GetLabels())
|
||||
require.Equal(t, expectedAnnotations, accessor.GetAnnotations())
|
||||
}
|
||||
|
||||
// Test finding folders with an owner
|
||||
func TestIntegrationFolderWithOwner(t *testing.T) {
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
DisableAnonymous: true,
|
||||
AppModeProduction: true,
|
||||
APIServerStorageType: "unified",
|
||||
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
|
||||
folders.RESOURCEGROUP: {
|
||||
DualWriterMode: grafanarest.Mode5,
|
||||
},
|
||||
"dashboards.dashboard.grafana.app": {
|
||||
DualWriterMode: grafanarest.Mode5,
|
||||
},
|
||||
},
|
||||
EnableFeatureToggles: []string{
|
||||
featuremgmt.FlagUnifiedStorageSearch,
|
||||
},
|
||||
})
|
||||
client := helper.GetResourceClient(apis.ResourceClientArgs{
|
||||
User: helper.Org1.Admin,
|
||||
GVR: gvr,
|
||||
})
|
||||
|
||||
// Without owner
|
||||
folder := &unstructured.Unstructured{
|
||||
Object: map[string]any{
|
||||
"spec": map[string]any{
|
||||
"title": "Folder without owner",
|
||||
},
|
||||
},
|
||||
}
|
||||
folder.SetName("folderA")
|
||||
out, err := client.Resource.Create(context.Background(), folder, metav1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, folder.GetName(), out.GetName())
|
||||
|
||||
// with owner
|
||||
folder = &unstructured.Unstructured{
|
||||
Object: map[string]any{
|
||||
"spec": map[string]any{
|
||||
"title": "Folder with owner",
|
||||
},
|
||||
},
|
||||
}
|
||||
folder.SetName("folderB")
|
||||
folder.SetOwnerReferences([]metav1.OwnerReference{{
|
||||
APIVersion: "iam.grafana.app/v0alpha1",
|
||||
Kind: "Team",
|
||||
Name: "engineering",
|
||||
UID: "123456", // required by k8s
|
||||
}})
|
||||
out, err = client.Resource.Create(context.Background(), folder, metav1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, folder.GetName(), out.GetName())
|
||||
|
||||
// Get everything
|
||||
results, err := client.Resource.List(context.Background(), metav1.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"folderA", "folderB"}, getNames(results.Items))
|
||||
|
||||
// Find results with a specific owner
|
||||
results, err = client.Resource.List(context.Background(), metav1.ListOptions{
|
||||
FieldSelector: "search.ownerReference=iam.grafana.app/Team/engineering",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"folderB"}, getNames(results.Items))
|
||||
}
|
||||
|
||||
func getNames(items []unstructured.Unstructured) []string {
|
||||
names := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
names = append(names, item.GetName())
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
@@ -1873,6 +1873,25 @@
|
||||
"format": "int64"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ownerReference",
|
||||
"in": "query",
|
||||
"description": "filter by owner reference in the format {Group}/{Kind}/{Name}",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"examples": {
|
||||
"": {},
|
||||
"team": {
|
||||
"summary": "Team owner reference",
|
||||
"value": "iam.grafana.app/Team/xyz"
|
||||
},
|
||||
"user": {
|
||||
"summary": "User owner reference",
|
||||
"value": "iam.grafana.app/User/abc"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "explain",
|
||||
"in": "query",
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { DataFrameJSON } from '@grafana/data';
|
||||
import { AlertQuery, GrafanaAlertStateDecision, Labels } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { alertingApi } from './alertingApi';
|
||||
|
||||
/**
|
||||
* Request body for the backtest API matching the BacktestConfig struct in the backend
|
||||
*/
|
||||
export interface BacktestRequest {
|
||||
// Required time range fields
|
||||
from: string; // ISO 8601 timestamp
|
||||
to: string; // ISO 8601 timestamp
|
||||
interval: string; // e.g., "1m", "5m"
|
||||
|
||||
// Required alert definition fields
|
||||
condition: string;
|
||||
data: AlertQuery[];
|
||||
title: string;
|
||||
no_data_state?: GrafanaAlertStateDecision;
|
||||
exec_err_state?: GrafanaAlertStateDecision;
|
||||
|
||||
// Optional duration fields
|
||||
for?: string;
|
||||
keep_firing_for?: string;
|
||||
|
||||
// Optional metadata fields
|
||||
labels?: Labels;
|
||||
missing_series_evals_to_resolve?: number;
|
||||
|
||||
// Optional rule identification fields
|
||||
uid?: string;
|
||||
rule_group?: string;
|
||||
namespace_uid?: string;
|
||||
}
|
||||
|
||||
export const BACKTEST_URL = '/api/v1/rule/backtest';
|
||||
|
||||
export const backtestApi = alertingApi.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
runBacktest: build.mutation<DataFrameJSON, BacktestRequest>({
|
||||
query: (requestBody) => ({
|
||||
url: BACKTEST_URL,
|
||||
method: 'POST',
|
||||
body: requestBody,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const { useRunBacktestMutation } = backtestApi;
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { TimeRange, rangeUtil } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { Button, Drawer, Dropdown, Menu, MenuItem } from '@grafana/ui';
|
||||
|
||||
import { RuleFormValues } from '../../types/rule-form';
|
||||
|
||||
import { BacktestPanel } from './BacktestPanel';
|
||||
|
||||
interface BacktestDropdownButtonProps {
|
||||
ruleDefinition: RuleFormValues;
|
||||
}
|
||||
|
||||
export function BacktestDropdownButton({ ruleDefinition }: BacktestDropdownButtonProps) {
|
||||
const [isBacktestPanelOpen, setIsBacktestPanelOpen] = useState(false);
|
||||
const [backtestTimeRange, setBacktestTimeRange] = useState<TimeRange>();
|
||||
|
||||
const handleTimeRangeSelect = useCallback((rawFrom: string) => {
|
||||
const timeRange = rangeUtil.convertRawToRange({ from: rawFrom, to: 'now' });
|
||||
setBacktestTimeRange(timeRange);
|
||||
setIsBacktestPanelOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCustomSelect = useCallback(() => {
|
||||
setBacktestTimeRange(undefined);
|
||||
setIsBacktestPanelOpen(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dropdown
|
||||
overlay={
|
||||
<Menu>
|
||||
<MenuItem
|
||||
label={t('alerting.queryAndExpressionsStep.last15m', 'Last 15 minutes')}
|
||||
onClick={() => handleTimeRangeSelect('now-15m')}
|
||||
/>
|
||||
<MenuItem
|
||||
label={t('alerting.queryAndExpressionsStep.last1h', 'Last 1 hour')}
|
||||
onClick={() => handleTimeRangeSelect('now-1h')}
|
||||
/>
|
||||
<MenuItem label={t('alerting.queryAndExpressionsStep.custom', 'Custom')} onClick={handleCustomSelect} />
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<Button icon="bug" variant="secondary">
|
||||
<Trans i18nKey="alerting.queryAndExpressionsStep.testRule">Test Rule</Trans>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
|
||||
{isBacktestPanelOpen && (
|
||||
<Drawer
|
||||
title={t('alerting.backtest.panel-title', 'Rule Retroactive Testing')}
|
||||
onClose={() => setIsBacktestPanelOpen(false)}
|
||||
size="md"
|
||||
>
|
||||
<BacktestPanel ruleDefinition={ruleDefinition} initialTimeRange={backtestTimeRange} />
|
||||
</Drawer>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { fromPairs, isEmpty, isEqual } from 'lodash';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { AlertLabels } from '@grafana/alerting/unstable';
|
||||
import { DataFrameJSON, GrafanaTheme2, TimeRange, rangeUtil } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import {
|
||||
Alert,
|
||||
Icon,
|
||||
LoadingPlaceholder,
|
||||
RefreshPicker,
|
||||
Stack,
|
||||
Text,
|
||||
TimeRangePicker,
|
||||
Tooltip,
|
||||
useStyles2,
|
||||
} from '@grafana/ui';
|
||||
|
||||
import { useRunBacktestMutation } from '../../api/backtestApi';
|
||||
import { RuleFormValues } from '../../types/rule-form';
|
||||
import { combineMatcherStrings } from '../../utils/alertmanager';
|
||||
import { messageFromError } from '../../utils/redux';
|
||||
import { formValuesToRulerGrafanaRuleDTO } from '../../utils/rule-form';
|
||||
import { LogRecordViewerByTimestamp } from '../rules/state-history/LogRecordViewer';
|
||||
import { LogTimelineViewer } from '../rules/state-history/LogTimelineViewer';
|
||||
import { useFrameSubset } from '../rules/state-history/LokiStateHistory';
|
||||
import { useRuleHistoryRecords } from '../rules/state-history/useRuleHistoryRecords';
|
||||
|
||||
interface BacktestPanelProps {
|
||||
ruleDefinition: RuleFormValues;
|
||||
initialTimeRange?: TimeRange;
|
||||
}
|
||||
|
||||
export function BacktestPanel({ ruleDefinition, initialTimeRange }: BacktestPanelProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>(
|
||||
initialTimeRange || rangeUtil.convertRawToRange({ from: 'now-15m', to: 'now' })
|
||||
);
|
||||
const [stateHistory, setStateHistory] = useState<DataFrameJSON>();
|
||||
const [instancesFilter, setInstancesFilter] = useState('');
|
||||
const shouldRunInitialBacktest = useRef(!!initialTimeRange);
|
||||
|
||||
const [runBacktest, { isLoading, error: mutationError }] = useRunBacktestMutation();
|
||||
|
||||
const handleRunBacktest = useCallback(async () => {
|
||||
// Convert form values to the proper AlertRule format
|
||||
const alertRule = formValuesToRulerGrafanaRuleDTO(ruleDefinition);
|
||||
|
||||
// Build requestBody matching BacktestConfig struct
|
||||
const requestBody = {
|
||||
// Required time range fields
|
||||
from: timeRange.from.toISOString(),
|
||||
to: timeRange.to.toISOString(),
|
||||
interval: ruleDefinition.evaluateEvery,
|
||||
|
||||
// Required alert definition fields
|
||||
condition: alertRule.grafana_alert.condition,
|
||||
data: alertRule.grafana_alert.data,
|
||||
title: alertRule.grafana_alert.title,
|
||||
no_data_state: alertRule.grafana_alert.no_data_state,
|
||||
exec_err_state: alertRule.grafana_alert.exec_err_state,
|
||||
|
||||
// Optional duration fields
|
||||
for: alertRule.for,
|
||||
keep_firing_for: alertRule.keep_firing_for,
|
||||
|
||||
// Optional metadata fields
|
||||
labels: alertRule.labels,
|
||||
missing_series_evals_to_resolve: alertRule.grafana_alert.missing_series_evals_to_resolve,
|
||||
|
||||
// Optional rule identification fields
|
||||
uid: alertRule.grafana_alert.uid,
|
||||
rule_group: ruleDefinition.group,
|
||||
namespace_uid: ruleDefinition.folder?.uid,
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await runBacktest(requestBody).unwrap();
|
||||
setStateHistory(result);
|
||||
} catch (err) {
|
||||
// Error is handled by RTK Query and available via mutationError
|
||||
}
|
||||
}, [ruleDefinition, timeRange, runBacktest]);
|
||||
|
||||
// Update time range when initialTimeRange prop changes
|
||||
useEffect(() => {
|
||||
if (initialTimeRange) {
|
||||
setTimeRange(initialTimeRange);
|
||||
}
|
||||
}, [initialTimeRange]);
|
||||
|
||||
// Run backtest once after initial mount when timeRange is synchronized with initialTimeRange
|
||||
useEffect(() => {
|
||||
if (shouldRunInitialBacktest.current && initialTimeRange && isEqual(timeRange, initialTimeRange)) {
|
||||
shouldRunInitialBacktest.current = false;
|
||||
handleRunBacktest();
|
||||
}
|
||||
}, [initialTimeRange, timeRange, handleRunBacktest]);
|
||||
|
||||
const { dataFrames, historyRecords, commonLabels } = useRuleHistoryRecords(stateHistory, instancesFilter);
|
||||
|
||||
const { frameSubset, frameTimeRange } = useFrameSubset(dataFrames);
|
||||
|
||||
const onLogRecordLabelClick = useCallback(
|
||||
(label: string) => {
|
||||
const matcherString = combineMatcherStrings(instancesFilter, label);
|
||||
setInstancesFilter(matcherString);
|
||||
},
|
||||
[instancesFilter]
|
||||
);
|
||||
|
||||
const hasResults = stateHistory !== undefined;
|
||||
|
||||
const notices = stateHistory?.schema?.meta?.notices || [];
|
||||
const errorMessage = mutationError ? messageFromError(mutationError) : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Stack direction="row" alignItems="flex-end" justifyContent="flex-end">
|
||||
<TimeRangePicker
|
||||
value={timeRange}
|
||||
onChange={setTimeRange}
|
||||
onChangeTimeZone={() => {}}
|
||||
onMoveBackward={() => {}}
|
||||
onMoveForward={() => {}}
|
||||
onZoom={() => {}}
|
||||
/>
|
||||
<RefreshPicker
|
||||
onRefresh={handleRunBacktest}
|
||||
onIntervalChanged={() => {}}
|
||||
isLoading={isLoading}
|
||||
noIntervalPicker={true}
|
||||
/>
|
||||
</Stack>
|
||||
<div className={styles.scrollableContent}>
|
||||
{isLoading && <LoadingPlaceholder text={t('alerting.backtest.loading', 'Running backtest...')} />}
|
||||
|
||||
{errorMessage && (
|
||||
<Alert title={t('alerting.backtest.error-title', 'Failed to run backtest')}>{errorMessage}</Alert>
|
||||
)}
|
||||
|
||||
{!isLoading && !mutationError && hasResults && notices.length > 0 && (
|
||||
<Stack direction="column" gap={1}>
|
||||
{notices.map((notice, index) => (
|
||||
<Alert key={index} severity={notice.severity || 'info'} title="">
|
||||
{notice.text}
|
||||
</Alert>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{!isLoading && !mutationError && hasResults && (
|
||||
<div className={styles.resultsContainer}>
|
||||
{!isEmpty(commonLabels) && (
|
||||
<Stack gap={1} alignItems="center" wrap="wrap">
|
||||
<Stack gap={0.5} alignItems="center" minWidth="fit-content">
|
||||
<Text variant="bodySmall">
|
||||
<Trans i18nKey="alerting.loki-state-history.common-labels">Common labels</Trans>
|
||||
</Text>
|
||||
<Tooltip
|
||||
content={t(
|
||||
'alerting.loki-state-history.tooltip-common-labels',
|
||||
'Common labels are the ones attached to all of the alert instances'
|
||||
)}
|
||||
>
|
||||
<Icon name="info-circle" size="sm" />
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
<AlertLabels labels={fromPairs(commonLabels)} size="sm" />
|
||||
</Stack>
|
||||
)}
|
||||
<LogTimelineViewer frames={frameSubset} timeRange={frameTimeRange} />
|
||||
<LogRecordViewerByTimestamp
|
||||
records={historyRecords}
|
||||
commonLabels={commonLabels}
|
||||
onLabelClick={onLogRecordLabelClick}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
scrollableContent: css({
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
paddingTop: theme.spacing(2),
|
||||
overflow: 'hidden',
|
||||
}),
|
||||
resultsContainer: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(2),
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
}),
|
||||
});
|
||||
+3
@@ -60,6 +60,7 @@ import {
|
||||
formValuesToRulerRuleDTO,
|
||||
} from '../../../utils/rule-form';
|
||||
import { fromRulerRule, fromRulerRuleAndRuleGroupIdentifier } from '../../../utils/rule-id';
|
||||
import { BacktestDropdownButton } from '../../backtesting/BacktestDropdownButton';
|
||||
import { GrafanaRuleExporter } from '../../export/GrafanaRuleExporter';
|
||||
import { AlertRuleNameAndMetric } from '../AlertRuleNameInput';
|
||||
import AnnotationsStep from '../AnnotationsStep';
|
||||
@@ -290,6 +291,8 @@ export const AlertRuleForm = ({ existing, prefill, isManualRestore }: Props) =>
|
||||
<Trans i18nKey="alerting.alert-rule-form.action-buttons.edit-yaml">Edit YAML</Trans>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{config.featureToggles.alertingBacktesting && <BacktestDropdownButton ruleDefinition={watch()} />}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { AdHocVariableModel, EventBusSrv, GroupByVariableModel, VariableModel } from '@grafana/data';
|
||||
import { BackendSrv, config, setBackendSrv } from '@grafana/runtime';
|
||||
import { GroupByVariable, sceneGraph } from '@grafana/scenes';
|
||||
import { GroupByVariable, sceneGraph, SceneQueryRunner } from '@grafana/scenes';
|
||||
import { AdHocFilterItem, PanelContext } from '@grafana/ui';
|
||||
|
||||
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
|
||||
import { findVizPanelByKey } from '../utils/utils';
|
||||
import { findVizPanelByKey, getQueryRunnerFor } from '../utils/utils';
|
||||
|
||||
import { getAdHocFilterVariableFor, setDashboardPanelContext } from './setDashboardPanelContext';
|
||||
|
||||
@@ -159,6 +159,23 @@ describe('setDashboardPanelContext', () => {
|
||||
// Verify existing filter value updated
|
||||
expect(variable.state.filters[1].operator).toBe('!=');
|
||||
});
|
||||
|
||||
it('Should use existing adhoc filter when panel has no panel-level datasource because queries have all the same datasources (v2 behavior)', () => {
|
||||
const { scene, context } = buildTestScene({ existingFilterVariable: true, panelDatasourceUndefined: true });
|
||||
|
||||
const variable = getAdHocFilterVariableFor(scene, { uid: 'my-ds-uid' });
|
||||
variable.setState({ filters: [] });
|
||||
|
||||
context.onAddAdHocFilter!({ key: 'hello', value: 'world', operator: '=' });
|
||||
|
||||
// Should use the existing adhoc filter variable, not create a new one
|
||||
expect(variable.state.filters).toEqual([{ key: 'hello', value: 'world', operator: '=' }]);
|
||||
|
||||
// Verify no new adhoc variables were created
|
||||
const variables = sceneGraph.getVariables(scene);
|
||||
const adhocVars = variables.state.variables.filter((v) => v.state.type === 'adhoc');
|
||||
expect(adhocVars.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFiltersBasedOnGrouping', () => {
|
||||
@@ -312,6 +329,7 @@ interface SceneOptions {
|
||||
existingFilterVariable?: boolean;
|
||||
existingGroupByVariable?: boolean;
|
||||
groupByDatasourceUid?: string;
|
||||
panelDatasourceUndefined?: boolean;
|
||||
}
|
||||
|
||||
function buildTestScene(options: SceneOptions) {
|
||||
@@ -385,6 +403,19 @@ function buildTestScene(options: SceneOptions) {
|
||||
});
|
||||
|
||||
const vizPanel = findVizPanelByKey(scene, 'panel-4')!;
|
||||
|
||||
// Simulate v2 dashboard behavior where non-mixed panels don't have panel-level datasource
|
||||
// but the queries have their own datasources
|
||||
if (options.panelDatasourceUndefined) {
|
||||
const queryRunner = getQueryRunnerFor(vizPanel);
|
||||
if (queryRunner instanceof SceneQueryRunner) {
|
||||
queryRunner.setState({
|
||||
datasource: undefined,
|
||||
queries: [{ refId: 'A', datasource: { uid: 'my-ds-uid', type: 'prometheus' } }],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const context: PanelContext = {
|
||||
eventBus: new EventBusSrv(),
|
||||
eventsScope: 'global',
|
||||
|
||||
@@ -6,7 +6,12 @@ import { AdHocFilterItem, PanelContext } from '@grafana/ui';
|
||||
import { annotationServer } from 'app/features/annotations/api';
|
||||
|
||||
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
|
||||
import { getDashboardSceneFor, getPanelIdForVizPanel, getQueryRunnerFor } from '../utils/utils';
|
||||
import {
|
||||
getDashboardSceneFor,
|
||||
getDatasourceFromQueryRunner,
|
||||
getPanelIdForVizPanel,
|
||||
getQueryRunnerFor,
|
||||
} from '../utils/utils';
|
||||
|
||||
import { DashboardScene } from './DashboardScene';
|
||||
|
||||
@@ -121,7 +126,7 @@ export function setDashboardPanelContext(vizPanel: VizPanel, context: PanelConte
|
||||
context.eventBus.publish(new AnnotationChangeEvent({ id }));
|
||||
};
|
||||
|
||||
context.onAddAdHocFilter = (newFilter: AdHocFilterItem) => {
|
||||
context.onAddAdHocFilter = async (newFilter: AdHocFilterItem) => {
|
||||
const dashboard = getDashboardSceneFor(vizPanel);
|
||||
|
||||
const queryRunner = getQueryRunnerFor(vizPanel);
|
||||
@@ -129,7 +134,19 @@ export function setDashboardPanelContext(vizPanel: VizPanel, context: PanelConte
|
||||
return;
|
||||
}
|
||||
|
||||
const filterVar = getAdHocFilterVariableFor(dashboard, queryRunner.state.datasource);
|
||||
let datasource = getDatasourceFromQueryRunner(queryRunner);
|
||||
|
||||
// If the datasource is type-only (e.g. it's possible that only group is set in V2 schema queries)
|
||||
// we need to resolve it to a full datasource
|
||||
if (datasource && !datasource.uid) {
|
||||
const datasourceToLoad = await getDataSourceSrv().get(datasource);
|
||||
datasource = {
|
||||
uid: datasourceToLoad.uid,
|
||||
type: datasourceToLoad.type,
|
||||
};
|
||||
}
|
||||
|
||||
const filterVar = getAdHocFilterVariableFor(dashboard, datasource);
|
||||
updateAdHocFilterVariable(filterVar, newFilter);
|
||||
};
|
||||
|
||||
@@ -141,7 +158,8 @@ export function setDashboardPanelContext(vizPanel: VizPanel, context: PanelConte
|
||||
return [];
|
||||
}
|
||||
|
||||
const groupByVar = getGroupByVariableFor(dashboard, queryRunner.state.datasource);
|
||||
const datasource = getDatasourceFromQueryRunner(queryRunner);
|
||||
const groupByVar = getGroupByVariableFor(dashboard, datasource);
|
||||
|
||||
if (!groupByVar) {
|
||||
return [];
|
||||
@@ -158,7 +176,7 @@ export function setDashboardPanelContext(vizPanel: VizPanel, context: PanelConte
|
||||
.filter((item) => item !== undefined);
|
||||
};
|
||||
|
||||
context.onAddAdHocFilters = (items: AdHocFilterItem[]) => {
|
||||
context.onAddAdHocFilters = async (items: AdHocFilterItem[]) => {
|
||||
const dashboard = getDashboardSceneFor(vizPanel);
|
||||
|
||||
const queryRunner = getQueryRunnerFor(vizPanel);
|
||||
@@ -166,7 +184,18 @@ export function setDashboardPanelContext(vizPanel: VizPanel, context: PanelConte
|
||||
return;
|
||||
}
|
||||
|
||||
const filterVar = getAdHocFilterVariableFor(dashboard, queryRunner.state.datasource);
|
||||
let datasource = getDatasourceFromQueryRunner(queryRunner);
|
||||
|
||||
// If the datasource is type-only (e.g. it's possible that only group is set in V2 schema queries)
|
||||
// we need to resolve it to a full datasource
|
||||
if (datasource && !datasource.uid) {
|
||||
const datasourceToLoad = await getDataSourceSrv().get(datasource);
|
||||
datasource = {
|
||||
uid: datasourceToLoad.uid,
|
||||
type: datasourceToLoad.type,
|
||||
};
|
||||
}
|
||||
const filterVar = getAdHocFilterVariableFor(dashboard, datasource);
|
||||
bulkUpdateAdHocFiltersVariable(filterVar, items);
|
||||
};
|
||||
|
||||
|
||||
+2
-1
@@ -144,7 +144,8 @@ export function transformSceneToSaveModelSchemaV2(scene: DashboardScene, isSnaps
|
||||
try {
|
||||
// validateDashboardSchemaV2 will throw an error if the dashboard is not valid
|
||||
if (validateDashboardSchemaV2(dashboardSchemaV2)) {
|
||||
return sortedDeepCloneWithoutNulls(dashboardSchemaV2, true);
|
||||
// Strip BOMs from all strings to prevent CUE validation errors ("illegal byte order mark")
|
||||
return sortedDeepCloneWithoutNulls(dashboardSchemaV2, true, true);
|
||||
}
|
||||
// should never reach this point, validation should throw an error
|
||||
throw new Error('Error we could transform the dashboard to schema v2: ' + dashboardSchemaV2);
|
||||
|
||||
@@ -3,6 +3,8 @@ import { getDataSourceSrv } from '@grafana/runtime';
|
||||
import { AdHocFiltersVariable, GroupByVariable, sceneGraph, SceneObject, SceneQueryRunner } from '@grafana/scenes';
|
||||
import { DataSourceRef } from '@grafana/schema';
|
||||
|
||||
import { getDatasourceFromQueryRunner } from './utils';
|
||||
|
||||
export function verifyDrilldownApplicability(
|
||||
sourceObject: SceneObject,
|
||||
queriesDataSource: DataSourceRef | undefined,
|
||||
@@ -26,7 +28,7 @@ export async function getDrilldownApplicability(
|
||||
return;
|
||||
}
|
||||
|
||||
const datasource = queryRunner.state.datasource;
|
||||
const datasource = getDatasourceFromQueryRunner(queryRunner);
|
||||
const queries = queryRunner.state.data?.request?.targets;
|
||||
|
||||
const ds = await getDataSourceSrv().get(datasource?.uid);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { sceneGraph, VizPanel } from '@grafana/scenes';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { getExploreUrl } from 'app/core/utils/explore';
|
||||
|
||||
import { getQueryRunnerFor } from './utils';
|
||||
import { getDatasourceFromQueryRunner, getQueryRunnerFor } from './utils';
|
||||
|
||||
export function getViewPanelUrl(vizPanel: VizPanel) {
|
||||
return locationUtil.getUrlForPartial(locationService.getLocation(), {
|
||||
@@ -27,10 +27,11 @@ export function tryGetExploreUrlForPanel(vizPanel: VizPanel): Promise<string | u
|
||||
}
|
||||
|
||||
const timeRange = sceneGraph.getTimeRange(vizPanel);
|
||||
const datasource = getDatasourceFromQueryRunner(queryRunner);
|
||||
|
||||
return getExploreUrl({
|
||||
queries: queryRunner.state.queries,
|
||||
dsRef: queryRunner.state.datasource,
|
||||
dsRef: datasource,
|
||||
timeRange: timeRange.state.value,
|
||||
scopedVars: { __sceneObject: { value: vizPanel } },
|
||||
adhocFilters: queryRunner.state.data?.request?.filters,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getDataSourceRef, IntervalVariableModel } from '@grafana/data';
|
||||
import { DataSourceRef, getDataSourceRef, IntervalVariableModel } from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { config, getDataSourceSrv } from '@grafana/runtime';
|
||||
import {
|
||||
@@ -237,6 +237,26 @@ export function getQueryRunnerFor(sceneObject: SceneObject | undefined): SceneQu
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the datasource from a query runner.
|
||||
* When no panel-level datasource is set, it means all queries use the same datasource,
|
||||
* so we extract the datasource from the first query.
|
||||
*/
|
||||
export function getDatasourceFromQueryRunner(queryRunner: SceneQueryRunner): DataSourceRef | null | undefined {
|
||||
// Panel-level datasource is set for mixed datasource panels
|
||||
if (queryRunner.state.datasource) {
|
||||
return queryRunner.state.datasource;
|
||||
}
|
||||
|
||||
// No panel-level datasource means all queries share the same datasource
|
||||
const firstQuery = queryRunner.state.queries?.[0];
|
||||
if (firstQuery?.datasource) {
|
||||
return firstQuery.datasource;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getDashboardSceneFor(sceneObject: SceneObject): DashboardScene {
|
||||
const root = sceneObject.getRoot();
|
||||
|
||||
|
||||
@@ -729,6 +729,11 @@
|
||||
"placeholder-value-input": "",
|
||||
"placeholder-value-input-default": "Zadejte obsah vlastní vysvětlivky…"
|
||||
},
|
||||
"backtest": {
|
||||
"error-title": "",
|
||||
"loading": "",
|
||||
"panel-title": ""
|
||||
},
|
||||
"bulk-actions": {
|
||||
"delete": {
|
||||
"success": "Pravidla byla úspěšně odstraněna ze složky"
|
||||
@@ -2219,11 +2224,15 @@
|
||||
"min-interval": "Min. Interval = {{minInterval}}"
|
||||
},
|
||||
"queryAndExpressionsStep": {
|
||||
"custom": "",
|
||||
"disableAdvancedOptions": {
|
||||
"text": "Vybrané dotazy a výrazy nelze převést na výchozí. Pokud deaktivujete pokročilé možnosti, váš dotaz a podmínka budou obnoveny do výchozího nastavení."
|
||||
},
|
||||
"last15m": "",
|
||||
"last1h": "",
|
||||
"preview": "Náhled",
|
||||
"previewCondition": "Podmínka pravidla náhledu výstrahy"
|
||||
"previewCondition": "Podmínka pravidla náhledu výstrahy",
|
||||
"testRule": ""
|
||||
},
|
||||
"receiver-filter": {
|
||||
"aria-label-contact-points": "Filtrovat podle kontaktních bodů",
|
||||
|
||||
@@ -723,6 +723,11 @@
|
||||
"placeholder-value-input": "",
|
||||
"placeholder-value-input-default": "Inhalt der benutzerdefinierten Anmerkung eingeben …"
|
||||
},
|
||||
"backtest": {
|
||||
"error-title": "",
|
||||
"loading": "",
|
||||
"panel-title": ""
|
||||
},
|
||||
"bulk-actions": {
|
||||
"delete": {
|
||||
"success": "Die Regeln wurden erfolgreich aus dem Ordner gelöscht"
|
||||
@@ -2203,11 +2208,15 @@
|
||||
"min-interval": "Mind. Intervall = {{minInterval}}"
|
||||
},
|
||||
"queryAndExpressionsStep": {
|
||||
"custom": "",
|
||||
"disableAdvancedOptions": {
|
||||
"text": "Die ausgewählten Abfragen und Ausdrücke können nicht in die Standardeinstellung konvertiert werden. Wenn Sie die erweiterten Optionen deaktivieren, werden Ihre Abfrage und Bedingung auf die Standardeinstellungen zurückgesetzt."
|
||||
},
|
||||
"last15m": "",
|
||||
"last1h": "",
|
||||
"preview": "Vorschau",
|
||||
"previewCondition": "Vorschau der Warnregelbedingung"
|
||||
"previewCondition": "Vorschau der Warnregelbedingung",
|
||||
"testRule": ""
|
||||
},
|
||||
"receiver-filter": {
|
||||
"aria-label-contact-points": "Nach Kontaktpunkten filtern",
|
||||
|
||||
@@ -723,6 +723,11 @@
|
||||
"placeholder-value-input": "Enter a {{key}}...",
|
||||
"placeholder-value-input-default": "Enter custom annotation content..."
|
||||
},
|
||||
"backtest": {
|
||||
"error-title": "Failed to run backtest",
|
||||
"loading": "Running backtest...",
|
||||
"panel-title": "Rule Retroactive Testing"
|
||||
},
|
||||
"bulk-actions": {
|
||||
"delete": {
|
||||
"success": "Rules successfully deleted from folder"
|
||||
@@ -2203,11 +2208,15 @@
|
||||
"min-interval": "Min. Interval = {{minInterval}}"
|
||||
},
|
||||
"queryAndExpressionsStep": {
|
||||
"custom": "Custom",
|
||||
"disableAdvancedOptions": {
|
||||
"text": "The selected queries and expressions cannot be converted to default. If you deactivate advanced options, your query and condition will be reset to default settings."
|
||||
},
|
||||
"last15m": "Last 15 minutes",
|
||||
"last1h": "Last 1 hour",
|
||||
"preview": "Preview",
|
||||
"previewCondition": "Preview alert rule condition"
|
||||
"previewCondition": "Preview alert rule condition",
|
||||
"testRule": "Test Rule"
|
||||
},
|
||||
"receiver-filter": {
|
||||
"aria-label-contact-points": "Filter by contact points",
|
||||
|
||||
@@ -723,6 +723,11 @@
|
||||
"placeholder-value-input": "",
|
||||
"placeholder-value-input-default": "Introduce el contenido de la anotación personalizada..."
|
||||
},
|
||||
"backtest": {
|
||||
"error-title": "",
|
||||
"loading": "",
|
||||
"panel-title": ""
|
||||
},
|
||||
"bulk-actions": {
|
||||
"delete": {
|
||||
"success": "Reglas eliminadas correctamente de la carpeta"
|
||||
@@ -2203,11 +2208,15 @@
|
||||
"min-interval": "Tamaño min. Intervalo = {{minInterval}}"
|
||||
},
|
||||
"queryAndExpressionsStep": {
|
||||
"custom": "",
|
||||
"disableAdvancedOptions": {
|
||||
"text": "Las consultas y expresiones seleccionadas no se pueden convertir a predeterminadas. Si desactivas las opciones avanzadas, tu consulta y condición se restablecerán a la configuración predeterminada."
|
||||
},
|
||||
"last15m": "",
|
||||
"last1h": "",
|
||||
"preview": "Vista previa",
|
||||
"previewCondition": "Vista previa de la condición de la regla de alerta"
|
||||
"previewCondition": "Vista previa de la condición de la regla de alerta",
|
||||
"testRule": ""
|
||||
},
|
||||
"receiver-filter": {
|
||||
"aria-label-contact-points": "Filtrar por puntos de contacto",
|
||||
|
||||
@@ -723,6 +723,11 @@
|
||||
"placeholder-value-input": "",
|
||||
"placeholder-value-input-default": "Saisir le contenu de l’annotation personnalisée..."
|
||||
},
|
||||
"backtest": {
|
||||
"error-title": "",
|
||||
"loading": "",
|
||||
"panel-title": ""
|
||||
},
|
||||
"bulk-actions": {
|
||||
"delete": {
|
||||
"success": "Règles supprimées du dossier"
|
||||
@@ -2203,11 +2208,15 @@
|
||||
"min-interval": "Min. Intervalle = {{minInterval}}"
|
||||
},
|
||||
"queryAndExpressionsStep": {
|
||||
"custom": "",
|
||||
"disableAdvancedOptions": {
|
||||
"text": "Les requêtes et expressions sélectionnées ne peuvent pas être converties en valeurs par défaut. Si vous désactivez les options avancées, votre requête et votre condition seront réinitialisées aux valeurs par défaut."
|
||||
},
|
||||
"last15m": "",
|
||||
"last1h": "",
|
||||
"preview": "Aperçu",
|
||||
"previewCondition": "Aperçu de la condition de la règle d'alerte"
|
||||
"previewCondition": "Aperçu de la condition de la règle d'alerte",
|
||||
"testRule": ""
|
||||
},
|
||||
"receiver-filter": {
|
||||
"aria-label-contact-points": "Filtrer par points de contact",
|
||||
|
||||
@@ -723,6 +723,11 @@
|
||||
"placeholder-value-input": "",
|
||||
"placeholder-value-input-default": "Adja meg az egyéni jegyzet tartalmát…"
|
||||
},
|
||||
"backtest": {
|
||||
"error-title": "",
|
||||
"loading": "",
|
||||
"panel-title": ""
|
||||
},
|
||||
"bulk-actions": {
|
||||
"delete": {
|
||||
"success": "A szabályok sikeresen törlődtek a mappából"
|
||||
@@ -2203,11 +2208,15 @@
|
||||
"min-interval": "Min. intervallum = {{minInterval}}"
|
||||
},
|
||||
"queryAndExpressionsStep": {
|
||||
"custom": "",
|
||||
"disableAdvancedOptions": {
|
||||
"text": "A kijelölt lekérdezések és kifejezések nem konvertálhatók alapértelmezettre. Ha kikapcsolja a speciális beállításokat, a lekérdezés és a feltétel visszaáll az alapértelmezett beállításokra."
|
||||
},
|
||||
"last15m": "",
|
||||
"last1h": "",
|
||||
"preview": "Előnézet",
|
||||
"previewCondition": "Riasztási szabály előnézeti feltétele"
|
||||
"previewCondition": "Riasztási szabály előnézeti feltétele",
|
||||
"testRule": ""
|
||||
},
|
||||
"receiver-filter": {
|
||||
"aria-label-contact-points": "Szűrés kapcsolattartási pontok szerint",
|
||||
|
||||
@@ -720,6 +720,11 @@
|
||||
"placeholder-value-input": "",
|
||||
"placeholder-value-input-default": "Masukkan konten anotasi kustom..."
|
||||
},
|
||||
"backtest": {
|
||||
"error-title": "",
|
||||
"loading": "",
|
||||
"panel-title": ""
|
||||
},
|
||||
"bulk-actions": {
|
||||
"delete": {
|
||||
"success": "Aturan berhasil dihapus dari folder"
|
||||
@@ -2195,11 +2200,15 @@
|
||||
"min-interval": "Min. Interval = {{minInterval}}"
|
||||
},
|
||||
"queryAndExpressionsStep": {
|
||||
"custom": "",
|
||||
"disableAdvancedOptions": {
|
||||
"text": "Kueri dan ekspresi yang dipilih tidak dapat dikonversi ke default. Jika Anda menonaktifkan opsi lanjutan, kueri dan kondisi Anda akan diatur ulang ke pengaturan default."
|
||||
},
|
||||
"last15m": "",
|
||||
"last1h": "",
|
||||
"preview": "Pratinjau",
|
||||
"previewCondition": "Pratinjau kondisi aturan peringatan"
|
||||
"previewCondition": "Pratinjau kondisi aturan peringatan",
|
||||
"testRule": ""
|
||||
},
|
||||
"receiver-filter": {
|
||||
"aria-label-contact-points": "Filter berdasarkan titik kontak",
|
||||
|
||||
@@ -723,6 +723,11 @@
|
||||
"placeholder-value-input": "",
|
||||
"placeholder-value-input-default": "Inserisci il contenuto dell'annotazione personalizzata..."
|
||||
},
|
||||
"backtest": {
|
||||
"error-title": "",
|
||||
"loading": "",
|
||||
"panel-title": ""
|
||||
},
|
||||
"bulk-actions": {
|
||||
"delete": {
|
||||
"success": "Regole eliminate dalla cartella"
|
||||
@@ -2203,11 +2208,15 @@
|
||||
"min-interval": "Min Intervallo = {{minInterval}}"
|
||||
},
|
||||
"queryAndExpressionsStep": {
|
||||
"custom": "",
|
||||
"disableAdvancedOptions": {
|
||||
"text": "Le query e le espressioni selezionate non possono essere convertite in predefinite. Se disattivi le opzioni avanzate, la query e la condizione verranno ripristinate alle impostazioni predefinite."
|
||||
},
|
||||
"last15m": "",
|
||||
"last1h": "",
|
||||
"preview": "Anteprima",
|
||||
"previewCondition": "Anteprima condizione regola di avviso"
|
||||
"previewCondition": "Anteprima condizione regola di avviso",
|
||||
"testRule": ""
|
||||
},
|
||||
"receiver-filter": {
|
||||
"aria-label-contact-points": "Filtra per punti di contatto",
|
||||
|
||||
@@ -720,6 +720,11 @@
|
||||
"placeholder-value-input": "",
|
||||
"placeholder-value-input-default": "カスタム注釈内容を入力..."
|
||||
},
|
||||
"backtest": {
|
||||
"error-title": "",
|
||||
"loading": "",
|
||||
"panel-title": ""
|
||||
},
|
||||
"bulk-actions": {
|
||||
"delete": {
|
||||
"success": "ルールがフォルダから正常に削除されました"
|
||||
@@ -2195,11 +2200,15 @@
|
||||
"min-interval": "最小間隔= {{minInterval}}"
|
||||
},
|
||||
"queryAndExpressionsStep": {
|
||||
"custom": "",
|
||||
"disableAdvancedOptions": {
|
||||
"text": "選択したクエリと式はデフォルトに変換できません。高度なオプションを無効にすると、クエリと条件はデフォルト設定にリセットされます。"
|
||||
},
|
||||
"last15m": "",
|
||||
"last1h": "",
|
||||
"preview": "プレビュー",
|
||||
"previewCondition": "アラートルール条件をプレビューする"
|
||||
"previewCondition": "アラートルール条件をプレビューする",
|
||||
"testRule": ""
|
||||
},
|
||||
"receiver-filter": {
|
||||
"aria-label-contact-points": "コンタクトポイントで絞り込む",
|
||||
|
||||
@@ -720,6 +720,11 @@
|
||||
"placeholder-value-input": "",
|
||||
"placeholder-value-input-default": "사용자 지정 주석 내용을 입력하세요..."
|
||||
},
|
||||
"backtest": {
|
||||
"error-title": "",
|
||||
"loading": "",
|
||||
"panel-title": ""
|
||||
},
|
||||
"bulk-actions": {
|
||||
"delete": {
|
||||
"success": "폴더에서 규칙이 성공적으로 삭제되었습니다"
|
||||
@@ -2195,11 +2200,15 @@
|
||||
"min-interval": "최소 간격 = {{minInterval}}"
|
||||
},
|
||||
"queryAndExpressionsStep": {
|
||||
"custom": "",
|
||||
"disableAdvancedOptions": {
|
||||
"text": "선택한 쿼리와 표현식을 기본값으로 변환할 수 없습니다. 고급 옵션을 비활성화하면 쿼리와 조건이 기본 설정으로 재설정됩니다."
|
||||
},
|
||||
"last15m": "",
|
||||
"last1h": "",
|
||||
"preview": "미리보기",
|
||||
"previewCondition": "경고 규칙 조건 미리보기"
|
||||
"previewCondition": "경고 규칙 조건 미리보기",
|
||||
"testRule": ""
|
||||
},
|
||||
"receiver-filter": {
|
||||
"aria-label-contact-points": "연락처로 필터링",
|
||||
|
||||
@@ -723,6 +723,11 @@
|
||||
"placeholder-value-input": "",
|
||||
"placeholder-value-input-default": "Aangepaste annotatie-inhoud invoeren..."
|
||||
},
|
||||
"backtest": {
|
||||
"error-title": "",
|
||||
"loading": "",
|
||||
"panel-title": ""
|
||||
},
|
||||
"bulk-actions": {
|
||||
"delete": {
|
||||
"success": "Regels zijn verwijderd uit de map"
|
||||
@@ -2203,11 +2208,15 @@
|
||||
"min-interval": "Min. Interval = {{minInterval}}"
|
||||
},
|
||||
"queryAndExpressionsStep": {
|
||||
"custom": "",
|
||||
"disableAdvancedOptions": {
|
||||
"text": "De geselecteerde query's en expressies kunnen niet worden geconverteerd naar standaard. Als je geavanceerde opties deactiveert, worden je query en voorwaarde teruggezet naar de standaardinstellingen."
|
||||
},
|
||||
"last15m": "",
|
||||
"last1h": "",
|
||||
"preview": "Voorbeeld",
|
||||
"previewCondition": "Voorbeeld waarschuwingsregel voorwaarde"
|
||||
"previewCondition": "Voorbeeld waarschuwingsregel voorwaarde",
|
||||
"testRule": ""
|
||||
},
|
||||
"receiver-filter": {
|
||||
"aria-label-contact-points": "Filteren op contactpunten",
|
||||
|
||||
@@ -729,6 +729,11 @@
|
||||
"placeholder-value-input": "",
|
||||
"placeholder-value-input-default": "Wpisz treść niestandardowej adnotacji…"
|
||||
},
|
||||
"backtest": {
|
||||
"error-title": "",
|
||||
"loading": "",
|
||||
"panel-title": ""
|
||||
},
|
||||
"bulk-actions": {
|
||||
"delete": {
|
||||
"success": "Reguły zostały usunięte z folderu"
|
||||
@@ -2219,11 +2224,15 @@
|
||||
"min-interval": "Min. odstęp czasu = {{minInterval}}"
|
||||
},
|
||||
"queryAndExpressionsStep": {
|
||||
"custom": "",
|
||||
"disableAdvancedOptions": {
|
||||
"text": "Nie można przekonwertować wybranych zapytań i wyrażeń na domyślne. Jeśli wyłączysz opcje zaawansowane, zapytanie i warunek zostaną zresetowane do ustawień domyślnych."
|
||||
},
|
||||
"last15m": "",
|
||||
"last1h": "",
|
||||
"preview": "Podgląd",
|
||||
"previewCondition": "Podgląd warunku reguły alertu"
|
||||
"previewCondition": "Podgląd warunku reguły alertu",
|
||||
"testRule": ""
|
||||
},
|
||||
"receiver-filter": {
|
||||
"aria-label-contact-points": "Filtruj według punktów kontaktu",
|
||||
|
||||
@@ -723,6 +723,11 @@
|
||||
"placeholder-value-input": "",
|
||||
"placeholder-value-input-default": "Insira o conteúdo da anotação personalizada…"
|
||||
},
|
||||
"backtest": {
|
||||
"error-title": "",
|
||||
"loading": "",
|
||||
"panel-title": ""
|
||||
},
|
||||
"bulk-actions": {
|
||||
"delete": {
|
||||
"success": "As regras foram excluídas da pasta"
|
||||
@@ -2203,11 +2208,15 @@
|
||||
"min-interval": "Mín. Intervalo = {{minInterval}}"
|
||||
},
|
||||
"queryAndExpressionsStep": {
|
||||
"custom": "",
|
||||
"disableAdvancedOptions": {
|
||||
"text": "As consultas e expressões selecionadas não podem ser convertidas para o padrão. Se você desativar as opções avançadas, sua consulta e condição serão redefinidas para as configurações padrão."
|
||||
},
|
||||
"last15m": "",
|
||||
"last1h": "",
|
||||
"preview": "Visualizar",
|
||||
"previewCondition": "Visualizar condição de regra de alerta"
|
||||
"previewCondition": "Visualizar condição de regra de alerta",
|
||||
"testRule": ""
|
||||
},
|
||||
"receiver-filter": {
|
||||
"aria-label-contact-points": "Filtrar por pontos de contato",
|
||||
|
||||
@@ -723,6 +723,11 @@
|
||||
"placeholder-value-input": "",
|
||||
"placeholder-value-input-default": "Introduzir o conteúdo da anotação personalizada..."
|
||||
},
|
||||
"backtest": {
|
||||
"error-title": "",
|
||||
"loading": "",
|
||||
"panel-title": ""
|
||||
},
|
||||
"bulk-actions": {
|
||||
"delete": {
|
||||
"success": "Regras eliminadas da pasta com sucesso"
|
||||
@@ -2203,11 +2208,15 @@
|
||||
"min-interval": "Min. Intervalo = {{minInterval}}"
|
||||
},
|
||||
"queryAndExpressionsStep": {
|
||||
"custom": "",
|
||||
"disableAdvancedOptions": {
|
||||
"text": "As consultas e expressões selecionadas não podem ser convertidas para padrão. Se desativar as opções avançadas, a sua consulta e condição serão repostas para as definições padrão."
|
||||
},
|
||||
"last15m": "",
|
||||
"last1h": "",
|
||||
"preview": "Pré-visualizar",
|
||||
"previewCondition": "Pré-visualizar a condição da regra de alerta"
|
||||
"previewCondition": "Pré-visualizar a condição da regra de alerta",
|
||||
"testRule": ""
|
||||
},
|
||||
"receiver-filter": {
|
||||
"aria-label-contact-points": "Filtrar por pontos de contacto",
|
||||
|
||||
@@ -729,6 +729,11 @@
|
||||
"placeholder-value-input": "",
|
||||
"placeholder-value-input-default": "Ввести содержимое пользовательской аннотации..."
|
||||
},
|
||||
"backtest": {
|
||||
"error-title": "",
|
||||
"loading": "",
|
||||
"panel-title": ""
|
||||
},
|
||||
"bulk-actions": {
|
||||
"delete": {
|
||||
"success": "Правила удалены из папки"
|
||||
@@ -2219,11 +2224,15 @@
|
||||
"min-interval": "Мин. интервал = {{minInterval}}"
|
||||
},
|
||||
"queryAndExpressionsStep": {
|
||||
"custom": "",
|
||||
"disableAdvancedOptions": {
|
||||
"text": "Выбранные запросы и выражения не могут быть преобразованы в используемые по умолчанию. Если вы отключите расширенные параметры, ваш запрос и условие будут сброшены до настроек по умолчанию."
|
||||
},
|
||||
"last15m": "",
|
||||
"last1h": "",
|
||||
"preview": "Предварительный просмотр",
|
||||
"previewCondition": "Предварительный просмотр условия правила оповещения"
|
||||
"previewCondition": "Предварительный просмотр условия правила оповещения",
|
||||
"testRule": ""
|
||||
},
|
||||
"receiver-filter": {
|
||||
"aria-label-contact-points": "Фильтр по точкам контакта",
|
||||
|
||||
@@ -723,6 +723,11 @@
|
||||
"placeholder-value-input": "",
|
||||
"placeholder-value-input-default": "Ange innehåll för anpassad kommentar …"
|
||||
},
|
||||
"backtest": {
|
||||
"error-title": "",
|
||||
"loading": "",
|
||||
"panel-title": ""
|
||||
},
|
||||
"bulk-actions": {
|
||||
"delete": {
|
||||
"success": "Reglerna har raderats från mappen"
|
||||
@@ -2203,11 +2208,15 @@
|
||||
"min-interval": "Min. Intervall = {{minInterval}}"
|
||||
},
|
||||
"queryAndExpressionsStep": {
|
||||
"custom": "",
|
||||
"disableAdvancedOptions": {
|
||||
"text": "De valda frågorna och uttrycken kan inte konverteras till standard. Om du inaktiverar avancerade alternativ kommer din fråga och ditt villkor att återställas till standardinställningarna."
|
||||
},
|
||||
"last15m": "",
|
||||
"last1h": "",
|
||||
"preview": "Förhandsgranska",
|
||||
"previewCondition": "Förhandsgranska varningsregeltillstånd"
|
||||
"previewCondition": "Förhandsgranska varningsregeltillstånd",
|
||||
"testRule": ""
|
||||
},
|
||||
"receiver-filter": {
|
||||
"aria-label-contact-points": "Filtrera efter kontaktpunkter",
|
||||
|
||||
@@ -723,6 +723,11 @@
|
||||
"placeholder-value-input": "",
|
||||
"placeholder-value-input-default": "Özel ek açıklama içeriği girin..."
|
||||
},
|
||||
"backtest": {
|
||||
"error-title": "",
|
||||
"loading": "",
|
||||
"panel-title": ""
|
||||
},
|
||||
"bulk-actions": {
|
||||
"delete": {
|
||||
"success": "Kurallar klasörden başarıyla silindi"
|
||||
@@ -2203,11 +2208,15 @@
|
||||
"min-interval": "Min. Aralık = {{minInterval}}"
|
||||
},
|
||||
"queryAndExpressionsStep": {
|
||||
"custom": "",
|
||||
"disableAdvancedOptions": {
|
||||
"text": "Seçilen sorgular ve ifadeler varsayılana dönüştürülemez. Gelişmiş seçenekleri devre dışı bırakırsanız sorgunuz ve koşulunuz varsayılan ayarlara sıfırlanır."
|
||||
},
|
||||
"last15m": "",
|
||||
"last1h": "",
|
||||
"preview": "Ön izleme",
|
||||
"previewCondition": "Uyarı kuralı koşulunu ön izle"
|
||||
"previewCondition": "Uyarı kuralı koşulunu ön izle",
|
||||
"testRule": ""
|
||||
},
|
||||
"receiver-filter": {
|
||||
"aria-label-contact-points": "",
|
||||
|
||||
@@ -720,6 +720,11 @@
|
||||
"placeholder-value-input": "",
|
||||
"placeholder-value-input-default": "输入自定义注释内容..."
|
||||
},
|
||||
"backtest": {
|
||||
"error-title": "",
|
||||
"loading": "",
|
||||
"panel-title": ""
|
||||
},
|
||||
"bulk-actions": {
|
||||
"delete": {
|
||||
"success": "规则已成功从文件夹中删除"
|
||||
@@ -2195,11 +2200,15 @@
|
||||
"min-interval": "最小间隔 = {{minInterval}}"
|
||||
},
|
||||
"queryAndExpressionsStep": {
|
||||
"custom": "",
|
||||
"disableAdvancedOptions": {
|
||||
"text": "无法将所选查询和表达式转换为默认值。如果停用高级选项,您的查询和条件将重置为默认设置。"
|
||||
},
|
||||
"last15m": "",
|
||||
"last1h": "",
|
||||
"preview": "预览",
|
||||
"previewCondition": "预览提醒规则条件"
|
||||
"previewCondition": "预览提醒规则条件",
|
||||
"testRule": ""
|
||||
},
|
||||
"receiver-filter": {
|
||||
"aria-label-contact-points": "按联络点筛选",
|
||||
|
||||
@@ -720,6 +720,11 @@
|
||||
"placeholder-value-input": "",
|
||||
"placeholder-value-input-default": "輸入自訂註解內容…"
|
||||
},
|
||||
"backtest": {
|
||||
"error-title": "",
|
||||
"loading": "",
|
||||
"panel-title": ""
|
||||
},
|
||||
"bulk-actions": {
|
||||
"delete": {
|
||||
"success": "已成功從資料夾中刪除規則"
|
||||
@@ -2195,11 +2200,15 @@
|
||||
"min-interval": "最小間隔 = {{minInterval}}"
|
||||
},
|
||||
"queryAndExpressionsStep": {
|
||||
"custom": "",
|
||||
"disableAdvancedOptions": {
|
||||
"text": "所選查詢和表達式無法轉換為預設值。如果停用進階選項,您的查詢和條件將重設為預設設定。"
|
||||
},
|
||||
"last15m": "",
|
||||
"last1h": "",
|
||||
"preview": "預覽",
|
||||
"previewCondition": "預覽警報規則條件"
|
||||
"previewCondition": "預覽警報規則條件",
|
||||
"testRule": ""
|
||||
},
|
||||
"receiver-filter": {
|
||||
"aria-label-contact-points": "按聯絡點篩選",
|
||||
|
||||
Reference in New Issue
Block a user