Compare commits

..

1 Commits

Author SHA1 Message Date
idastambuk 8c877b081e Cleanup dashboards 2025-12-29 16:12:52 +01:00
85 changed files with 507 additions and 2634 deletions
@@ -1,142 +0,0 @@
{
"kind": "Dashboard",
"apiVersion": "dashboard.grafana.app/v1beta1",
"metadata": {
"name": "bom-in-links-test",
"namespace": "org-1",
"labels": {
"test": "bom-stripping"
}
},
"spec": {
"title": "BOM Stripping Test Dashboard",
"description": "Testing that BOM characters are stripped from URLs during conversion",
"schemaVersion": 42,
"tags": ["test", "bom"],
"editable": true,
"links": [
{
"title": "Dashboard link with BOM",
"type": "link",
"url": "http://example.com?var=${datasource}&other=value",
"targetBlank": true,
"icon": "external link"
}
],
"panels": [
{
"id": 1,
"type": "table",
"title": "Panel with BOM in field config override links",
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"fieldConfig": {
"defaults": {
"custom": {},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{"color": "green"},
{"color": "red", "value": 80}
]
}
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "server"
},
"properties": [
{
"id": "links",
"value": [
{
"title": "Override link with BOM",
"url": "http://localhost:3000/d/test?var-datacenter=${__data.fields[datacenter]}&var-server=${__value.raw}"
}
]
}
]
}
]
},
"links": [
{
"title": "Panel data link with BOM",
"url": "http://example.com/${__data.fields.cluster}&var=value",
"targetBlank": true
}
],
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "test-ds"
}
}
]
},
{
"id": 2,
"type": "timeseries",
"title": "Panel with BOM in options dataLinks",
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 0
},
"options": {
"legend": {
"showLegend": true,
"displayMode": "list",
"placement": "bottom"
},
"dataLinks": [
{
"title": "Options data link with BOM",
"url": "http://example.com?series=${__series.name}&time=${__value.time}",
"targetBlank": true
}
]
},
"fieldConfig": {
"defaults": {
"links": [
{
"title": "Field config default link with BOM",
"url": "http://example.com?field=${__field.name}&value=${__value.raw}",
"targetBlank": false
}
]
},
"overrides": []
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "prometheus",
"uid": "test-ds"
}
}
]
}
],
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {
"refresh_intervals": ["5s", "10s", "30s", "1m", "5m"]
}
}
}
@@ -120,7 +120,7 @@
"value": [
{
"title": "filter",
"url": "http://localhost:3000/d/-Y-tnEDWk/templating-nested-template-variables?var-datacenter=${__data.fields[datacenter]}\u0026var-server=${__value.raw}"
"url": "http://localhost:3000/d/-Y-tnEDWk/templating-nested-template-variables?var-datacenter=${__data.fields[datacenter]}\u0026var-server=${__value.raw}"
}
]
}
@@ -124,7 +124,7 @@
"value": [
{
"title": "filter",
"url": "http://localhost:3000/d/-Y-tnEDWk/templating-nested-template-variables?var-datacenter=${__data.fields[datacenter]}\u0026var-server=${__value.raw}"
"url": "http://localhost:3000/d/-Y-tnEDWk/templating-nested-template-variables?var-datacenter=${__data.fields[datacenter]}\u0026var-server=${__value.raw}"
}
]
}
@@ -2051,4 +2051,4 @@
"storedVersion": "v0alpha1"
}
}
}
}
@@ -2691,4 +2691,4 @@
"storedVersion": "v0alpha1"
}
}
}
}
@@ -2764,4 +2764,4 @@
"storedVersion": "v0alpha1"
}
}
}
}
@@ -1173,4 +1173,4 @@
"storedVersion": "v0alpha1"
}
}
}
}
@@ -1618,4 +1618,4 @@
"storedVersion": "v0alpha1"
}
}
}
}
@@ -1670,4 +1670,4 @@
"storedVersion": "v0alpha1"
}
}
}
}
@@ -1,161 +0,0 @@
{
"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"
}
}
}
@@ -1,242 +0,0 @@
{
"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"
}
}
}
@@ -1,246 +0,0 @@
{
"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,36 +229,6 @@ 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 != "" {
@@ -423,8 +393,7 @@ 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 {
cleanUrl := stripBOM(urlStr)
dashLink.Url = &cleanUrl
dashLink.Url = &urlStr
}
}
@@ -2270,7 +2239,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: stripBOM(schemaversion.GetStringValue(linkMap, "url")),
Url: schemaversion.GetStringValue(linkMap, "url"),
}
if _, exists := linkMap["targetBlank"]; exists {
targetBlank := getBoolField(linkMap, "targetBlank", false)
@@ -2362,12 +2331,6 @@ 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)
@@ -2511,14 +2474,9 @@ func extractFieldConfigDefaults(defaults map[string]interface{}) dashv2alpha1.Da
hasDefaults = true
}
// Extract array field - strip BOMs from link URLs
// Extract array field
if linksArray, ok := extractArrayField(defaults, "links"); ok {
cleanedLinks := stripBOMFromInterface(linksArray)
if cleanedArray, ok := cleanedLinks.([]interface{}); ok {
fieldConfigDefaults.Links = cleanedArray
} else {
fieldConfigDefaults.Links = linksArray
}
fieldConfigDefaults.Links = linksArray
hasDefaults = true
}
@@ -2804,11 +2762,9 @@ 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: cleanedValue,
Value: propertyMap["value"],
})
}
}
@@ -1,14 +1,12 @@
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';
@@ -34,8 +32,8 @@ test.describe(
tag: ['@dashboards'],
},
() => {
test('can enable repeats', async ({ dashboardPage, selectors, page }) => {
await importTestDashboard(page, selectors, 'Custom grid repeats - add repeats');
test('can enable repeats', async ({ dashboardPage, selectors, page, importDashboard }) => {
await importDashboard('Custom grid repeats - add repeats');
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
@@ -62,13 +60,8 @@ test.describe(
await checkRepeatedPanelTitles(dashboardPage, selectors, repeatTitleBase, repeatOptions);
});
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)
);
test('can update repeats with variable change', async ({ dashboardPage, selectors, page, importDashboard }) => {
await importDashboard('Custom grid repeats - update on variable change', JSON.stringify(testV2DashWithRepeats));
await dashboardPage
.getByGrafanaSelector(
@@ -94,13 +87,8 @@ test.describe(
)
).toBeHidden();
});
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)
);
test('can update repeats in edit pane', async ({ dashboardPage, selectors, page, importDashboard }) => {
await importDashboard('Custom grid repeats - update through edit pane', JSON.stringify(testV2DashWithRepeats));
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
@@ -122,13 +110,8 @@ test.describe(
await checkRepeatedPanelTitles(dashboardPage, selectors, newTitleBase, repeatOptions);
});
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)
);
test('can update repeats in panel editor', async ({ dashboardPage, selectors, page, importDashboard }) => {
await importDashboard('Custom grid repeats - update through panel editor', JSON.stringify(testV2DashWithRepeats));
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
@@ -181,10 +164,13 @@ test.describe(
await checkRepeatedPanelTitles(dashboardPage, selectors, newTitleBase, repeatOptions);
});
test('can update repeats in panel editor when loaded directly', async ({ dashboardPage, selectors, page }) => {
await importTestDashboard(
page,
selectors,
test('can update repeats in panel editor when loaded directly', async ({
dashboardPage,
selectors,
page,
importDashboard,
}) => {
await importDashboard(
'Custom grid repeats - update through directly loaded panel editor',
JSON.stringify(testV2DashWithRepeats)
);
@@ -232,13 +218,8 @@ test.describe(
await checkRepeatedPanelTitles(dashboardPage, selectors, newTitleBase, repeatOptions);
});
test('can move repeated panels', async ({ dashboardPage, selectors, page }) => {
await importTestDashboard(
page,
selectors,
'Custom grid repeats - move repeated panels',
JSON.stringify(testV2DashWithRepeats)
);
test('can move repeated panels', async ({ dashboardPage, selectors, page, importDashboard }) => {
await importDashboard('Custom grid repeats - move repeated panels', JSON.stringify(testV2DashWithRepeats));
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
@@ -276,13 +257,8 @@ test.describe(
`${repeatTitleBase}${repeatOptions.at(-1)}`
);
});
test('can view repeated panel', async ({ dashboardPage, selectors, page }) => {
await importTestDashboard(
page,
selectors,
'Custom grid repeats - move repeated panels',
JSON.stringify(testV2DashWithRepeats)
);
test('can view repeated panel', async ({ dashboardPage, selectors, page, importDashboard }) => {
await importDashboard('Custom grid repeats - move repeated panels', JSON.stringify(testV2DashWithRepeats));
await dashboardPage
.getByGrafanaSelector(selectors.components.Panels.Panel.title(`${repeatTitleBase}${repeatOptions.at(-1)}`))
@@ -332,10 +308,8 @@ test.describe(
).toBeVisible();
});
test('can view embedded repeated panel', async ({ dashboardPage, selectors, page }) => {
await importTestDashboard(
page,
selectors,
test('can view embedded repeated panel', async ({ dashboardPage, selectors, page, importDashboard }) => {
await importDashboard(
'Custom grid repeats - view embedded repeated panel',
JSON.stringify(testV2DashWithRepeats)
);
@@ -353,13 +327,8 @@ test.describe(
)
).toBeVisible();
});
test('can remove repeats', async ({ dashboardPage, selectors, page }) => {
await importTestDashboard(
page,
selectors,
'Custom grid repeats - remove repeats',
JSON.stringify(testV2DashWithRepeats)
);
test('can remove repeats', async ({ dashboardPage, selectors, page, importDashboard }) => {
await importDashboard('Custom grid repeats - remove repeats', JSON.stringify(testV2DashWithRepeats));
// verify 6 panels are present (4 repeats and 2 normal)
expect(
@@ -1,11 +1,9 @@
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,
@@ -35,8 +33,8 @@ test.describe(
tag: ['@dashboards'],
},
() => {
test('can enable tab repeats', async ({ dashboardPage, selectors, page }) => {
await importTestDashboard(page, selectors, 'Tabs layout repeats - add repeats');
test('can enable tab repeats', async ({ dashboardPage, selectors, page, importDashboard }) => {
await importDashboard('Tabs layout repeats - add repeats');
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
@@ -69,13 +67,8 @@ test.describe(
await checkRepeatedTabTitles(dashboardPage, selectors, repeatTitleBase, repeatOptions);
});
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)
);
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));
const c1Var = dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.SubMenu.submenuItemLabels('c1'));
await c1Var
@@ -97,13 +90,8 @@ test.describe(
dashboardPage.getByGrafanaSelector(selectors.components.Tab.title(`${repeatTitleBase}${repeatOptions.at(-1)}`))
).toBeHidden();
});
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)
);
test('can update repeats in edit pane', async ({ dashboardPage, selectors, page, importDashboard }) => {
await importDashboard('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
@@ -125,10 +113,8 @@ test.describe(
await checkRepeatedTabTitles(dashboardPage, selectors, newTitleBase, repeatOptions);
});
test('can update repeats after panel change', async ({ dashboardPage, selectors, page }) => {
await importTestDashboard(
page,
selectors,
test('can update repeats after panel change', async ({ dashboardPage, selectors, page, importDashboard }) => {
await importDashboard(
'Tabs layout repeats - update repeats after panel change',
JSON.stringify(V2DashWithTabRepeats)
);
@@ -165,10 +151,13 @@ test.describe(
).toBeVisible();
});
test('can update repeats after panel change in editor', async ({ dashboardPage, selectors, page }) => {
await importTestDashboard(
page,
selectors,
test('can update repeats after panel change in editor', async ({
dashboardPage,
selectors,
page,
importDashboard,
}) => {
await importDashboard(
'Tabs layout repeats - update repeats after panel change in editor',
JSON.stringify(V2DashWithTabRepeats)
);
@@ -225,10 +214,13 @@ test.describe(
).toBeVisible();
});
test('can hide canvas grid add row action in repeats', async ({ dashboardPage, selectors, page }) => {
await importTestDashboard(
page,
selectors,
test('can hide canvas grid add row action in repeats', async ({
dashboardPage,
selectors,
page,
importDashboard,
}) => {
await importDashboard(
'Tabs layout repeats - hide canvas add action in repeats',
JSON.stringify(V2DashWithTabRepeats)
);
@@ -244,13 +236,8 @@ test.describe(
await expect(dashboardPage.getByGrafanaSelector(selectors.components.CanvasGridAddActions.addRow)).toBeHidden();
});
test('can move repeated tabs', async ({ dashboardPage, selectors, page }) => {
await importTestDashboard(
page,
selectors,
'Tabs layout repeats - move repeated tabs',
JSON.stringify(V2DashWithTabRepeats)
);
test('can move repeated tabs', async ({ dashboardPage, selectors, page, importDashboard }) => {
await importDashboard('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');
@@ -269,13 +256,8 @@ test.describe(
expect(normalTab2?.x).toBeLessThan(repeatedTab2?.x || 0);
});
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)
);
test('can load into repeated tab', async ({ dashboardPage, selectors, page, importDashboard }) => {
await importDashboard('Tabs layout repeats - can load into repeated tab', JSON.stringify(V2DashWithTabRepeats));
await dashboardPage
.getByGrafanaSelector(selectors.components.Tab.title(`${repeatTitleBase}${repeatOptions.at(2)}`))
@@ -292,13 +274,8 @@ test.describe(
).toBe('true');
});
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)
);
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));
// non repeated panel in repeated tab
await dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel')).first().hover();
@@ -367,10 +344,8 @@ test.describe(
).toBeVisible();
});
test('can view embedded panels in repeated tab', async ({ dashboardPage, selectors, page }) => {
await importTestDashboard(
page,
selectors,
test('can view embedded panels in repeated tab', async ({ dashboardPage, selectors, page, importDashboard }) => {
await importDashboard(
'Tabs layout repeats - view embedded panels in repeated tabs',
JSON.stringify(V2DashWithTabRepeats)
);
@@ -417,13 +392,8 @@ test.describe(
).toBeVisible();
});
test('can remove repeats', async ({ dashboardPage, selectors, page }) => {
await importTestDashboard(
page,
selectors,
'Tabs layout repeats - remove repeats',
JSON.stringify(V2DashWithTabRepeats)
);
test('can remove repeats', async ({ dashboardPage, selectors, page, importDashboard }) => {
await importDashboard('Tabs layout repeats - remove repeats', JSON.stringify(V2DashWithTabRepeats));
// verify 5 tabs are present (4 repeats and 1 normal)
await checkRepeatedTabTitles(dashboardPage, selectors, repeatTitleBase, repeatOptions);
@@ -0,0 +1,31 @@
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';
+15 -1
View File
@@ -160,7 +160,12 @@ 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) {
export async function importTestDashboard(
page: Page,
selectors: E2ESelectorGroups,
title: string,
dashInput?: string
): Promise<string> {
await page.goto(selectors.pages.ImportDashboard.url);
await page
.getByTestId(selectors.components.DashboardImportPage.textarea)
@@ -177,6 +182,15 @@ export async function importTestDashboard(page: Page, selectors: E2ESelectorGrou
}
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) {
@@ -249,7 +249,6 @@ const injectedRtkApi = api
permission: queryArg.permission,
sort: queryArg.sort,
limit: queryArg.limit,
ownerReference: queryArg.ownerReference,
explain: queryArg.explain,
},
}),
@@ -677,8 +676,6 @@ 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;
};
-88
View File
@@ -1,88 +0,0 @@
package auditing
import (
"encoding/json"
"time"
)
type Event struct {
// The namespace the action was performed in.
Namespace string `json:"namespace"`
// When it happened.
ObservedAt time.Time `json:"-"` // see MarshalJSON for why this is omitted
// Who/what performed the action.
SubjectName string `json:"subjectName"`
SubjectUID string `json:"subjectUID"`
// What was performed.
Verb string `json:"verb"`
// The object the action was performed on. For verbs like "list" this will be empty.
Object string `json:"object,omitempty"`
// API information.
APIGroup string `json:"apiGroup,omitempty"`
APIVersion string `json:"apiVersion,omitempty"`
Kind string `json:"kind,omitempty"`
// Outcome of the action.
Outcome EventOutcome `json:"outcome"`
// Extra fields to add more context to the event.
Extra map[string]string `json:"extra,omitempty"`
}
func (e Event) Time() time.Time {
return e.ObservedAt
}
func (e Event) MarshalJSON() ([]byte, error) {
type Alias Event
return json.Marshal(&struct {
FormattedTimestamp string `json:"observedAt"`
Alias
}{
FormattedTimestamp: e.ObservedAt.UTC().Format(time.RFC3339Nano),
Alias: (Alias)(e),
})
}
func (e Event) KVPairs() []any {
args := []any{
"audit", true,
"namespace", e.Namespace,
"observedAt", e.ObservedAt.UTC().Format(time.RFC3339Nano),
"subjectName", e.SubjectName,
"subjectUID", e.SubjectUID,
"verb", e.Verb,
"object", e.Object,
"apiGroup", e.APIGroup,
"apiVersion", e.APIVersion,
"kind", e.Kind,
"outcome", e.Outcome,
}
if len(e.Extra) > 0 {
extraArgs := make([]any, 0, len(e.Extra)*2)
for k, v := range e.Extra {
extraArgs = append(extraArgs, "extra_"+k, v)
}
args = append(args, extraArgs...)
}
return args
}
type EventOutcome string
const (
EventOutcomeUnknown EventOutcome = "unknown"
EventOutcomeSuccess EventOutcome = "success"
EventOutcomeFailureUnauthorized EventOutcome = "failure_unauthorized"
EventOutcomeFailureNotFound EventOutcome = "failure_not_found"
EventOutcomeFailureGeneric EventOutcome = "failure_generic"
)
-64
View File
@@ -1,64 +0,0 @@
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)
})
}
-35
View File
@@ -190,32 +190,6 @@ 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",
@@ -484,15 +458,6 @@ 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{
-17
View File
@@ -129,23 +129,6 @@ 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
+1 -1
View File
@@ -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, api.Cfg.UnifiedAlerting, api.FeatureManager),
backtesting: backtesting.NewEngine(api.AppUrl, api.EvaluatorFactory, api.Tracer),
featureManager: api.FeatureManager,
appUrl: api.AppUrl,
tracer: api.Tracer,
+47 -15
View File
@@ -34,6 +34,7 @@ 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 {
@@ -229,27 +230,54 @@ func (srv TestingApiSrv) BacktestAlertRule(c *contextmodel.ReqContext, cmd apimo
return ErrResp(http.StatusNotFound, nil, "Backgtesting API is not enabled")
}
rule, err := apivalidation.ValidateBacktestConfig(c.GetOrgID(), cmd, apivalidation.RuleLimitsFromConfig(srv.cfg, srv.featureManager))
if err != nil {
return ErrResp(http.StatusBadRequest, err, "")
if cmd.From.After(cmd.To) {
return ErrResp(400, nil, "From cannot be greater than To")
}
if err := srv.authz.AuthorizeDatasourceAccessForRule(c.Req.Context(), c.SignedInUser, rule); err != nil {
noDataState, err := ngmodels.NoDataStateFromString(string(cmd.NoDataState))
if err != nil {
return ErrResp(400, err, "")
}
forInterval := time.Duration(cmd.For)
if forInterval < 0 {
return ErrResp(400, nil, "Bad For interval")
}
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 {
return errorToResponse(err)
}
// 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
}
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,
}
result, err := srv.backtesting.Test(c.Req.Context(), c.SignedInUser, rule, cmd.From, cmd.To, folderTitle)
result, err := srv.backtesting.Test(c.Req.Context(), c.SignedInUser, rule, cmd.From, cmd.To)
if err != nil {
if errors.Is(err, backtesting.ErrInvalidInputData) {
return ErrResp(400, err, "Failed to evaluate")
@@ -257,5 +285,9 @@ func (srv TestingApiSrv) BacktestAlertRule(c *contextmodel.ReqContext, cmd apimo
return ErrResp(500, err, "Failed to evaluate")
}
return response.JSONStreaming(http.StatusOK, result)
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)
}
+2 -8
View File
@@ -81,15 +81,9 @@ 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": // TODO (yuri) this should be protected by dedicated permission
case http.MethodPost + "/api/v1/rule/backtest":
// additional authorization is done in the request handler
eval = ac.EvalAll(
ac.EvalPermission(ac.ActionAlertingRuleRead),
ac.EvalAny(
ac.EvalPermission(ac.ActionAlertingRuleUpdate),
ac.EvalPermission(ac.ActionAlertingRuleCreate),
),
)
eval = ac.EvalPermission(ac.ActionAlertingRuleRead)
case http.MethodPost + "/api/v1/eval":
// additional authorization is done in the request handler
eval = ac.EvalPermission(ac.ActionAlertingRuleRead)
@@ -221,21 +221,15 @@ 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"`
KeepFiringFor *model.Duration `json:"keep_firing_for,omitempty"`
Condition string `json:"condition"`
Data []AlertQuery `json:"data"`
For model.Duration `json:"for,omitempty"`
Title string `json:"title"`
Labels map[string]string `json:"labels,omitempty"`
Title string `json:"title"`
Labels map[string]string `json:"labels,omitempty"`
Annotations map[string]string `json:"annotations,omitempty"`
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"`
NoDataState NoDataState `json:"no_data_state"`
}
// swagger:model
@@ -249,21 +249,6 @@ 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())
@@ -351,11 +336,18 @@ func ValidateRuleGroup(
return nil, fmt.Errorf("rule group name is too long. Max length is %d", store.AlertRuleMaxRuleGroupNameLength)
}
interval, err := validateGroupInterval(ruleGroupConfig.Interval, limits)
if err != nil {
return nil, err
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
}
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)
@@ -418,32 +410,3 @@ 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)
}
+46 -173
View File
@@ -15,16 +15,10 @@ 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 (
@@ -34,7 +28,7 @@ var (
backtestingEvaluatorFactory = newBacktestingEvaluator
)
type callbackFunc = func(evaluationIndex int, now time.Time, results eval.Results) (bool, error)
type callbackFunc = func(evaluationIndex int, now time.Time, results eval.Results) error
type backtestingEvaluator interface {
Eval(ctx context.Context, from time.Time, interval time.Duration, evaluations int, callback callbackFunc) error
@@ -46,17 +40,11 @@ type stateManager interface {
}
type Engine struct {
evalFactory eval.EvaluatorFactory
createStateManager func() stateManager
disableGrafanaFolder bool
featureToggles featuremgmt.FeatureToggles
minInterval time.Duration
baseInterval time.Duration
jitterStrategy schedule.JitterStrategy
maxEvaluations int
evalFactory eval.EvaluatorFactory
createStateManager func() stateManager
}
func NewEngine(appUrl *url.URL, evalFactory eval.EvaluatorFactory, tracer tracing.Tracer, cfg setting.UnifiedAlertingSettings, toggles featuremgmt.FeatureToggles) *Engine {
func NewEngine(appUrl *url.URL, evalFactory eval.EvaluatorFactory, tracer tracing.Tracer) *Engine {
return &Engine{
evalFactory: evalFactory,
createStateManager: func() stateManager {
@@ -72,139 +60,74 @@ 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, 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 [%d,%d]", ErrInvalidInputData, from.Unix(), to.Unix())
}
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(ruleCtx).New("backtesting", util.GenerateShortUID())
logger := logger.FromContext(ctx)
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))
if !from.Before(to) {
return nil, fmt.Errorf("%w: invalid interval of the backtesting [%d,%d]", ErrInvalidInputData, from.Unix(), to.Unix())
}
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)
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)
}
length := int(to.Sub(from).Seconds()) / int(rule.IntervalSeconds)
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
}
stateManager := e.createStateManager()
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,
},
)
evaluator, err := backtestingEvaluatorFactory(ruleCtx, e.evalFactory, user, rule.GetEvalCondition().WithSource("backtesting"), &schedule.AlertingResultsFromRuleState{
Manager: stateManager,
Rule: rule,
})
if err != nil {
return nil, errors.Join(ErrInvalidInputData, err)
}
logger.Info("Start testing alert rule", "from", from, "to", to, "interval", rule.GetInterval(), "firstTick", firstEval, "evaluations", evaluations, "jitterOffset", jitterOffset, "jitterStrategy", effectiveStrategy)
logger.Info("Start testing alert rule", "from", from, "to", to, "interval", rule.IntervalSeconds, "evaluations", length)
var builder *historian.QueryResultBuilder
start := time.Now()
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,
}
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
}
tsField := data.NewField("Time", nil, make([]time.Time, length))
valueFields := make(map[data.Fingerprint]*data.Field)
// 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)
}
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 := stateMgr.ProcessEvalResults(ruleCtx, currentTime, rule, results, extraLabels, nil)
states := stateManager.ProcessEvalResults(ruleCtx, currentTime, rule, results, nil, nil)
tsField.Set(idx, currentTime)
for _, s := range states {
if !historian.ShouldRecord(s) {
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
}
entry := historian.StateTransitionToLokiEntry(ruleMeta, s)
err := builder.AddRow(currentTime, entry, labelsBytes)
if err != nil {
return false, err
}
}
return idx <= evaluations, nil
return nil
})
fields := make([]*data.Field, 0, len(valueFields)+1)
fields = append(fields, tsField)
for _, f := range valueFields {
fields = append(fields, f)
}
result := data.NewFrame("Testing results", fields...)
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
logger.Info("Rule testing finished successfully", "duration", time.Since(start))
return result, nil
}
func newBacktestingEvaluator(ctx context.Context, evalFactory eval.EvaluatorFactory, user identity.Requester, condition models.Condition, reader eval.AlertingResultsReader) (backtestingEvaluator, error) {
@@ -250,53 +173,3 @@ 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
}
+147 -191
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"math/rand"
"testing"
"time"
@@ -13,11 +14,9 @@ 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"
)
@@ -159,6 +158,16 @@ 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
}
@@ -180,17 +189,84 @@ 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)
@@ -211,26 +287,84 @@ 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, "")
_, 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)
require.ErrorIs(t, err, ErrInvalidInputData)
})
})
@@ -242,7 +376,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)
})
})
@@ -270,188 +404,10 @@ func (f *fakeBacktestingEvaluator) Eval(_ context.Context, from time.Time, inter
if err != nil {
return err
}
c, err := callback(idx, now, results)
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,13 +85,10 @@ func (d *dataEvaluator) Eval(_ context.Context, from time.Time, interval time.Du
EvaluatedAt: now,
})
}
cont, err := callback(i, now, result)
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) (bool, error) {
err = evaluator.Eval(context.Background(), from, time.Second, resultsCount, func(idx int, now time.Time, res eval.Results) error {
r = append(r, results{
now, res,
})
return true, nil
return 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) (bool, error) {
err = evaluator.Eval(context.Background(), from, interval, int(size), func(idx int, now time.Time, res eval.Results) error {
r = append(r, results{
now, res,
})
return true, nil
return 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) (bool, error) {
err = evaluator.Eval(context.Background(), from, interval, size, func(idx int, now time.Time, res eval.Results) error {
r = append(r, results{
now, res,
})
return true, nil
return 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) (bool, error) {
err = evaluator.Eval(context.Background(), newFrom, time.Second, cap(r), func(idx int, now time.Time, res eval.Results) error {
r = append(r, results{
now, res,
})
return true, nil
return 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) (bool, error) {
err = evaluator.Eval(context.Background(), from, time.Second, cap(r), func(idx int, now time.Time, res eval.Results) error {
r = append(r, results{
now, res,
})
return true, nil
return nil
})
rowIdx := 0
@@ -282,21 +282,12 @@ 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) (bool, error) {
err = evaluator.Eval(context.Background(), from, time.Second, 6, func(idx int, now time.Time, res eval.Results) error {
if idx == 5 {
return false, expectedError
return expectedError
}
return true, nil
return 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,13 +18,10 @@ func (d *queryEvaluator) Eval(ctx context.Context, from time.Time, interval time
if err != nil {
return err
}
cont, err := callback(idx, now, results)
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) (bool, error) {
err := evaluator.Eval(ctx, from, interval, times, func(idx int, now time.Time, results eval.Results) error {
intervals[idx] = now
return true, nil
return 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", func(t *testing.T) {
t.Run("should stop evaluation if error", 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) (bool, error) {
err := evaluator.Eval(ctx, from, interval, times, func(idx int, now time.Time, results eval.Results) error {
intervals = append(intervals, now)
return true, nil
return nil
})
require.ErrorIs(t, err, expectedError)
require.Len(t, intervals, 3)
@@ -81,31 +81,14 @@ 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) (bool, error) {
err := evaluator.Eval(ctx, from, interval, times, func(idx int, now time.Time, results eval.Results) error {
if len(intervals) > 3 {
return false, expectedError
return expectedError
}
intervals = append(intervals, now)
return true, nil
return 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,10 +480,6 @@ 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 {
-10
View File
@@ -5,7 +5,6 @@ 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"
@@ -14,10 +13,6 @@ 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
@@ -62,11 +57,6 @@ 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,11 +44,7 @@ func New(c clock.Clock, interval time.Duration, metric *Metrics, logger log.Logg
}
func getStartTick(clk clock.Clock, interval time.Duration) time.Time {
return GetStartTick(clk.Now(), interval)
}
func GetStartTick(t time.Time, interval time.Duration) time.Time {
nano := t.UnixNano()
nano := clk.Now().UnixNano()
return time.Unix(0, nano-(nano%interval.Nanoseconds()))
}
+3 -3
View File
@@ -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))
})
}
}
+42 -90
View File
@@ -41,69 +41,6 @@ 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"
@@ -254,7 +191,20 @@ func (h RemoteLokiBackend) merge(res []lokiclient.Stream, folderUIDToFilter []st
totalLen += len(arr.Values)
}
queryResult := NewQueryResultBuilder(totalLen)
// 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)
// Initialize a slice of pointers to the current position in each array.
pointers := make([]int, len(res))
@@ -309,10 +259,17 @@ func (h RemoteLokiBackend) merge(res []lokiclient.Stream, folderUIDToFilter []st
pointers[minElStreamIdx]++
continue
}
queryResult.AddRowRaw(time.Unix(0, tsNano), entryBytes, lblsJson)
times = append(times, time.Unix(0, tsNano))
labels = append(labels, lblsJson)
lines = append(lines, json.RawMessage(entryBytes))
pointers[minElStreamIdx]++
}
return queryResult.ToFrame(), nil
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
}
func StatesToStream(rule history_model.RuleMeta, states []state.StateTransition, externalLabels map[string]string, logger log.Logger) lokiclient.Stream {
@@ -325,11 +282,28 @@ 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
}
entry := StateTransitionToLokiEntry(rule, state)
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()
}
jsn, err := json.Marshal(entry)
if err != nil {
@@ -350,28 +324,6 @@ 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
-7
View File
@@ -156,8 +156,6 @@ type UnifiedAlertingSettings struct {
// AlertmanagerMaxTemplateOutputSize specifies the maximum allowed size for rendered template output in bytes.
AlertmanagerMaxTemplateOutputSize int64
BacktestingMaxEvaluations int
}
type RecordingRuleSettings struct {
@@ -596,11 +594,6 @@ 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
}
-17
View File
@@ -11,7 +11,6 @@ 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"
@@ -121,22 +120,6 @@ 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)
-10
View File
@@ -101,11 +101,6 @@ 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"`
@@ -222,10 +217,6 @@ 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()
}
@@ -304,7 +295,6 @@ 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,10 +48,6 @@ 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"
@@ -16,41 +16,10 @@ 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
}
// 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)
}
}
// TODO: support other field selectors
}
// The required names
@@ -73,6 +42,9 @@ 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
+1
View File
@@ -22,6 +22,7 @@ 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,16 +13,7 @@
"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",
+10 -16
View File
@@ -1559,20 +1559,17 @@ 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:
case selection.Equals, selection.DoubleEquals:
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 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 (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 {
@@ -1588,6 +1585,11 @@ 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
@@ -1620,14 +1622,6 @@ 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),
+3 -13
View File
@@ -60,7 +60,7 @@ func getBleveDocMappings(fields resource.SearchableDocumentFields) *mapping.Docu
}
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_DESCRIPTION, descriptionMapping)
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_TAGS, &mapping.FieldMapping{
tagsMapping := &mapping.FieldMapping{
Name: resource.SEARCH_FIELD_TAGS,
Type: "text",
Analyzer: keyword.Name,
@@ -69,18 +69,8 @@ func getBleveDocMappings(fields resource.SearchableDocumentFields) *mapping.Docu
IncludeTermVectors: false,
IncludeInAll: true,
DocValues: false,
})
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,
})
}
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_TAGS, tagsMapping)
folderMapping := &mapping.FieldMapping{
Name: resource.SEARCH_FIELD_FOLDER,
@@ -36,7 +36,6 @@ func TestDocumentMapping(t *testing.T) {
Checksum: "ooo",
TimestampMillis: 1234,
},
OwnerReferences: []string{"iam.grafana.app/Team/devops", "iam.grafana.app/User/xyz"},
}
data.UpdateCopyFields()
@@ -50,5 +49,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, 19, len(doc.Fields))
require.Equal(t, 17, len(doc.Fields))
}
@@ -16,9 +16,5 @@
"kind": "repo",
"id": "MyGIT"
},
"managedBy": "repo:MyGIT",
"ownerReferences": [
"iam.grafana.app/Team/engineering",
"iam.grafana.app/User/test"
]
"managedBy": "repo:MyGIT"
}
@@ -9,16 +9,7 @@
"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.Equalf(t, http.StatusOK, status, "Response: %s", body)
require.Equal(t, http.StatusOK, status)
var result data.Frame
require.NoErrorf(t, json.Unmarshal([]byte(body), &result), "cannot parse response to data frame")
})
@@ -107,7 +107,6 @@ func TestBacktesting(t *testing.T) {
resourcepermissions.SetResourcePermissionCommand{
Actions: []string{
accesscontrol.ActionAlertingRuleRead,
accesscontrol.ActionAlertingRuleUpdate,
},
Resource: "folders",
ResourceID: "*",
@@ -12,9 +12,6 @@
},
"condition": "A",
"no_data_state": "Alerting",
"title": "test-rule-backtesting-data",
"rule_group": "test-group",
"namespace_uid": "test-namespace",
"data": [
{
"refId": "A",
@@ -196,9 +193,6 @@
},
"condition": "C",
"no_data_state": "Alerting",
"title": "test-rule-backtesting-data",
"rule_group": "test-group",
"namespace_uid": "test-namespace",
"data": [
{
"refId": "A",
-76
View File
@@ -2202,79 +2202,3 @@ 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,25 +1873,6 @@
"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",
+5 -11
View File
@@ -1,29 +1,23 @@
import { isArray, isPlainObject, isString } from 'lodash';
import { isArray, isPlainObject } 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, stripBOMs?: boolean): T {
export function sortedDeepCloneWithoutNulls<T>(value: T, convertInfinity?: boolean): T {
if (isArray(value)) {
return value.map((item) => sortedDeepCloneWithoutNulls(item, convertInfinity, stripBOMs)) as unknown as T;
return value.map((item) => sortedDeepCloneWithoutNulls(item, convertInfinity)) as unknown as T;
}
if (isPlainObject(value)) {
return Object.keys(value as { [key: string]: any })
.sort()
.reduce((acc: any, key) => {
let v = (value as any)[key];
const v = (value as any)[key];
// Remove null values
if (v != null) {
// Strip BOMs from strings
if (stripBOMs && isString(v)) {
v = v.replace(/\ufeff/g, '');
}
acc[key] = sortedDeepCloneWithoutNulls(v, convertInfinity, stripBOMs);
acc[key] = sortedDeepCloneWithoutNulls(v, convertInfinity);
}
if (convertInfinity && (v === Infinity || v === -Infinity)) {
@@ -1,50 +0,0 @@
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;
@@ -1,63 +0,0 @@
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>
)}
</>
);
}
@@ -1,200 +0,0 @@
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',
}),
});
@@ -60,7 +60,6 @@ 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';
@@ -291,8 +290,6 @@ 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, SceneQueryRunner } from '@grafana/scenes';
import { GroupByVariable, sceneGraph } from '@grafana/scenes';
import { AdHocFilterItem, PanelContext } from '@grafana/ui';
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
import { findVizPanelByKey, getQueryRunnerFor } from '../utils/utils';
import { findVizPanelByKey } from '../utils/utils';
import { getAdHocFilterVariableFor, setDashboardPanelContext } from './setDashboardPanelContext';
@@ -159,23 +159,6 @@ 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', () => {
@@ -329,7 +312,6 @@ interface SceneOptions {
existingFilterVariable?: boolean;
existingGroupByVariable?: boolean;
groupByDatasourceUid?: string;
panelDatasourceUndefined?: boolean;
}
function buildTestScene(options: SceneOptions) {
@@ -403,19 +385,6 @@ 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,12 +6,7 @@ import { AdHocFilterItem, PanelContext } from '@grafana/ui';
import { annotationServer } from 'app/features/annotations/api';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
import {
getDashboardSceneFor,
getDatasourceFromQueryRunner,
getPanelIdForVizPanel,
getQueryRunnerFor,
} from '../utils/utils';
import { getDashboardSceneFor, getPanelIdForVizPanel, getQueryRunnerFor } from '../utils/utils';
import { DashboardScene } from './DashboardScene';
@@ -126,7 +121,7 @@ export function setDashboardPanelContext(vizPanel: VizPanel, context: PanelConte
context.eventBus.publish(new AnnotationChangeEvent({ id }));
};
context.onAddAdHocFilter = async (newFilter: AdHocFilterItem) => {
context.onAddAdHocFilter = (newFilter: AdHocFilterItem) => {
const dashboard = getDashboardSceneFor(vizPanel);
const queryRunner = getQueryRunnerFor(vizPanel);
@@ -134,19 +129,7 @@ export function setDashboardPanelContext(vizPanel: VizPanel, context: PanelConte
return;
}
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);
const filterVar = getAdHocFilterVariableFor(dashboard, queryRunner.state.datasource);
updateAdHocFilterVariable(filterVar, newFilter);
};
@@ -158,8 +141,7 @@ export function setDashboardPanelContext(vizPanel: VizPanel, context: PanelConte
return [];
}
const datasource = getDatasourceFromQueryRunner(queryRunner);
const groupByVar = getGroupByVariableFor(dashboard, datasource);
const groupByVar = getGroupByVariableFor(dashboard, queryRunner.state.datasource);
if (!groupByVar) {
return [];
@@ -176,7 +158,7 @@ export function setDashboardPanelContext(vizPanel: VizPanel, context: PanelConte
.filter((item) => item !== undefined);
};
context.onAddAdHocFilters = async (items: AdHocFilterItem[]) => {
context.onAddAdHocFilters = (items: AdHocFilterItem[]) => {
const dashboard = getDashboardSceneFor(vizPanel);
const queryRunner = getQueryRunnerFor(vizPanel);
@@ -184,18 +166,7 @@ export function setDashboardPanelContext(vizPanel: VizPanel, context: PanelConte
return;
}
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);
const filterVar = getAdHocFilterVariableFor(dashboard, queryRunner.state.datasource);
bulkUpdateAdHocFiltersVariable(filterVar, items);
};
@@ -144,8 +144,7 @@ export function transformSceneToSaveModelSchemaV2(scene: DashboardScene, isSnaps
try {
// validateDashboardSchemaV2 will throw an error if the dashboard is not valid
if (validateDashboardSchemaV2(dashboardSchemaV2)) {
// Strip BOMs from all strings to prevent CUE validation errors ("illegal byte order mark")
return sortedDeepCloneWithoutNulls(dashboardSchemaV2, true, true);
return sortedDeepCloneWithoutNulls(dashboardSchemaV2, 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,8 +3,6 @@ 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,
@@ -28,7 +26,7 @@ export async function getDrilldownApplicability(
return;
}
const datasource = getDatasourceFromQueryRunner(queryRunner);
const datasource = queryRunner.state.datasource;
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 { getDatasourceFromQueryRunner, getQueryRunnerFor } from './utils';
import { getQueryRunnerFor } from './utils';
export function getViewPanelUrl(vizPanel: VizPanel) {
return locationUtil.getUrlForPartial(locationService.getLocation(), {
@@ -27,11 +27,10 @@ export function tryGetExploreUrlForPanel(vizPanel: VizPanel): Promise<string | u
}
const timeRange = sceneGraph.getTimeRange(vizPanel);
const datasource = getDatasourceFromQueryRunner(queryRunner);
return getExploreUrl({
queries: queryRunner.state.queries,
dsRef: datasource,
dsRef: queryRunner.state.datasource,
timeRange: timeRange.state.value,
scopedVars: { __sceneObject: { value: vizPanel } },
adhocFilters: queryRunner.state.data?.request?.filters,
@@ -1,4 +1,4 @@
import { DataSourceRef, getDataSourceRef, IntervalVariableModel } from '@grafana/data';
import { getDataSourceRef, IntervalVariableModel } from '@grafana/data';
import { t } from '@grafana/i18n';
import { config, getDataSourceSrv } from '@grafana/runtime';
import {
@@ -237,26 +237,6 @@ 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();
+1 -10
View File
@@ -729,11 +729,6 @@
"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"
@@ -2224,15 +2219,11 @@
"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",
"testRule": ""
"previewCondition": "Podmínka pravidla náhledu výstrahy"
},
"receiver-filter": {
"aria-label-contact-points": "Filtrovat podle kontaktních bodů",
+1 -10
View File
@@ -723,11 +723,6 @@
"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"
@@ -2208,15 +2203,11 @@
"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",
"testRule": ""
"previewCondition": "Vorschau der Warnregelbedingung"
},
"receiver-filter": {
"aria-label-contact-points": "Nach Kontaktpunkten filtern",
+1 -10
View File
@@ -723,11 +723,6 @@
"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"
@@ -2208,15 +2203,11 @@
"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",
"testRule": "Test Rule"
"previewCondition": "Preview alert rule condition"
},
"receiver-filter": {
"aria-label-contact-points": "Filter by contact points",
+1 -10
View File
@@ -723,11 +723,6 @@
"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"
@@ -2208,15 +2203,11 @@
"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",
"testRule": ""
"previewCondition": "Vista previa de la condición de la regla de alerta"
},
"receiver-filter": {
"aria-label-contact-points": "Filtrar por puntos de contacto",
+1 -10
View File
@@ -723,11 +723,6 @@
"placeholder-value-input": "",
"placeholder-value-input-default": "Saisir le contenu de lannotation personnalisée..."
},
"backtest": {
"error-title": "",
"loading": "",
"panel-title": ""
},
"bulk-actions": {
"delete": {
"success": "Règles supprimées du dossier"
@@ -2208,15 +2203,11 @@
"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",
"testRule": ""
"previewCondition": "Aperçu de la condition de la règle d'alerte"
},
"receiver-filter": {
"aria-label-contact-points": "Filtrer par points de contact",
+1 -10
View File
@@ -723,11 +723,6 @@
"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"
@@ -2208,15 +2203,11 @@
"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",
"testRule": ""
"previewCondition": "Riasztási szabály előnézeti feltétele"
},
"receiver-filter": {
"aria-label-contact-points": "Szűrés kapcsolattartási pontok szerint",
+1 -10
View File
@@ -720,11 +720,6 @@
"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"
@@ -2200,15 +2195,11 @@
"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",
"testRule": ""
"previewCondition": "Pratinjau kondisi aturan peringatan"
},
"receiver-filter": {
"aria-label-contact-points": "Filter berdasarkan titik kontak",
+1 -10
View File
@@ -723,11 +723,6 @@
"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"
@@ -2208,15 +2203,11 @@
"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",
"testRule": ""
"previewCondition": "Anteprima condizione regola di avviso"
},
"receiver-filter": {
"aria-label-contact-points": "Filtra per punti di contatto",
+1 -10
View File
@@ -720,11 +720,6 @@
"placeholder-value-input": "",
"placeholder-value-input-default": "カスタム注釈内容を入力..."
},
"backtest": {
"error-title": "",
"loading": "",
"panel-title": ""
},
"bulk-actions": {
"delete": {
"success": "ルールがフォルダから正常に削除されました"
@@ -2200,15 +2195,11 @@
"min-interval": "最小間隔= {{minInterval}}"
},
"queryAndExpressionsStep": {
"custom": "",
"disableAdvancedOptions": {
"text": "選択したクエリと式はデフォルトに変換できません。高度なオプションを無効にすると、クエリと条件はデフォルト設定にリセットされます。"
},
"last15m": "",
"last1h": "",
"preview": "プレビュー",
"previewCondition": "アラートルール条件をプレビューする",
"testRule": ""
"previewCondition": "アラートルール条件をプレビューする"
},
"receiver-filter": {
"aria-label-contact-points": "コンタクトポイントで絞り込む",
+1 -10
View File
@@ -720,11 +720,6 @@
"placeholder-value-input": "",
"placeholder-value-input-default": "사용자 지정 주석 내용을 입력하세요..."
},
"backtest": {
"error-title": "",
"loading": "",
"panel-title": ""
},
"bulk-actions": {
"delete": {
"success": "폴더에서 규칙이 성공적으로 삭제되었습니다"
@@ -2200,15 +2195,11 @@
"min-interval": "최소 간격 = {{minInterval}}"
},
"queryAndExpressionsStep": {
"custom": "",
"disableAdvancedOptions": {
"text": "선택한 쿼리와 표현식을 기본값으로 변환할 수 없습니다. 고급 옵션을 비활성화하면 쿼리와 조건이 기본 설정으로 재설정됩니다."
},
"last15m": "",
"last1h": "",
"preview": "미리보기",
"previewCondition": "경고 규칙 조건 미리보기",
"testRule": ""
"previewCondition": "경고 규칙 조건 미리보기"
},
"receiver-filter": {
"aria-label-contact-points": "연락처로 필터링",
+1 -10
View File
@@ -723,11 +723,6 @@
"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"
@@ -2208,15 +2203,11 @@
"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",
"testRule": ""
"previewCondition": "Voorbeeld waarschuwingsregel voorwaarde"
},
"receiver-filter": {
"aria-label-contact-points": "Filteren op contactpunten",
+1 -10
View File
@@ -729,11 +729,6 @@
"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"
@@ -2224,15 +2219,11 @@
"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",
"testRule": ""
"previewCondition": "Podgląd warunku reguły alertu"
},
"receiver-filter": {
"aria-label-contact-points": "Filtruj według punktów kontaktu",
+1 -10
View File
@@ -723,11 +723,6 @@
"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"
@@ -2208,15 +2203,11 @@
"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",
"testRule": ""
"previewCondition": "Visualizar condição de regra de alerta"
},
"receiver-filter": {
"aria-label-contact-points": "Filtrar por pontos de contato",
+1 -10
View File
@@ -723,11 +723,6 @@
"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"
@@ -2208,15 +2203,11 @@
"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",
"testRule": ""
"previewCondition": "Pré-visualizar a condição da regra de alerta"
},
"receiver-filter": {
"aria-label-contact-points": "Filtrar por pontos de contacto",
+1 -10
View File
@@ -729,11 +729,6 @@
"placeholder-value-input": "",
"placeholder-value-input-default": "Ввести содержимое пользовательской аннотации..."
},
"backtest": {
"error-title": "",
"loading": "",
"panel-title": ""
},
"bulk-actions": {
"delete": {
"success": "Правила удалены из папки"
@@ -2224,15 +2219,11 @@
"min-interval": "Мин. интервал = {{minInterval}}"
},
"queryAndExpressionsStep": {
"custom": "",
"disableAdvancedOptions": {
"text": "Выбранные запросы и выражения не могут быть преобразованы в используемые по умолчанию. Если вы отключите расширенные параметры, ваш запрос и условие будут сброшены до настроек по умолчанию."
},
"last15m": "",
"last1h": "",
"preview": "Предварительный просмотр",
"previewCondition": "Предварительный просмотр условия правила оповещения",
"testRule": ""
"previewCondition": "Предварительный просмотр условия правила оповещения"
},
"receiver-filter": {
"aria-label-contact-points": "Фильтр по точкам контакта",
+1 -10
View File
@@ -723,11 +723,6 @@
"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"
@@ -2208,15 +2203,11 @@
"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",
"testRule": ""
"previewCondition": "Förhandsgranska varningsregeltillstånd"
},
"receiver-filter": {
"aria-label-contact-points": "Filtrera efter kontaktpunkter",
+1 -10
View File
@@ -723,11 +723,6 @@
"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"
@@ -2208,15 +2203,11 @@
"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",
"testRule": ""
"previewCondition": "Uyarı kuralı koşulunu ön izle"
},
"receiver-filter": {
"aria-label-contact-points": "",
+1 -10
View File
@@ -720,11 +720,6 @@
"placeholder-value-input": "",
"placeholder-value-input-default": "输入自定义注释内容..."
},
"backtest": {
"error-title": "",
"loading": "",
"panel-title": ""
},
"bulk-actions": {
"delete": {
"success": "规则已成功从文件夹中删除"
@@ -2200,15 +2195,11 @@
"min-interval": "最小间隔 = {{minInterval}}"
},
"queryAndExpressionsStep": {
"custom": "",
"disableAdvancedOptions": {
"text": "无法将所选查询和表达式转换为默认值。如果停用高级选项,您的查询和条件将重置为默认设置。"
},
"last15m": "",
"last1h": "",
"preview": "预览",
"previewCondition": "预览提醒规则条件",
"testRule": ""
"previewCondition": "预览提醒规则条件"
},
"receiver-filter": {
"aria-label-contact-points": "按联络点筛选",
+1 -10
View File
@@ -720,11 +720,6 @@
"placeholder-value-input": "",
"placeholder-value-input-default": "輸入自訂註解內容…"
},
"backtest": {
"error-title": "",
"loading": "",
"panel-title": ""
},
"bulk-actions": {
"delete": {
"success": "已成功從資料夾中刪除規則"
@@ -2200,15 +2195,11 @@
"min-interval": "最小間隔 = {{minInterval}}"
},
"queryAndExpressionsStep": {
"custom": "",
"disableAdvancedOptions": {
"text": "所選查詢和表達式無法轉換為預設值。如果停用進階選項,您的查詢和條件將重設為預設設定。"
},
"last15m": "",
"last1h": "",
"preview": "預覽",
"previewCondition": "預覽警報規則條件",
"testRule": ""
"previewCondition": "預覽警報規則條件"
},
"receiver-filter": {
"aria-label-contact-points": "按聯絡點篩選",