From ca0c09cb73788f750ad933a93184c4aa3f9b35a6 Mon Sep 17 00:00:00 2001 From: Ivan Ortega Alba Date: Mon, 15 Dec 2025 13:50:10 +0100 Subject: [PATCH 1/7] DashboardV2: Fix value mapping v1 to v2 (#115331) * DashboardV2: Fix value mapping v1 to v2 * Update apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go Co-authored-by: Dominik Prokop --------- Co-authored-by: Dominik Prokop --- .../v1beta1.value-mapping-and-overrides.json | 603 +++++++++++++ ....value-mapping-and-overrides.v0alpha1.json | 580 +++++++++++++ ....value-mapping-and-overrides.v2alpha1.json | 783 +++++++++++++++++ ...1.value-mapping-and-overrides.v2beta1.json | 795 ++++++++++++++++++ .../conversion/v1beta1_to_v2alpha1.go | 14 +- go.work.sum | 3 + 6 files changed, 2769 insertions(+), 9 deletions(-) create mode 100644 apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.value-mapping-and-overrides.json create mode 100644 apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.value-mapping-and-overrides.v0alpha1.json create mode 100644 apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.value-mapping-and-overrides.v2alpha1.json create mode 100644 apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.value-mapping-and-overrides.v2beta1.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.value-mapping-and-overrides.json b/apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.value-mapping-and-overrides.json new file mode 100644 index 00000000000..c0a82877ecc --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.value-mapping-and-overrides.json @@ -0,0 +1,603 @@ +{ + "kind": "DashboardWithAccessInfo", + "apiVersion": "dashboard.grafana.app/v1beta1", + "metadata": { + "name": "value-mapping-test", + "namespace": "default", + "uid": "value-mapping-test", + "resourceVersion": "1765384157199094", + "generation": 2, + "creationTimestamp": "2025-11-19T20:09:28Z", + "labels": { + "grafana.app/deprecatedInternalID": "646372978987008" + }, + "annotations": {}, + "managedFields": [] + }, + "spec": { + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "Test dashboard for all value mapping types and override matcher types", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, + "description": "Panel with ValueMap mapping type - maps specific text values to colors and display text", + "fieldConfig": { + "defaults": { + "mappings": [ + { + "options": { + "critical": { + "color": "red", + "index": 0, + "text": "Critical!" + }, + "warning": { + "color": "orange", + "index": 1, + "text": "Warning" + }, + "ok": { + "color": "green", + "index": 2, + "text": "OK" + } + }, + "type": "value" + } + ] + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "status" + }, + "properties": [ + { + "id": "custom.width", + "value": 100 + }, + { + "id": "custom.align", + "value": "center" + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "targets": [ + { + "expr": "up", + "refId": "A" + } + ], + "title": "ValueMap Example", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, + "description": "Panel with RangeMap mapping type - maps numerical ranges to colors and display text", + "fieldConfig": { + "defaults": { + "mappings": [ + { + "options": { + "from": 0, + "to": 50, + "result": { + "color": "green", + "index": 0, + "text": "Low" + } + }, + "type": "range" + }, + { + "options": { + "from": 50, + "to": 80, + "result": { + "color": "orange", + "index": 1, + "text": "Medium" + } + }, + "type": "range" + }, + { + "options": { + "from": 80, + "to": 100, + "result": { + "color": "red", + "index": 2, + "text": "High" + } + }, + "type": "range" + } + ] + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "/^cpu_/" + }, + "properties": [ + { + "id": "unit", + "value": "percent" + }, + { + "id": "decimals", + "value": 2 + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "targets": [ + { + "expr": "cpu_usage_percent", + "refId": "A" + } + ], + "title": "RangeMap Example", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, + "description": "Panel with RegexMap mapping type - maps values matching regex patterns to colors", + "fieldConfig": { + "defaults": { + "mappings": [ + { + "options": { + "pattern": "/^error.*/", + "result": { + "color": "red", + "index": 0, + "text": "Error" + } + }, + "type": "regex" + }, + { + "options": { + "pattern": "/^warn.*/", + "result": { + "color": "orange", + "index": 1, + "text": "Warning" + } + }, + "type": "regex" + }, + { + "options": { + "pattern": "/^info.*/", + "result": { + "color": "blue", + "index": 2, + "text": "Info" + } + }, + "type": "regex" + } + ] + }, + "overrides": [ + { + "matcher": { + "id": "byType", + "options": "string" + }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { + "type": "color-text" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "targets": [ + { + "expr": "log_level", + "refId": "A" + } + ], + "title": "RegexMap Example", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, + "description": "Panel with SpecialValueMap mapping type - maps special values like null, NaN, true, false to display text", + "fieldConfig": { + "defaults": { + "mappings": [ + { + "options": { + "match": "null", + "result": { + "color": "gray", + "index": 0, + "text": "No Data" + } + }, + "type": "special" + }, + { + "options": { + "match": "nan", + "result": { + "color": "gray", + "index": 1, + "text": "Not a Number" + } + }, + "type": "special" + }, + { + "options": { + "match": "null+nan", + "result": { + "color": "gray", + "index": 2, + "text": "N/A" + } + }, + "type": "special" + }, + { + "options": { + "match": "true", + "result": { + "color": "green", + "index": 3, + "text": "Yes" + } + }, + "type": "special" + }, + { + "options": { + "match": "false", + "result": { + "color": "red", + "index": 4, + "text": "No" + } + }, + "type": "special" + }, + { + "options": { + "match": "empty", + "result": { + "color": "gray", + "index": 5, + "text": "Empty" + } + }, + "type": "special" + } + ] + }, + "overrides": [ + { + "matcher": { + "id": "byFrameRefID", + "options": "A" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "blue" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 4, + "targets": [ + { + "expr": "some_metric", + "refId": "A" + } + ], + "title": "SpecialValueMap Example", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, + "description": "Panel with all mapping types combined - demonstrates mixing different mapping types and multiple override matchers", + "fieldConfig": { + "defaults": { + "mappings": [ + { + "options": { + "success": { + "color": "green", + "index": 0, + "text": "Success" + }, + "failure": { + "color": "red", + "index": 1, + "text": "Failure" + } + }, + "type": "value" + }, + { + "options": { + "from": 0, + "to": 100, + "result": { + "color": "blue", + "index": 2, + "text": "In Range" + } + }, + "type": "range" + }, + { + "options": { + "pattern": "/^[A-Z]{3}-\\d+$/", + "result": { + "color": "purple", + "index": 3, + "text": "ID Format" + } + }, + "type": "regex" + }, + { + "options": { + "match": "null", + "result": { + "color": "gray", + "index": 4, + "text": "Missing" + } + }, + "type": "special" + } + ] + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "status" + }, + "properties": [ + { + "id": "custom.width", + "value": 120 + }, + { + "id": "custom.cellOptions", + "value": { + "type": "color-background" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "/^value_/" + }, + "properties": [ + { + "id": "unit", + "value": "short" + }, + { + "id": "min", + "value": 0 + }, + { + "id": "max", + "value": 100 + } + ] + }, + { + "matcher": { + "id": "byType", + "options": "number" + }, + "properties": [ + { + "id": "decimals", + "value": 2 + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 50 + }, + { + "color": "red", + "value": 80 + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byFrameRefID", + "options": "B" + }, + "properties": [ + { + "id": "displayName", + "value": "Secondary Query" + } + ] + }, + { + "matcher": { + "id": "byValue", + "options": { + "reducer": "allIsNull", + "op": "gte", + "value": 0 + } + }, + "properties": [ + { + "id": "custom.hidden", + "value": true + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 16 + }, + "id": 5, + "targets": [ + { + "expr": "combined_metric", + "refId": "A" + }, + { + "expr": "secondary_metric", + "refId": "B" + } + ], + "title": "Combined Mappings and Overrides Example", + "type": "table" + } + ], + "schemaVersion": 42, + "tags": [ + "value-mapping", + "overrides", + "test" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Value Mapping and Overrides Test", + "weekStart": "" + }, + "status": { + "conversion": { + "failed": false, + "storedVersion": "v0alpha1" + } + }, + "access": { + "slug": "value-mapping-test", + "url": "/d/value-mapping-test/value-mapping-and-overrides-test", + "canSave": true, + "canEdit": true, + "canAdmin": true, + "canStar": true, + "canDelete": true, + "annotationsPermissions": { + "dashboard": { + "canAdd": true, + "canEdit": true, + "canDelete": true + }, + "organization": { + "canAdd": true, + "canEdit": true, + "canDelete": true + } + } + } +} diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.value-mapping-and-overrides.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.value-mapping-and-overrides.v0alpha1.json new file mode 100644 index 00000000000..c824b19412d --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.value-mapping-and-overrides.v0alpha1.json @@ -0,0 +1,580 @@ +{ + "kind": "DashboardWithAccessInfo", + "apiVersion": "dashboard.grafana.app/v0alpha1", + "metadata": { + "name": "value-mapping-test", + "namespace": "default", + "uid": "value-mapping-test", + "resourceVersion": "1765384157199094", + "generation": 2, + "creationTimestamp": "2025-11-19T20:09:28Z", + "labels": { + "grafana.app/deprecatedInternalID": "646372978987008" + } + }, + "spec": { + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "type": "dashboard" + } + ] + }, + "description": "Test dashboard for all value mapping types and override matcher types", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, + "description": "Panel with ValueMap mapping type - maps specific text values to colors and display text", + "fieldConfig": { + "defaults": { + "mappings": [ + { + "options": { + "critical": { + "color": "red", + "index": 0, + "text": "Critical!" + }, + "ok": { + "color": "green", + "index": 2, + "text": "OK" + }, + "warning": { + "color": "orange", + "index": 1, + "text": "Warning" + } + }, + "type": "value" + } + ] + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "status" + }, + "properties": [ + { + "id": "custom.width", + "value": 100 + }, + { + "id": "custom.align", + "value": "center" + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "targets": [ + { + "expr": "up", + "refId": "A" + } + ], + "title": "ValueMap Example", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, + "description": "Panel with RangeMap mapping type - maps numerical ranges to colors and display text", + "fieldConfig": { + "defaults": { + "mappings": [ + { + "options": { + "from": 0, + "result": { + "color": "green", + "index": 0, + "text": "Low" + }, + "to": 50 + }, + "type": "range" + }, + { + "options": { + "from": 50, + "result": { + "color": "orange", + "index": 1, + "text": "Medium" + }, + "to": 80 + }, + "type": "range" + }, + { + "options": { + "from": 80, + "result": { + "color": "red", + "index": 2, + "text": "High" + }, + "to": 100 + }, + "type": "range" + } + ] + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "/^cpu_/" + }, + "properties": [ + { + "id": "unit", + "value": "percent" + }, + { + "id": "decimals", + "value": 2 + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "targets": [ + { + "expr": "cpu_usage_percent", + "refId": "A" + } + ], + "title": "RangeMap Example", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, + "description": "Panel with RegexMap mapping type - maps values matching regex patterns to colors", + "fieldConfig": { + "defaults": { + "mappings": [ + { + "options": { + "pattern": "/^error.*/", + "result": { + "color": "red", + "index": 0, + "text": "Error" + } + }, + "type": "regex" + }, + { + "options": { + "pattern": "/^warn.*/", + "result": { + "color": "orange", + "index": 1, + "text": "Warning" + } + }, + "type": "regex" + }, + { + "options": { + "pattern": "/^info.*/", + "result": { + "color": "blue", + "index": 2, + "text": "Info" + } + }, + "type": "regex" + } + ] + }, + "overrides": [ + { + "matcher": { + "id": "byType", + "options": "string" + }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { + "type": "color-text" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "targets": [ + { + "expr": "log_level", + "refId": "A" + } + ], + "title": "RegexMap Example", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, + "description": "Panel with SpecialValueMap mapping type - maps special values like null, NaN, true, false to display text", + "fieldConfig": { + "defaults": { + "mappings": [ + { + "options": { + "match": "null", + "result": { + "color": "gray", + "index": 0, + "text": "No Data" + } + }, + "type": "special" + }, + { + "options": { + "match": "nan", + "result": { + "color": "gray", + "index": 1, + "text": "Not a Number" + } + }, + "type": "special" + }, + { + "options": { + "match": "null+nan", + "result": { + "color": "gray", + "index": 2, + "text": "N/A" + } + }, + "type": "special" + }, + { + "options": { + "match": "true", + "result": { + "color": "green", + "index": 3, + "text": "Yes" + } + }, + "type": "special" + }, + { + "options": { + "match": "false", + "result": { + "color": "red", + "index": 4, + "text": "No" + } + }, + "type": "special" + }, + { + "options": { + "match": "empty", + "result": { + "color": "gray", + "index": 5, + "text": "Empty" + } + }, + "type": "special" + } + ] + }, + "overrides": [ + { + "matcher": { + "id": "byFrameRefID", + "options": "A" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "blue", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 4, + "targets": [ + { + "expr": "some_metric", + "refId": "A" + } + ], + "title": "SpecialValueMap Example", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, + "description": "Panel with all mapping types combined - demonstrates mixing different mapping types and multiple override matchers", + "fieldConfig": { + "defaults": { + "mappings": [ + { + "options": { + "failure": { + "color": "red", + "index": 1, + "text": "Failure" + }, + "success": { + "color": "green", + "index": 0, + "text": "Success" + } + }, + "type": "value" + }, + { + "options": { + "from": 0, + "result": { + "color": "blue", + "index": 2, + "text": "In Range" + }, + "to": 100 + }, + "type": "range" + }, + { + "options": { + "pattern": "/^[A-Z]{3}-\\d+$/", + "result": { + "color": "purple", + "index": 3, + "text": "ID Format" + } + }, + "type": "regex" + }, + { + "options": { + "match": "null", + "result": { + "color": "gray", + "index": 4, + "text": "Missing" + } + }, + "type": "special" + } + ] + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "status" + }, + "properties": [ + { + "id": "custom.width", + "value": 120 + }, + { + "id": "custom.cellOptions", + "value": { + "type": "color-background" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "/^value_/" + }, + "properties": [ + { + "id": "unit", + "value": "short" + }, + { + "id": "min", + "value": 0 + }, + { + "id": "max", + "value": 100 + } + ] + }, + { + "matcher": { + "id": "byType", + "options": "number" + }, + "properties": [ + { + "id": "decimals", + "value": 2 + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 50 + }, + { + "color": "red", + "value": 80 + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byFrameRefID", + "options": "B" + }, + "properties": [ + { + "id": "displayName", + "value": "Secondary Query" + } + ] + }, + { + "matcher": { + "id": "byValue", + "options": { + "op": "gte", + "reducer": "allIsNull", + "value": 0 + } + }, + "properties": [ + { + "id": "custom.hidden", + "value": true + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 16 + }, + "id": 5, + "targets": [ + { + "expr": "combined_metric", + "refId": "A" + }, + { + "expr": "secondary_metric", + "refId": "B" + } + ], + "title": "Combined Mappings and Overrides Example", + "type": "table" + } + ], + "schemaVersion": 42, + "tags": [ + "value-mapping", + "overrides", + "test" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Value Mapping and Overrides Test", + "weekStart": "" + }, + "status": { + "conversion": { + "failed": false, + "storedVersion": "v1beta1" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.value-mapping-and-overrides.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.value-mapping-and-overrides.v2alpha1.json new file mode 100644 index 00000000000..f4a950d53a6 --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.value-mapping-and-overrides.v2alpha1.json @@ -0,0 +1,783 @@ +{ + "kind": "DashboardWithAccessInfo", + "apiVersion": "dashboard.grafana.app/v2alpha1", + "metadata": { + "name": "value-mapping-test", + "namespace": "default", + "uid": "value-mapping-test", + "resourceVersion": "1765384157199094", + "generation": 2, + "creationTimestamp": "2025-11-19T20:09:28Z", + "labels": { + "grafana.app/deprecatedInternalID": "646372978987008" + } + }, + "spec": { + "annotations": [ + { + "kind": "AnnotationQuery", + "spec": { + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "query": { + "kind": "grafana", + "spec": {} + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "builtIn": true, + "legacyOptions": { + "type": "dashboard" + } + } + } + ], + "cursorSync": "Off", + "description": "Test dashboard for all value mapping types and override matcher types", + "editable": true, + "elements": { + "panel-1": { + "kind": "Panel", + "spec": { + "id": 1, + "title": "ValueMap Example", + "description": "Panel with ValueMap mapping type - maps specific text values to colors and display text", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "prometheus", + "spec": { + "expr": "up" + } + }, + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "stat", + "spec": { + "pluginVersion": "", + "options": {}, + "fieldConfig": { + "defaults": { + "mappings": [ + { + "type": "value", + "options": { + "critical": { + "text": "Critical!", + "color": "red", + "index": 0 + }, + "ok": { + "text": "OK", + "color": "green", + "index": 2 + }, + "warning": { + "text": "Warning", + "color": "orange", + "index": 1 + } + } + } + ] + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "status" + }, + "properties": [ + { + "id": "custom.width", + "value": 100 + }, + { + "id": "custom.align", + "value": "center" + } + ] + } + ] + } + } + } + } + }, + "panel-2": { + "kind": "Panel", + "spec": { + "id": 2, + "title": "RangeMap Example", + "description": "Panel with RangeMap mapping type - maps numerical ranges to colors and display text", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "prometheus", + "spec": { + "expr": "cpu_usage_percent" + } + }, + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "gauge", + "spec": { + "pluginVersion": "", + "options": {}, + "fieldConfig": { + "defaults": { + "mappings": [ + { + "type": "range", + "options": { + "from": 0, + "to": 50, + "result": { + "text": "Low", + "color": "green", + "index": 0 + } + } + }, + { + "type": "range", + "options": { + "from": 50, + "to": 80, + "result": { + "text": "Medium", + "color": "orange", + "index": 1 + } + } + }, + { + "type": "range", + "options": { + "from": 80, + "to": 100, + "result": { + "text": "High", + "color": "red", + "index": 2 + } + } + } + ] + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "/^cpu_/" + }, + "properties": [ + { + "id": "unit", + "value": "percent" + }, + { + "id": "decimals", + "value": 2 + } + ] + } + ] + } + } + } + } + }, + "panel-3": { + "kind": "Panel", + "spec": { + "id": 3, + "title": "RegexMap Example", + "description": "Panel with RegexMap mapping type - maps values matching regex patterns to colors", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "prometheus", + "spec": { + "expr": "log_level" + } + }, + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "stat", + "spec": { + "pluginVersion": "", + "options": {}, + "fieldConfig": { + "defaults": { + "mappings": [ + { + "type": "regex", + "options": { + "pattern": "/^error.*/", + "result": { + "text": "Error", + "color": "red", + "index": 0 + } + } + }, + { + "type": "regex", + "options": { + "pattern": "/^warn.*/", + "result": { + "text": "Warning", + "color": "orange", + "index": 1 + } + } + }, + { + "type": "regex", + "options": { + "pattern": "/^info.*/", + "result": { + "text": "Info", + "color": "blue", + "index": 2 + } + } + } + ] + }, + "overrides": [ + { + "matcher": { + "id": "byType", + "options": "string" + }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { + "type": "color-text" + } + } + ] + } + ] + } + } + } + } + }, + "panel-4": { + "kind": "Panel", + "spec": { + "id": 4, + "title": "SpecialValueMap Example", + "description": "Panel with SpecialValueMap mapping type - maps special values like null, NaN, true, false to display text", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "prometheus", + "spec": { + "expr": "some_metric" + } + }, + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "stat", + "spec": { + "pluginVersion": "", + "options": {}, + "fieldConfig": { + "defaults": { + "mappings": [ + { + "type": "special", + "options": { + "match": "null", + "result": { + "text": "No Data", + "color": "gray", + "index": 0 + } + } + }, + { + "type": "special", + "options": { + "match": "nan", + "result": { + "text": "Not a Number", + "color": "gray", + "index": 1 + } + } + }, + { + "type": "special", + "options": { + "match": "null+nan", + "result": { + "text": "N/A", + "color": "gray", + "index": 2 + } + } + }, + { + "type": "special", + "options": { + "match": "true", + "result": { + "text": "Yes", + "color": "green", + "index": 3 + } + } + }, + { + "type": "special", + "options": { + "match": "false", + "result": { + "text": "No", + "color": "red", + "index": 4 + } + } + }, + { + "type": "special", + "options": { + "match": "empty", + "result": { + "text": "Empty", + "color": "gray", + "index": 5 + } + } + } + ] + }, + "overrides": [ + { + "matcher": { + "id": "byFrameRefID", + "options": "A" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "blue", + "mode": "fixed" + } + } + ] + } + ] + } + } + } + } + }, + "panel-5": { + "kind": "Panel", + "spec": { + "id": 5, + "title": "Combined Mappings and Overrides Example", + "description": "Panel with all mapping types combined - demonstrates mixing different mapping types and multiple override matchers", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "prometheus", + "spec": { + "expr": "combined_metric" + } + }, + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, + "refId": "A", + "hidden": false + } + }, + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "prometheus", + "spec": { + "expr": "secondary_metric" + } + }, + "datasource": { + "type": "prometheus", + "uid": "prometheus-uid" + }, + "refId": "B", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "table", + "spec": { + "pluginVersion": "", + "options": {}, + "fieldConfig": { + "defaults": { + "mappings": [ + { + "type": "value", + "options": { + "failure": { + "text": "Failure", + "color": "red", + "index": 1 + }, + "success": { + "text": "Success", + "color": "green", + "index": 0 + } + } + }, + { + "type": "range", + "options": { + "from": 0, + "to": 100, + "result": { + "text": "In Range", + "color": "blue", + "index": 2 + } + } + }, + { + "type": "regex", + "options": { + "pattern": "/^[A-Z]{3}-\\d+$/", + "result": { + "text": "ID Format", + "color": "purple", + "index": 3 + } + } + }, + { + "type": "special", + "options": { + "match": "null", + "result": { + "text": "Missing", + "color": "gray", + "index": 4 + } + } + } + ] + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "status" + }, + "properties": [ + { + "id": "custom.width", + "value": 120 + }, + { + "id": "custom.cellOptions", + "value": { + "type": "color-background" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "/^value_/" + }, + "properties": [ + { + "id": "unit", + "value": "short" + }, + { + "id": "min", + "value": 0 + }, + { + "id": "max", + "value": 100 + } + ] + }, + { + "matcher": { + "id": "byType", + "options": "number" + }, + "properties": [ + { + "id": "decimals", + "value": 2 + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 50 + }, + { + "color": "red", + "value": 80 + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byFrameRefID", + "options": "B" + }, + "properties": [ + { + "id": "displayName", + "value": "Secondary Query" + } + ] + }, + { + "matcher": { + "id": "byValue", + "options": { + "op": "gte", + "reducer": "allIsNull", + "value": 0 + } + }, + "properties": [ + { + "id": "custom.hidden", + "value": true + } + ] + } + ] + } + } + } + } + } + }, + "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" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 0, + "y": 8, + "width": 12, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-3" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 12, + "y": 8, + "width": 12, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-4" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 0, + "y": 16, + "width": 24, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-5" + } + } + } + ] + } + }, + "links": [], + "liveNow": false, + "preload": false, + "tags": [ + "value-mapping", + "overrides", + "test" + ], + "timeSettings": { + "timezone": "browser", + "from": "now-6h", + "to": "now", + "autoRefresh": "", + "autoRefreshIntervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "hideTimepicker": false, + "fiscalYearStartMonth": 0 + }, + "title": "Value Mapping and Overrides Test", + "variables": [] + }, + "status": { + "conversion": { + "failed": false, + "storedVersion": "v1beta1" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.value-mapping-and-overrides.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.value-mapping-and-overrides.v2beta1.json new file mode 100644 index 00000000000..ad492e24a6e --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.value-mapping-and-overrides.v2beta1.json @@ -0,0 +1,795 @@ +{ + "kind": "DashboardWithAccessInfo", + "apiVersion": "dashboard.grafana.app/v2beta1", + "metadata": { + "name": "value-mapping-test", + "namespace": "default", + "uid": "value-mapping-test", + "resourceVersion": "1765384157199094", + "generation": 2, + "creationTimestamp": "2025-11-19T20:09:28Z", + "labels": { + "grafana.app/deprecatedInternalID": "646372978987008" + } + }, + "spec": { + "annotations": [ + { + "kind": "AnnotationQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "grafana", + "version": "v0", + "datasource": { + "name": "-- Grafana --" + }, + "spec": {} + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "builtIn": true, + "legacyOptions": { + "type": "dashboard" + } + } + } + ], + "cursorSync": "Off", + "description": "Test dashboard for all value mapping types and override matcher types", + "editable": true, + "elements": { + "panel-1": { + "kind": "Panel", + "spec": { + "id": 1, + "title": "ValueMap Example", + "description": "Panel with ValueMap mapping type - maps specific text values to colors and display text", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "prometheus", + "version": "v0", + "datasource": { + "name": "prometheus-uid" + }, + "spec": { + "expr": "up" + } + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "stat", + "version": "", + "spec": { + "options": {}, + "fieldConfig": { + "defaults": { + "mappings": [ + { + "type": "value", + "options": { + "critical": { + "text": "Critical!", + "color": "red", + "index": 0 + }, + "ok": { + "text": "OK", + "color": "green", + "index": 2 + }, + "warning": { + "text": "Warning", + "color": "orange", + "index": 1 + } + } + } + ] + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "status" + }, + "properties": [ + { + "id": "custom.width", + "value": 100 + }, + { + "id": "custom.align", + "value": "center" + } + ] + } + ] + } + } + } + } + }, + "panel-2": { + "kind": "Panel", + "spec": { + "id": 2, + "title": "RangeMap Example", + "description": "Panel with RangeMap mapping type - maps numerical ranges to colors and display text", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "prometheus", + "version": "v0", + "datasource": { + "name": "prometheus-uid" + }, + "spec": { + "expr": "cpu_usage_percent" + } + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "gauge", + "version": "", + "spec": { + "options": {}, + "fieldConfig": { + "defaults": { + "mappings": [ + { + "type": "range", + "options": { + "from": 0, + "to": 50, + "result": { + "text": "Low", + "color": "green", + "index": 0 + } + } + }, + { + "type": "range", + "options": { + "from": 50, + "to": 80, + "result": { + "text": "Medium", + "color": "orange", + "index": 1 + } + } + }, + { + "type": "range", + "options": { + "from": 80, + "to": 100, + "result": { + "text": "High", + "color": "red", + "index": 2 + } + } + } + ] + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "/^cpu_/" + }, + "properties": [ + { + "id": "unit", + "value": "percent" + }, + { + "id": "decimals", + "value": 2 + } + ] + } + ] + } + } + } + } + }, + "panel-3": { + "kind": "Panel", + "spec": { + "id": 3, + "title": "RegexMap Example", + "description": "Panel with RegexMap mapping type - maps values matching regex patterns to colors", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "prometheus", + "version": "v0", + "datasource": { + "name": "prometheus-uid" + }, + "spec": { + "expr": "log_level" + } + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "stat", + "version": "", + "spec": { + "options": {}, + "fieldConfig": { + "defaults": { + "mappings": [ + { + "type": "regex", + "options": { + "pattern": "/^error.*/", + "result": { + "text": "Error", + "color": "red", + "index": 0 + } + } + }, + { + "type": "regex", + "options": { + "pattern": "/^warn.*/", + "result": { + "text": "Warning", + "color": "orange", + "index": 1 + } + } + }, + { + "type": "regex", + "options": { + "pattern": "/^info.*/", + "result": { + "text": "Info", + "color": "blue", + "index": 2 + } + } + } + ] + }, + "overrides": [ + { + "matcher": { + "id": "byType", + "options": "string" + }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { + "type": "color-text" + } + } + ] + } + ] + } + } + } + } + }, + "panel-4": { + "kind": "Panel", + "spec": { + "id": 4, + "title": "SpecialValueMap Example", + "description": "Panel with SpecialValueMap mapping type - maps special values like null, NaN, true, false to display text", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "prometheus", + "version": "v0", + "datasource": { + "name": "prometheus-uid" + }, + "spec": { + "expr": "some_metric" + } + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "stat", + "version": "", + "spec": { + "options": {}, + "fieldConfig": { + "defaults": { + "mappings": [ + { + "type": "special", + "options": { + "match": "null", + "result": { + "text": "No Data", + "color": "gray", + "index": 0 + } + } + }, + { + "type": "special", + "options": { + "match": "nan", + "result": { + "text": "Not a Number", + "color": "gray", + "index": 1 + } + } + }, + { + "type": "special", + "options": { + "match": "null+nan", + "result": { + "text": "N/A", + "color": "gray", + "index": 2 + } + } + }, + { + "type": "special", + "options": { + "match": "true", + "result": { + "text": "Yes", + "color": "green", + "index": 3 + } + } + }, + { + "type": "special", + "options": { + "match": "false", + "result": { + "text": "No", + "color": "red", + "index": 4 + } + } + }, + { + "type": "special", + "options": { + "match": "empty", + "result": { + "text": "Empty", + "color": "gray", + "index": 5 + } + } + } + ] + }, + "overrides": [ + { + "matcher": { + "id": "byFrameRefID", + "options": "A" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "blue", + "mode": "fixed" + } + } + ] + } + ] + } + } + } + } + }, + "panel-5": { + "kind": "Panel", + "spec": { + "id": 5, + "title": "Combined Mappings and Overrides Example", + "description": "Panel with all mapping types combined - demonstrates mixing different mapping types and multiple override matchers", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "prometheus", + "version": "v0", + "datasource": { + "name": "prometheus-uid" + }, + "spec": { + "expr": "combined_metric" + } + }, + "refId": "A", + "hidden": false + } + }, + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "prometheus", + "version": "v0", + "datasource": { + "name": "prometheus-uid" + }, + "spec": { + "expr": "secondary_metric" + } + }, + "refId": "B", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "table", + "version": "", + "spec": { + "options": {}, + "fieldConfig": { + "defaults": { + "mappings": [ + { + "type": "value", + "options": { + "failure": { + "text": "Failure", + "color": "red", + "index": 1 + }, + "success": { + "text": "Success", + "color": "green", + "index": 0 + } + } + }, + { + "type": "range", + "options": { + "from": 0, + "to": 100, + "result": { + "text": "In Range", + "color": "blue", + "index": 2 + } + } + }, + { + "type": "regex", + "options": { + "pattern": "/^[A-Z]{3}-\\d+$/", + "result": { + "text": "ID Format", + "color": "purple", + "index": 3 + } + } + }, + { + "type": "special", + "options": { + "match": "null", + "result": { + "text": "Missing", + "color": "gray", + "index": 4 + } + } + } + ] + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "status" + }, + "properties": [ + { + "id": "custom.width", + "value": 120 + }, + { + "id": "custom.cellOptions", + "value": { + "type": "color-background" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "/^value_/" + }, + "properties": [ + { + "id": "unit", + "value": "short" + }, + { + "id": "min", + "value": 0 + }, + { + "id": "max", + "value": 100 + } + ] + }, + { + "matcher": { + "id": "byType", + "options": "number" + }, + "properties": [ + { + "id": "decimals", + "value": 2 + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 50 + }, + { + "color": "red", + "value": 80 + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byFrameRefID", + "options": "B" + }, + "properties": [ + { + "id": "displayName", + "value": "Secondary Query" + } + ] + }, + { + "matcher": { + "id": "byValue", + "options": { + "op": "gte", + "reducer": "allIsNull", + "value": 0 + } + }, + "properties": [ + { + "id": "custom.hidden", + "value": true + } + ] + } + ] + } + } + } + } + } + }, + "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" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 0, + "y": 8, + "width": 12, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-3" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 12, + "y": 8, + "width": 12, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-4" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 0, + "y": 16, + "width": 24, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-5" + } + } + } + ] + } + }, + "links": [], + "liveNow": false, + "preload": false, + "tags": [ + "value-mapping", + "overrides", + "test" + ], + "timeSettings": { + "timezone": "browser", + "from": "now-6h", + "to": "now", + "autoRefresh": "", + "autoRefreshIntervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "hideTimepicker": false, + "fiscalYearStartMonth": 0 + }, + "title": "Value Mapping and Overrides Test", + "variables": [] + }, + "status": { + "conversion": { + "failed": false, + "storedVersion": "v1beta1" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go index 4d6fd791fa9..f3f2f08ddb8 100644 --- a/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go +++ b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go @@ -2022,6 +2022,9 @@ func transformPanelQueries(ctx context.Context, panelMap map[string]interface{}, func transformSingleQuery(ctx context.Context, targetMap map[string]interface{}, panelDatasource *dashv2alpha1.DashboardDataSourceRef, dsIndexProvider schemaversion.DataSourceIndexProvider) dashv2alpha1.DashboardPanelQueryKind { refId := schemaversion.GetStringValue(targetMap, "refId", "A") + if refId == "" { + refId = "A" + } hidden := getBoolField(targetMap, "hide", false) // Extract datasource from query or use panel datasource @@ -2518,22 +2521,15 @@ func buildRegexMap(mappingMap map[string]interface{}) *dashv2alpha1.DashboardReg regexMap := &dashv2alpha1.DashboardRegexMap{} regexMap.Type = dashv2alpha1.DashboardMappingTypeRegex - opts, ok := mappingMap["options"].([]interface{}) - if !ok || len(opts) == 0 { - return nil - } - - optMap, ok := opts[0].(map[string]interface{}) + optMap, ok := mappingMap["options"].(map[string]interface{}) if !ok { return nil } r := dashv2alpha1.DashboardV2alpha1RegexMapOptions{} - if pattern, ok := optMap["regex"].(string); ok { + if pattern, ok := optMap["pattern"].(string); ok { r.Pattern = pattern } - - // Result is a DashboardValueMappingResult if resMap, ok := optMap["result"].(map[string]interface{}); ok { r.Result = buildValueMappingResult(resMap) } diff --git a/go.work.sum b/go.work.sum index 3462a071ffd..2e51fd8a06e 100644 --- a/go.work.sum +++ b/go.work.sum @@ -526,6 +526,8 @@ github.com/campoy/embedmd v1.0.0 h1:V4kI2qTJJLf4J29RzI/MAt2c3Bl4dQSYPuflzwFH2hY= github.com/campoy/embedmd v1.0.0/go.mod h1:oxyr9RCiSXg0M3VJ3ks0UGfp98BpSSGr0kpiX3MzVl8= github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/census-instrumentation/opencensus-proto v0.4.1 h1:iKLQ0xPNFxR/2hzXZMrBo8f1j86j5WHzznCCQxV/b8g= +github.com/centrifugal/centrifuge v0.37.2/go.mod h1:aj4iRJGhzi3SlL8iUtVezxway1Xf8g+hmNQkLLO7sS8= +github.com/centrifugal/protocol v0.16.2/go.mod h1:Q7OpS/8HMXDnL7f9DpNx24IhG96MP88WPpVTTCdrokI= github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0= github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA= github.com/chenzhuoyu/iasm v0.9.0 h1:9fhXjVzq5hUy2gkhhgHl95zG2cEAhw9OSGs8toWWAwo= @@ -1369,6 +1371,7 @@ github.com/rabbitmq/amqp091-go v1.9.0 h1:qrQtyzB4H8BQgEuJwhmVQqVHB9O4+MNDJCCAcpc github.com/rabbitmq/amqp091-go v1.9.0/go.mod h1:+jPrT9iY2eLjRaMSRHUhc3z14E/l85kv/f+6luSD3pc= github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5XpJzTSTfLsJV/mx9Q9g7kxmchpfZyxgzM= github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/redis/rueidis v1.0.64/go.mod h1:Lkhr2QTgcoYBhxARU7kJRO8SyVlgUuEkcJO1Y8MCluA= github.com/relvacode/iso8601 v1.6.0 h1:eFXUhMJN3Gz8Rcq82f9DTMW0svjtAVuIEULglM7QHTU= github.com/relvacode/iso8601 v1.6.0/go.mod h1:FlNp+jz+TXpyRqgmM7tnzHHzBnz776kmAH2h3sZCn0I= github.com/richardartoul/molecule v1.0.0 h1:+LFA9cT7fn8KF39zy4dhOnwcOwRoqKiBkPqKqya+8+U= From 00a6e1781fdd6dae2cce76f5101c10ba55db077c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Mon, 15 Dec 2025 14:08:04 +0100 Subject: [PATCH 2/7] Scopes: move scope dashboard list toggle to canvas/page (#115131) * Scopes: move scope dashboard list toggle to canvas/page * Updates * Updates * Fix test * Update --- .../scene/DashboardControls.tsx | 8 ++++ .../ContextualNavigationPaneToggle.tsx | 46 +++++++++++++++++++ .../scopes/dashboards/ScopesDashboards.tsx | 31 +++++++------ .../dashboards/ScopesDashboardsTreeSearch.tsx | 5 ++ .../scopes/selector/ScopesSelector.tsx | 22 ++------- .../scopes/tests/dashboardsList.test.ts | 3 -- 6 files changed, 78 insertions(+), 37 deletions(-) create mode 100644 public/app/features/scopes/dashboards/ContextualNavigationPaneToggle.tsx diff --git a/public/app/features/dashboard-scene/scene/DashboardControls.tsx b/public/app/features/dashboard-scene/scene/DashboardControls.tsx index 6981995bd3a..d2b80340dfc 100644 --- a/public/app/features/dashboard-scene/scene/DashboardControls.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardControls.tsx @@ -19,6 +19,7 @@ import { } from '@grafana/scenes'; import { Box, Button, useStyles2 } from '@grafana/ui'; import { playlistSrv } from 'app/features/playlist/PlaylistSrv'; +import { ContextualNavigationPaneToggle } from 'app/features/scopes/dashboards/ContextualNavigationPaneToggle'; import { PanelEditControls } from '../panel-edit/PanelEditControls'; import { getDashboardSceneFor } from '../utils/utils'; @@ -172,6 +173,9 @@ function DashboardControlsRenderer({ model }: SceneComponentProps )} + {config.featureToggles.scopeFilters && !editPanel && ( + + )} {!hideVariableControls && ( <> @@ -287,5 +291,9 @@ function getStyles(theme: GrafanaTheme2) { flexWrap: 'wrap', marginLeft: 'auto', }), + contextualNavToggle: css({ + display: 'inline-flex', + margin: theme.spacing(0, 1, 1, 0), + }), }; } diff --git a/public/app/features/scopes/dashboards/ContextualNavigationPaneToggle.tsx b/public/app/features/scopes/dashboards/ContextualNavigationPaneToggle.tsx new file mode 100644 index 00000000000..a87b8cc9df5 --- /dev/null +++ b/public/app/features/scopes/dashboards/ContextualNavigationPaneToggle.tsx @@ -0,0 +1,46 @@ +import { t } from '@grafana/i18n'; +import { useScopes } from '@grafana/runtime'; +import { ToolbarButton } from '@grafana/ui'; + +import { useScopesServices } from '../ScopesContextProvider'; + +interface Props { + className?: string; + hideWhenOpen?: boolean; +} + +export function ContextualNavigationPaneToggle({ className, hideWhenOpen }: Props) { + const scopes = useScopes(); + const services = useScopesServices(); + + if (!scopes || !services) { + return; + } + + const { scopesDashboardsService } = services; + const { readOnly, drawerOpened } = scopes.state; + + if (hideWhenOpen && drawerOpened) { + return null; + } + + const dashboardsIconLabel = readOnly + ? t('scopes.dashboards.toggle.disabled', 'Suggested dashboards list is disabled due to read only mode') + : drawerOpened + ? t('scopes.dashboards.toggle.collapse', 'Collapse suggested dashboards list') + : t('scopes.dashboards.toggle.expand', 'Expand suggested dashboards list'); + + return ( +
+ +
+ ); +} diff --git a/public/app/features/scopes/dashboards/ScopesDashboards.tsx b/public/app/features/scopes/dashboards/ScopesDashboards.tsx index 7a2fc7832f3..640eb6290b1 100644 --- a/public/app/features/scopes/dashboards/ScopesDashboards.tsx +++ b/public/app/features/scopes/dashboards/ScopesDashboards.tsx @@ -1,4 +1,4 @@ -import { css, cx } from '@emotion/css'; +import { css } from '@emotion/css'; import { useObservable } from 'react-use'; import { Observable } from 'rxjs'; @@ -34,22 +34,22 @@ export function ScopesDashboards() { if (!loading) { if (forScopeNames.length === 0) { return ( -
- No scopes selected +
+ + +
+ No scopes selected +
); } else if (dashboards.length === 0 && scopeNavigations.length === 0) { return ( -
- - No dashboards or links found for the selected scopes - +
+
+ + No dashboards or links found for the selected scopes + +
); } @@ -94,13 +94,14 @@ export function ScopesDashboards() { const getStyles = (theme: GrafanaTheme2) => { return { container: css({ - backgroundColor: theme.colors.background.primary, + backgroundColor: theme.colors.background.canvas, borderRight: `1px solid ${theme.colors.border.weak}`, display: 'flex', flexDirection: 'column', height: '100%', gap: theme.spacing(1), - padding: theme.spacing(2), + padding: theme.spacing(0, 2), + margin: theme.spacing(2, 0), width: theme.spacing(37.5), }), noResultsContainer: css({ diff --git a/public/app/features/scopes/dashboards/ScopesDashboardsTreeSearch.tsx b/public/app/features/scopes/dashboards/ScopesDashboardsTreeSearch.tsx index 3afb7a1368e..9235ff5eb61 100644 --- a/public/app/features/scopes/dashboards/ScopesDashboardsTreeSearch.tsx +++ b/public/app/features/scopes/dashboards/ScopesDashboardsTreeSearch.tsx @@ -6,6 +6,8 @@ import { GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; import { FilterInput, useStyles2 } from '@grafana/ui'; +import { ContextualNavigationPaneToggle } from './ContextualNavigationPaneToggle'; + export interface ScopesDashboardsTreeSearchProps { disabled: boolean; query: string; @@ -42,6 +44,7 @@ export function ScopesDashboardsTreeSearch({ disabled, query, onChange }: Scopes data-testid="scopes-dashboards-search" onChange={(value) => setInputState({ value, dirty: true })} /> +
); } @@ -49,6 +52,8 @@ export function ScopesDashboardsTreeSearch({ disabled, query, onChange }: Scopes const getStyles = (theme: GrafanaTheme2) => { return { container: css({ + display: 'flex', + gap: theme.spacing(1), flex: '0 1 auto', }), }; diff --git a/public/app/features/scopes/selector/ScopesSelector.tsx b/public/app/features/scopes/selector/ScopesSelector.tsx index bc89c748814..8c67fa202de 100644 --- a/public/app/features/scopes/selector/ScopesSelector.tsx +++ b/public/app/features/scopes/selector/ScopesSelector.tsx @@ -6,7 +6,7 @@ import { Observable } from 'rxjs'; import { GrafanaTheme2 } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; import { useScopes } from '@grafana/runtime'; -import { Button, Drawer, ErrorBoundary, ErrorWithStack, IconButton, Spinner, Text, useStyles2 } from '@grafana/ui'; +import { Button, Drawer, ErrorBoundary, ErrorWithStack, Spinner, Text, useStyles2 } from '@grafana/ui'; import { getModKey } from 'app/core/utils/browser'; import { useScopesServices } from '../ScopesContextProvider'; @@ -54,8 +54,8 @@ export const ScopesSelector = () => { tree, scopes: scopesMap, } = selectorServiceState; - const { scopesService, scopesSelectorService, scopesDashboardsService } = services; - const { readOnly, drawerOpened, loading } = scopes.state; + const { scopesService, scopesSelectorService } = services; + const { readOnly, loading } = scopes.state; const { open, removeAllScopes, @@ -70,24 +70,8 @@ export const ScopesSelector = () => { const recentScopes = getRecentScopes(); - const dashboardsIconLabel = readOnly - ? t('scopes.dashboards.toggle.disabled', 'Suggested dashboards list is disabled due to read only mode') - : drawerOpened - ? t('scopes.dashboards.toggle.collapse', 'Collapse suggested dashboards list') - : t('scopes.dashboards.toggle.expand', 'Expand suggested dashboards list'); - return ( <> - - { it('Shows a proper message when no scopes are selected', async () => { await toggleDashboards(); expectNoDashboardsNoScopes(); - expectNoDashboardsSearch(); }); it('Does not show the input when there are no dashboards found for scope', async () => { await updateScopes(scopesService, ['cloud']); await toggleDashboards(); expectNoDashboardsForScope(); - expectNoDashboardsSearch(); }); it('Shows the input and a message when there are no dashboards found for filter', async () => { From 75caaccad4f25c0b6ab645350e25ce19d5b715a0 Mon Sep 17 00:00:00 2001 From: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com> Date: Mon, 15 Dec 2025 14:14:44 +0100 Subject: [PATCH 3/7] Scopes: Add frontend support for `disableSubScopeSelection` (#115323) * Add devenv scopes and don't display the switch button * Add tests * Remove redundant test --- devenv/scopes/scopes-config.yaml | 1 + devenv/scopes/scopes.go | 38 +++-- .../ScopesDashboardsService.test.ts | 157 ++++++++++++++++++ .../dashboards/ScopesDashboardsService.ts | 6 + .../ScopesDashboardsTreeFolderItem.test.tsx | 59 +++++++ .../ScopesDashboardsTreeFolderItem.tsx | 2 +- .../app/features/scopes/dashboards/types.ts | 4 + 7 files changed, 249 insertions(+), 18 deletions(-) diff --git a/devenv/scopes/scopes-config.yaml b/devenv/scopes/scopes-config.yaml index 36fd6645fa9..d18679f6dcd 100644 --- a/devenv/scopes/scopes-config.yaml +++ b/devenv/scopes/scopes-config.yaml @@ -210,6 +210,7 @@ navigationTree: url: /d/UTv--wqMk scope: shoe-org subScope: apparel + disableSubScopeSelection: true children: - name: apparel-product-overview title: Product Overview diff --git a/devenv/scopes/scopes.go b/devenv/scopes/scopes.go index 335540cbef5..b252072c398 100644 --- a/devenv/scopes/scopes.go +++ b/devenv/scopes/scopes.go @@ -77,22 +77,24 @@ type TreeNode struct { } type NavigationConfig struct { - URL string `yaml:"url"` // URL path (e.g., /d/abc123 or /explore) - Scope string `yaml:"scope"` // Required scope - SubScope string `yaml:"subScope"` // Optional subScope for hierarchical navigation - Title string `yaml:"title"` // Display title - Groups []string `yaml:"groups"` // Optional groups for categorization + URL string `yaml:"url"` // URL path (e.g., /d/abc123 or /explore) + Scope string `yaml:"scope"` // Required scope + SubScope string `yaml:"subScope"` // Optional subScope for hierarchical navigation + Title string `yaml:"title"` // Display title + Groups []string `yaml:"groups"` // Optional groups for categorization + DisableSubScopeSelection bool `yaml:"disableSubScopeSelection"` // Makes the subscope not selectable } // NavigationTreeNode represents a node in the navigation tree structure type NavigationTreeNode struct { - Name string `yaml:"name"` - Title string `yaml:"title"` - URL string `yaml:"url"` - Scope string `yaml:"scope"` - SubScope string `yaml:"subScope,omitempty"` - Groups []string `yaml:"groups,omitempty"` - Children []NavigationTreeNode `yaml:"children,omitempty"` + Name string `yaml:"name"` + Title string `yaml:"title"` + URL string `yaml:"url"` + Scope string `yaml:"scope"` + SubScope string `yaml:"subScope,omitempty"` + Groups []string `yaml:"groups,omitempty"` + DisableSubScopeSelection bool `yaml:"disableSubScopeSelection,omitempty"` + Children []NavigationTreeNode `yaml:"children,omitempty"` } // Helper function to convert ScopeFilterConfig to v0alpha1.ScopeFilter @@ -313,8 +315,9 @@ func (c *Client) createScopeNavigation(name string, nav NavigationConfig) error prefixedScope := prefix + "-" + nav.Scope spec := v0alpha1.ScopeNavigationSpec{ - URL: nav.URL, - Scope: prefixedScope, + URL: nav.URL, + Scope: prefixedScope, + DisableSubScopeSelection: nav.DisableSubScopeSelection, } if nav.SubScope != "" { @@ -404,9 +407,10 @@ func treeToNavigations(node NavigationTreeNode, parentPath []string, dashboardCo // Create navigation for this node nav := NavigationConfig{ - URL: url, - Scope: node.Scope, - Title: node.Title, + URL: url, + Scope: node.Scope, + Title: node.Title, + DisableSubScopeSelection: node.DisableSubScopeSelection, } if node.SubScope != "" { nav.SubScope = node.SubScope diff --git a/public/app/features/scopes/dashboards/ScopesDashboardsService.test.ts b/public/app/features/scopes/dashboards/ScopesDashboardsService.test.ts index ca621106f81..082c8c790cc 100644 --- a/public/app/features/scopes/dashboards/ScopesDashboardsService.test.ts +++ b/public/app/features/scopes/dashboards/ScopesDashboardsService.test.ts @@ -887,4 +887,161 @@ describe('ScopesDashboardsService', () => { expect(service.state.navScopePath).toEqual(['mimir']); }); }); + + describe('disableSubScopeSelection', () => { + it('should set disableSubScopeSelection on folder when navigation has it set to true', async () => { + const mockNavigations: ScopeNavigation[] = [ + { + spec: { + url: '/d/dashboard1', + scope: 'scope1', + subScope: 'subScope1', + disableSubScopeSelection: true, + }, + status: { + title: 'Test Navigation', + }, + metadata: { + name: 'nav1', + }, + }, + ]; + + mockApiClient.fetchScopeNavigations.mockResolvedValue(mockNavigations); + await service.fetchDashboards(['scope1']); + + // Find the folder created for this subScope + const folderKey = Object.keys(service.state.folders[''].folders).find((key) => key.includes('subScope1')); + expect(folderKey).toBeDefined(); + + if (folderKey) { + const folder = service.state.folders[''].folders[folderKey]; + expect(folder.disableSubScopeSelection).toBe(true); + expect(folder.subScopeName).toBe('subScope1'); + } + }); + + it('should set disableSubScopeSelection to false when navigation has it set to false', async () => { + const mockNavigations: ScopeNavigation[] = [ + { + spec: { + url: '/d/dashboard1', + scope: 'scope1', + subScope: 'subScope1', + disableSubScopeSelection: false, + }, + status: { + title: 'Test Navigation', + }, + metadata: { + name: 'nav1', + }, + }, + ]; + + mockApiClient.fetchScopeNavigations.mockResolvedValue(mockNavigations); + await service.fetchDashboards(['scope1']); + + const folderKey = Object.keys(service.state.folders[''].folders).find((key) => key.includes('subScope1')); + expect(folderKey).toBeDefined(); + + if (folderKey) { + const folder = service.state.folders[''].folders[folderKey]; + expect(folder.disableSubScopeSelection).toBe(false); + expect(folder.subScopeName).toBe('subScope1'); + } + }); + + it('should set disableSubScopeSelection to undefined when navigation does not have it', async () => { + const mockNavigations: ScopeNavigation[] = [ + { + spec: { + url: '/d/dashboard1', + scope: 'scope1', + subScope: 'subScope1', + }, + status: { + title: 'Test Navigation', + }, + metadata: { + name: 'nav1', + }, + }, + ]; + + mockApiClient.fetchScopeNavigations.mockResolvedValue(mockNavigations); + await service.fetchDashboards(['scope1']); + + const folderKey = Object.keys(service.state.folders[''].folders).find((key) => key.includes('subScope1')); + expect(folderKey).toBeDefined(); + + if (folderKey) { + const folder = service.state.folders[''].folders[folderKey]; + expect(folder.disableSubScopeSelection).toBeUndefined(); + expect(folder.subScopeName).toBe('subScope1'); + } + }); + + it('should handle multiple navigations with different disableSubScopeSelection values', async () => { + const mockNavigations: ScopeNavigation[] = [ + { + spec: { + url: '/d/dashboard1', + scope: 'scope1', + subScope: 'subScope1', + disableSubScopeSelection: true, + }, + status: { + title: 'Disabled Navigation', + }, + metadata: { + name: 'nav1', + }, + }, + { + spec: { + url: '/d/dashboard2', + scope: 'scope1', + subScope: 'subScope2', + disableSubScopeSelection: false, + }, + status: { + title: 'Enabled Navigation', + }, + metadata: { + name: 'nav2', + }, + }, + { + spec: { + url: '/d/dashboard3', + scope: 'scope1', + subScope: 'subScope3', + }, + status: { + title: 'Default Navigation', + }, + metadata: { + name: 'nav3', + }, + }, + ]; + + mockApiClient.fetchScopeNavigations.mockResolvedValue(mockNavigations); + await service.fetchDashboards(['scope1']); + + const folders = service.state.folders[''].folders; + const folder1Key = Object.keys(folders).find((key) => key.includes('subScope1')); + const folder2Key = Object.keys(folders).find((key) => key.includes('subScope2')); + const folder3Key = Object.keys(folders).find((key) => key.includes('subScope3')); + + expect(folder1Key).toBeDefined(); + expect(folder2Key).toBeDefined(); + expect(folder3Key).toBeDefined(); + + expect(folders[folder1Key!].disableSubScopeSelection).toBe(true); + expect(folders[folder2Key!].disableSubScopeSelection).toBe(false); + expect(folders[folder3Key!].disableSubScopeSelection).toBeUndefined(); + }); + }); }); diff --git a/public/app/features/scopes/dashboards/ScopesDashboardsService.ts b/public/app/features/scopes/dashboards/ScopesDashboardsService.ts index 96518cba5f7..4d6c686280d 100644 --- a/public/app/features/scopes/dashboards/ScopesDashboardsService.ts +++ b/public/app/features/scopes/dashboards/ScopesDashboardsService.ts @@ -10,6 +10,7 @@ import { ScopesServiceBase } from '../ScopesServiceBase'; import { buildSubScopePath, isCurrentPath } from './scopeNavgiationUtils'; import { ScopeNavigation, + ScopeNavigationSpec, SuggestedNavigationsFolder, SuggestedNavigationsFoldersMap, SuggestedNavigationsMap, @@ -386,12 +387,17 @@ export class ScopesDashboardsService extends ScopesServiceBase { // The component checks for scopesSelectorService existence before calling setNavigationScope expect(mockScopesDashboardsService.setNavigationScope).not.toHaveBeenCalled(); }); + + describe('disableSubScopeSelection', () => { + it('does not show exchange icon when disableSubScopeSelection is true', () => { + const folder = createMockFolder({ + subScopeName: 'subScope1', + disableSubScopeSelection: true, + }); + + render( + + ); + + const exchangeButtons = screen.queryAllByRole('button', { name: /change root scope/i }); + expect(exchangeButtons).toHaveLength(0); + }); + + it('shows exchange icon when disableSubScopeSelection is false', () => { + const folder = createMockFolder({ + subScopeName: 'subScope1', + disableSubScopeSelection: false, + }); + + render( + + ); + + const exchangeButton = screen.getByRole('button', { name: /change root scope/i }); + expect(exchangeButton).toBeInTheDocument(); + }); + + it('shows exchange icon when disableSubScopeSelection is undefined', () => { + const folder = createMockFolder({ + subScopeName: 'subScope1', + disableSubScopeSelection: undefined, + }); + + render( + + ); + + const exchangeButton = screen.getByRole('button', { name: /change root scope/i }); + expect(exchangeButton).toBeInTheDocument(); + }); + }); }); diff --git a/public/app/features/scopes/dashboards/ScopesDashboardsTreeFolderItem.tsx b/public/app/features/scopes/dashboards/ScopesDashboardsTreeFolderItem.tsx index d6846f432ac..e4029d82b5b 100644 --- a/public/app/features/scopes/dashboards/ScopesDashboardsTreeFolderItem.tsx +++ b/public/app/features/scopes/dashboards/ScopesDashboardsTreeFolderItem.tsx @@ -48,7 +48,7 @@ export function ScopesDashboardsTreeFolderItem({ {folder.loading && } - {folder.subScopeName && ( + {folder.subScopeName && !folder.disableSubScopeSelection && ( ; From 49c5c0ce41855458e90037a409477df03ac0e362 Mon Sep 17 00:00:00 2001 From: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> Date: Mon, 15 Dec 2025 08:23:32 -0500 Subject: [PATCH 4/7] Docs: Clarify section title for repeating rows and tabs (#115170) Co-authored-by: grafakus --- .../build-dashboards/create-dynamic-dashboard/index.md | 3 ++- .../dashboard-scene/scene/layout-tabs/TabItemEditor.tsx | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/sources/visualizations/dashboards/build-dashboards/create-dynamic-dashboard/index.md b/docs/sources/visualizations/dashboards/build-dashboards/create-dynamic-dashboard/index.md index a77e4ca988f..0167ff147e5 100644 --- a/docs/sources/visualizations/dashboards/build-dashboards/create-dynamic-dashboard/index.md +++ b/docs/sources/visualizations/dashboards/build-dashboards/create-dynamic-dashboard/index.md @@ -245,11 +245,12 @@ To configure repeats, follow these steps: 1. Click **Save**. 1. Toggle off the edit mode switch. -### Repeating rows and the Dashboard special data source +### Repeating rows and tabs and the Dashboard special data source If a row includes panels using the special [Dashboard data source](ref:built-in-special-data-sources)—the data source that uses a result set from another panel in the same dashboard—then corresponding panels in repeated rows will reference the panel in the original row, not the ones in the repeated rows. +The same behavior applies to tabs. For example, in a dashboard: diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx index 745f612a8cc..ceb04d2fbe4 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx @@ -135,7 +135,7 @@ function TabRepeatSelect({ tab, id }: { tab: TabItem; id?: string }) { Learn more From b805d5cae0a896949817eea8d51a60212c02732d Mon Sep 17 00:00:00 2001 From: Kevin Minehart <5140827+kminehart@users.noreply.github.com> Date: Mon, 15 Dec 2025 14:30:38 +0100 Subject: [PATCH 5/7] Update PR Patch check to work on forks (#115308) * Update PR Patch check to work on forks --- .github/workflows/pr-patch-check-event.yml | 1 + .github/workflows/pr-patch-check.yml | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/.github/workflows/pr-patch-check-event.yml b/.github/workflows/pr-patch-check-event.yml index 2b30e0fa375..86a4ad64917 100644 --- a/.github/workflows/pr-patch-check-event.yml +++ b/.github/workflows/pr-patch-check-event.yml @@ -12,6 +12,7 @@ on: permissions: id-token: write contents: read + statuses: write # Since this is run on a pull request, we want to apply the patches intended for the # target branch onto the source branch, to verify compatibility before merging. diff --git a/.github/workflows/pr-patch-check.yml b/.github/workflows/pr-patch-check.yml index 52f75a05ff2..8a1f70174d7 100644 --- a/.github/workflows/pr-patch-check.yml +++ b/.github/workflows/pr-patch-check.yml @@ -29,6 +29,10 @@ permissions: # target branch onto the source branch, to verify compatibility before merging. jobs: dispatch-job: + # If the source is not from a fork then dispatch the job to the workflow. + # This will fail on forks when trying to broker a token, so instead, forks will create the required status and mark + # it as a success + if: ${{ ! github.event.pull_request.head.repo.fork }} env: HEAD_REF: ${{ inputs.head_ref }} BASE_REF: ${{ github.base_ref }} @@ -76,3 +80,20 @@ jobs: triggering_github_handle: SENDER } }) + dispatch-job-fork: + # If the source is from a fork then use the built-in workflow token to create the same status and unconditionally + # mark it as a success. + if: ${{ github.event.pull_request.head.repo.fork }} + permissions: + statuses: write + runs-on: ubuntu-latest + steps: + - name: Create status + uses: myrotvorets/set-commit-status-action@6d6905c99cd24a4a2cbccc720b62dc6ca5587141 + with: + token: ${{ github.token }} + sha: ${{ inputs.pr_commit_sha }} + repo: ${{ inputs.repo }} + status: success + context: "Test Patches (event)" + description: "Test Patches (event) on a fork" From 7c6475262d10e8a5f2da35e5a1f76578a221cca5 Mon Sep 17 00:00:00 2001 From: Levente Balogh Date: Mon, 15 Dec 2025 14:41:58 +0100 Subject: [PATCH 6/7] Docs: Update docs for annotation controls placement (#115207) * docs: update docs for annotation controls placement * chore: prettier fix * chore: revert changes to annotations-schema.md * fix: review note --- .../build-dashboards/annotate-visualizations/index.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/sources/visualizations/dashboards/build-dashboards/annotate-visualizations/index.md b/docs/sources/visualizations/dashboards/build-dashboards/annotate-visualizations/index.md index fcccfa6bd1b..f356d51c4a5 100644 --- a/docs/sources/visualizations/dashboards/build-dashboards/annotate-visualizations/index.md +++ b/docs/sources/visualizations/dashboards/build-dashboards/annotate-visualizations/index.md @@ -149,7 +149,10 @@ To add a new annotation query to a dashboard, follow these steps: You can also click **Open advanced data source picker** to see more options, including adding a data source (Admins only). 1. If you don't want to use the annotation query right away, clear the **Enabled** checkbox. -1. If you don't want the annotation query toggle to be displayed in the dashboard, select the **Hidden** checkbox. +1. Select one of the following options in the **Show annotation controls in** drop-down list to control where annotations are displayed: + - **Above dashboard** - The annotation toggle is displayed above the dashboard. This is the default. + - **Controls menu** - The annotation toggle is displayed in the dashboard controls menu instead of above the dashboard. The dashboard controls menu appears as a button in the dashboard toolbar. + - **Hidden** - The annotation toggle is not displayed on the dashboard. 1. Select a color for the event markers. 1. In the **Show in** drop-down, choose one of the following options: - **All panels** - The annotations are displayed on all panels that support annotations. From 12dd3dffe079bac4e0dae1b84adbc186f486a48e Mon Sep 17 00:00:00 2001 From: Will Assis <35489495+gassiss@users.noreply.github.com> Date: Mon, 15 Dec 2025 08:56:15 -0500 Subject: [PATCH 7/7] unified-storage: sqlkv skeleton (#115176) * implement sqlkv skeleton and include sqlkv in badgerkv tests --- pkg/setting/setting.go | 1 + pkg/setting/setting_unified_storage.go | 3 + .../unified/resource/datastore_test.go | 184 +++++++++++++----- .../unified/resource/eventstore_test.go | 112 ++++++++--- pkg/storage/unified/resource/notifier_test.go | 86 ++++++-- pkg/storage/unified/resource/sqlkv.go | 70 +++++++ .../unified/resource/storage_backend.go | 21 +- pkg/storage/unified/sql/server.go | 47 +++-- pkg/storage/unified/testing/kv.go | 8 +- pkg/storage/unified/testing/kv_test.go | 34 ++++ .../unified/testing/storage_backend_test.go | 53 ++++- 11 files changed, 511 insertions(+), 108 deletions(-) create mode 100644 pkg/storage/unified/resource/sqlkv.go diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index a01549d9da5..8e309433032 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -618,6 +618,7 @@ type Cfg struct { EnableSearch bool OverridesFilePath string OverridesReloadInterval time.Duration + EnableSQLKVBackend bool // Secrets Management SecretsManagement SecretsManagerSettings diff --git a/pkg/setting/setting_unified_storage.go b/pkg/setting/setting_unified_storage.go index 4f69daa64fd..72e01ce6ce9 100644 --- a/pkg/setting/setting_unified_storage.go +++ b/pkg/setting/setting_unified_storage.go @@ -100,6 +100,9 @@ func (cfg *Cfg) setUnifiedStorageConfig() { cfg.OverridesFilePath = section.Key("overrides_path").String() cfg.OverridesReloadInterval = section.Key("overrides_reload_period").MustDuration(30 * time.Second) + // use sqlkv (resource/sqlkv) instead of the sql backend (sql/backend) as the StorageServer + cfg.EnableSQLKVBackend = section.Key("enable_sqlkv_backend").MustBool(false) + cfg.MaxFileIndexAge = section.Key("max_file_index_age").MustDuration(0) cfg.MinFileIndexBuildVersion = section.Key("min_file_index_build_version").MustString("") } diff --git a/pkg/storage/unified/resource/datastore_test.go b/pkg/storage/unified/resource/datastore_test.go index 8f167c2e16e..02c318fe6d9 100644 --- a/pkg/storage/unified/resource/datastore_test.go +++ b/pkg/storage/unified/resource/datastore_test.go @@ -9,6 +9,9 @@ import ( "testing" "github.com/bwmarrin/snowflake" + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl" "github.com/stretchr/testify/require" ) @@ -24,6 +27,16 @@ func TestNewDataStore(t *testing.T) { require.NotNil(t, ds) } +// nolint:unused +func setupTestDataStoreSqlKv(t *testing.T) *dataStore { + dbstore := db.InitTestDB(t) + eDB, err := dbimpl.ProvideResourceDB(dbstore, setting.NewCfg(), nil) + require.NoError(t, err) + kv, err := NewSQLKV(eDB) + require.NoError(t, err) + return newDataStore(kv) +} + func TestDataKey_String(t *testing.T) { rv := int64(1934555792099250176) tests := []struct { @@ -679,10 +692,21 @@ func TestParseKey(t *testing.T) { } } -func TestDataStore_Save_And_Get(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() +func runDataStoreTestWith(t *testing.T, storeName string, newStoreFn func(*testing.T) *dataStore, testFn func(*testing.T, context.Context, *dataStore)) { + t.Run(storeName, func(t *testing.T) { + ctx := context.Background() + store := newStoreFn(t) + testFn(t, ctx, store) + }) +} +func TestDataStore_Save_And_Get(t *testing.T) { + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreSaveAndGet) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreSaveAndGet) +} + +func testDataStoreSaveAndGet(t *testing.T, ctx context.Context, ds *dataStore) { rv := node.Generate() testKey := DataKey{ @@ -744,9 +768,12 @@ func TestDataStore_Save_And_Get(t *testing.T) { } func TestDataStore_Delete(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreDelete) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreDelete) +} +func testDataStoreDelete(t *testing.T, ctx context.Context, ds *dataStore) { rv := node.Generate() testKey := DataKey{ @@ -795,9 +822,12 @@ func TestDataStore_Delete(t *testing.T) { } func TestDataStore_List(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreList) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreList) +} +func testDataStoreList(t *testing.T, ctx context.Context, ds *dataStore) { resourceKey := ListRequestKey{ Namespace: "test-namespace", Group: "test-group", @@ -919,9 +949,12 @@ func TestDataStore_List(t *testing.T) { } func TestDataStore_Integration(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreIntegration) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreIntegration) +} +func testDataStoreIntegration(t *testing.T, ctx context.Context, ds *dataStore) { t.Run("full lifecycle test", func(t *testing.T) { resourceKey := ListRequestKey{ Namespace: "integration-ns", @@ -1007,9 +1040,12 @@ func TestDataStore_Integration(t *testing.T) { } func TestDataStore_Keys(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreKeys) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreKeys) +} +func testDataStoreKeys(t *testing.T, ctx context.Context, ds *dataStore) { resourceKey := ListRequestKey{ Namespace: "test-namespace", Group: "test-group", @@ -1154,9 +1190,12 @@ func TestDataStore_Keys(t *testing.T) { } func TestDataStore_ValidationEnforced(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreValidationEnforced) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreValidationEnforced) +} +func testDataStoreValidationEnforced(t *testing.T, ctx context.Context, ds *dataStore) { // Create an invalid key invalidKey := DataKey{ Namespace: "Invalid-Namespace-$$$", @@ -1483,9 +1522,12 @@ func TestListRequestKey_Prefix(t *testing.T) { } func TestDataStore_LastResourceVersion(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreLastResourceVersion) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreLastResourceVersion) +} +func testDataStoreLastResourceVersion(t *testing.T, ctx context.Context, ds *dataStore) { t.Run("returns last resource version for existing data", func(t *testing.T) { resourceKey := ListRequestKey{ Namespace: "test-namespace", @@ -1585,9 +1627,12 @@ func TestDataStore_LastResourceVersion(t *testing.T) { } func TestDataStore_GetLatestResourceKey(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreGetLatestResourceKey) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreGetLatestResourceKey) +} +func testDataStoreGetLatestResourceKey(t *testing.T, ctx context.Context, ds *dataStore) { key := GetRequestKey{ Group: "apps", Resource: "resources", @@ -1648,9 +1693,12 @@ func TestDataStore_GetLatestResourceKey(t *testing.T) { } func TestDataStore_GetLatestResourceKey_Deleted(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreGetLatestResourceKeyDeleted) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreGetLatestResourceKeyDeleted) +} +func testDataStoreGetLatestResourceKeyDeleted(t *testing.T, ctx context.Context, ds *dataStore) { key := GetRequestKey{ Group: "apps", Resource: "resources", @@ -1676,9 +1724,12 @@ func TestDataStore_GetLatestResourceKey_Deleted(t *testing.T) { } func TestDataStore_GetLatestResourceKey_NotFound(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreGetLatestResourceKeyNotFound) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreGetLatestResourceKeyNotFound) +} +func testDataStoreGetLatestResourceKeyNotFound(t *testing.T, ctx context.Context, ds *dataStore) { key := GetRequestKey{ Group: "apps", Resource: "resources", @@ -1691,9 +1742,12 @@ func TestDataStore_GetLatestResourceKey_NotFound(t *testing.T) { } func TestDataStore_GetResourceKeyAtRevision(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreGetResourceKeyAtRevision) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreGetResourceKeyAtRevision) +} +func testDataStoreGetResourceKeyAtRevision(t *testing.T, ctx context.Context, ds *dataStore) { key := GetRequestKey{ Group: "apps", Resource: "resources", @@ -1766,9 +1820,12 @@ func TestDataStore_GetResourceKeyAtRevision(t *testing.T) { } func TestDataStore_ListLatestResourceKeys(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreListLatestResourceKeys) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreListLatestResourceKeys) +} +func testDataStoreListLatestResourceKeys(t *testing.T, ctx context.Context, ds *dataStore) { listKey := ListRequestKey{ Group: "apps", Resource: "resources", @@ -1819,9 +1876,12 @@ func TestDataStore_ListLatestResourceKeys(t *testing.T) { } func TestDataStore_ListLatestResourceKeys_Deleted(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreListLatestResourceKeysDeleted) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreListLatestResourceKeysDeleted) +} +func testDataStoreListLatestResourceKeysDeleted(t *testing.T, ctx context.Context, ds *dataStore) { listKey := ListRequestKey{ Group: "apps", Resource: "resources", @@ -1869,9 +1929,12 @@ func TestDataStore_ListLatestResourceKeys_Deleted(t *testing.T) { } func TestDataStore_ListLatestResourceKeys_Multiple(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreListLatestResourceKeysMultiple) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreListLatestResourceKeysMultiple) +} +func testDataStoreListLatestResourceKeysMultiple(t *testing.T, ctx context.Context, ds *dataStore) { listKey := ListRequestKey{ Group: "apps", Resource: "resources", @@ -1940,9 +2003,12 @@ func TestDataStore_ListLatestResourceKeys_Multiple(t *testing.T) { } func TestDataStore_ListResourceKeysAtRevision(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreListResourceKeysAtRevision) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreListResourceKeysAtRevision) +} +func testDataStoreListResourceKeysAtRevision(t *testing.T, ctx context.Context, ds *dataStore) { // Create multiple resources with different versions rv1 := node.Generate().Int64() rv2 := node.Generate().Int64() @@ -2152,9 +2218,12 @@ func TestDataStore_ListResourceKeysAtRevision(t *testing.T) { } func TestDataStore_ListResourceKeysAtRevision_ValidationErrors(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreListResourceKeysAtRevisionValidationErrors) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreListResourceKeysAtRevisionValidationErrors) +} +func testDataStoreListResourceKeysAtRevisionValidationErrors(t *testing.T, ctx context.Context, ds *dataStore) { tests := []struct { name string key ListRequestKey @@ -2194,9 +2263,12 @@ func TestDataStore_ListResourceKeysAtRevision_ValidationErrors(t *testing.T) { } func TestDataStore_ListResourceKeysAtRevision_EmptyResults(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreListResourceKeysAtRevisionEmptyResults) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreListResourceKeysAtRevisionEmptyResults) +} +func testDataStoreListResourceKeysAtRevisionEmptyResults(t *testing.T, ctx context.Context, ds *dataStore) { listKey := ListRequestKey{ Group: "apps", Resource: "resources", @@ -2213,9 +2285,12 @@ func TestDataStore_ListResourceKeysAtRevision_EmptyResults(t *testing.T) { } func TestDataStore_ListResourceKeysAtRevision_ResourcesNewerThanRevision(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreListResourceKeysAtRevisionResourcesNewerThanRevision) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreListResourceKeysAtRevisionResourcesNewerThanRevision) +} +func testDataStoreListResourceKeysAtRevisionResourcesNewerThanRevision(t *testing.T, ctx context.Context, ds *dataStore) { // Create a resource with a high resource version rv := node.Generate().Int64() key := DataKey{ @@ -2681,9 +2756,12 @@ func TestGetRequestKey_Prefix(t *testing.T) { } func TestDataStore_GetResourceStats_Comprehensive(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreGetResourceStatsComprehensive) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreGetResourceStatsComprehensive) +} +func testDataStoreGetResourceStatsComprehensive(t *testing.T, ctx context.Context, ds *dataStore) { // Test setup: 3 namespaces × 3 groups × 3 resources × 3 names × 3 versions = 243 total entries // But each name will have only 1 latest version that counts, so 3 × 3 × 3 × 3 = 81 non-deleted resources namespaces := []string{"ns1", "ns2", "ns3"} @@ -2888,9 +2966,12 @@ func TestDataStore_GetResourceStats_Comprehensive(t *testing.T) { } func TestDataStore_getGroupResources(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreGetGroupResources) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreGetGroupResources) +} +func testDataStoreGetGroupResources(t *testing.T, ctx context.Context, ds *dataStore) { // Create test data with multiple group/resource combinations testData := []struct { group string @@ -2951,9 +3032,12 @@ func TestDataStore_getGroupResources(t *testing.T) { } func TestDataStore_BatchDelete(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreBatchDelete) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreBatchDelete) +} +func testDataStoreBatchDelete(t *testing.T, ctx context.Context, ds *dataStore) { keys := make([]DataKey, 95) for i := 0; i < 95; i++ { rv := node.Generate().Int64() @@ -2987,9 +3071,12 @@ func TestDataStore_BatchDelete(t *testing.T) { } func TestDataStore_BatchGet(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreBatchGet) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreBatchGet) +} +func testDataStoreBatchGet(t *testing.T, ctx context.Context, ds *dataStore) { t.Run("batch get multiple existing keys", func(t *testing.T) { // Create test data keys := make([]DataKey, 5) @@ -3132,9 +3219,12 @@ func TestDataStore_BatchGet(t *testing.T) { } func TestDataStore_GetLatestAndPredecessor(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreGetLatestAndPredecessor) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreGetLatestAndPredecessor) +} +func testDataStoreGetLatestAndPredecessor(t *testing.T, ctx context.Context, ds *dataStore) { resourceKey := ListRequestKey{ Namespace: "test-namespace", Group: "test-group", diff --git a/pkg/storage/unified/resource/eventstore_test.go b/pkg/storage/unified/resource/eventstore_test.go index a9d2ee93eb4..270db1ddd3f 100644 --- a/pkg/storage/unified/resource/eventstore_test.go +++ b/pkg/storage/unified/resource/eventstore_test.go @@ -7,6 +7,10 @@ import ( "time" "github.com/bwmarrin/snowflake" + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -21,6 +25,20 @@ func setupTestEventStore(t *testing.T) *eventStore { return newEventStore(kv) } +func TestMain(m *testing.M) { + testsuite.Run(m) +} + +// nolint:unused +func setupTestEventStoreSqlKv(t *testing.T) *eventStore { + dbstore := db.InitTestDB(t) + eDB, err := dbimpl.ProvideResourceDB(dbstore, setting.NewCfg(), nil) + require.NoError(t, err) + kv, err := NewSQLKV(eDB) + require.NoError(t, err) + return newEventStore(kv) +} + func TestNewEventStore(t *testing.T) { store := setupTestEventStore(t) assert.NotNil(t, store.kv) @@ -180,10 +198,21 @@ func TestEventStore_ParseEventKey(t *testing.T) { assert.Equal(t, originalKey, parsedKey) } -func TestEventStore_Save_Get(t *testing.T) { - ctx := context.Background() - store := setupTestEventStore(t) +func runEventStoreTestWith(t *testing.T, storeName string, newStoreFn func(*testing.T) *eventStore, testFn func(*testing.T, context.Context, *eventStore)) { + t.Run(storeName, func(t *testing.T) { + ctx := context.Background() + store := newStoreFn(t) + testFn(t, ctx, store) + }) +} +func TestEventStore_Save_Get(t *testing.T) { + runEventStoreTestWith(t, "badger", setupTestEventStore, testEventStoreSaveGet) + // enable this when sqlkv is ready + // runEventStoreTestWith(t, "sqlkv", setupTestEventStoreSqlKv, testEventStoreSaveGet) +} + +func testEventStoreSaveGet(t *testing.T, ctx context.Context, store *eventStore) { event := Event{ Namespace: "default", Group: "apps", @@ -216,9 +245,12 @@ func TestEventStore_Save_Get(t *testing.T) { } func TestEventStore_Get_NotFound(t *testing.T) { - ctx := context.Background() - store := setupTestEventStore(t) + runEventStoreTestWith(t, "badger", setupTestEventStore, testEventStoreGetNotFound) + // enable this when sqlkv is ready + // runEventStoreTestWith(t, "sqlkv", setupTestEventStoreSqlKv, testEventStoreGetNotFound) +} +func testEventStoreGetNotFound(t *testing.T, ctx context.Context, store *eventStore) { nonExistentKey := EventKey{ Namespace: "default", Group: "apps", @@ -233,9 +265,12 @@ func TestEventStore_Get_NotFound(t *testing.T) { } func TestEventStore_LastEventKey(t *testing.T) { - ctx := context.Background() - store := setupTestEventStore(t) + runEventStoreTestWith(t, "badger", setupTestEventStore, testEventStoreLastEventKey) + // enable this when sqlkv is ready + // runEventStoreTestWith(t, "sqlkv", setupTestEventStoreSqlKv, testEventStoreLastEventKey) +} +func testEventStoreLastEventKey(t *testing.T, ctx context.Context, store *eventStore) { // Test when no events exist _, err := store.LastEventKey(ctx) assert.Error(t, err) @@ -292,9 +327,12 @@ func TestEventStore_LastEventKey(t *testing.T) { } func TestEventStore_ListKeysSince(t *testing.T) { - ctx := context.Background() - store := setupTestEventStore(t) + runEventStoreTestWith(t, "badger", setupTestEventStore, testEventStoreListKeysSince) + // enable this when sqlkv is ready + // runEventStoreTestWith(t, "sqlkv", setupTestEventStoreSqlKv, testEventStoreListKeysSince) +} +func testEventStoreListKeysSince(t *testing.T, ctx context.Context, store *eventStore) { // Add events with different resource versions events := []Event{ { @@ -349,9 +387,12 @@ func TestEventStore_ListKeysSince(t *testing.T) { } func TestEventStore_ListSince(t *testing.T) { - ctx := context.Background() - store := setupTestEventStore(t) + runEventStoreTestWith(t, "badger", setupTestEventStore, testEventStoreListSince) + // enable this when sqlkv is ready + // runEventStoreTestWith(t, "sqlkv", setupTestEventStoreSqlKv, testEventStoreListSince) +} +func testEventStoreListSince(t *testing.T, ctx context.Context, store *eventStore) { // Add events with different resource versions events := []Event{ { @@ -404,9 +445,12 @@ func TestEventStore_ListSince(t *testing.T) { } func TestEventStore_ListSince_Empty(t *testing.T) { - ctx := context.Background() - store := setupTestEventStore(t) + runEventStoreTestWith(t, "badger", setupTestEventStore, testEventStoreListSinceEmpty) + // enable this when sqlkv is ready + // runEventStoreTestWith(t, "sqlkv", setupTestEventStoreSqlKv, testEventStoreListSinceEmpty) +} +func testEventStoreListSinceEmpty(t *testing.T, ctx context.Context, store *eventStore) { // List events when store is empty retrievedEvents := make([]Event, 0) for event, err := range store.ListSince(ctx, 0) { @@ -459,9 +503,12 @@ func TestEventKey_Struct(t *testing.T) { } func TestEventStore_Save_InvalidJSON(t *testing.T) { - ctx := context.Background() - store := setupTestEventStore(t) + runEventStoreTestWith(t, "badger", setupTestEventStore, testEventStoreSaveInvalidJSON) + // enable this when sqlkv is ready + // runEventStoreTestWith(t, "sqlkv", setupTestEventStoreSqlKv, testEventStoreSaveInvalidJSON) +} +func testEventStoreSaveInvalidJSON(t *testing.T, ctx context.Context, store *eventStore) { // This should work fine as the Event struct should be serializable event := Event{ Namespace: "default", @@ -477,9 +524,12 @@ func TestEventStore_Save_InvalidJSON(t *testing.T) { } func TestEventStore_CleanupOldEvents(t *testing.T) { - ctx := context.Background() - store := setupTestEventStore(t) + runEventStoreTestWith(t, "badger", setupTestEventStore, testEventStoreCleanupOldEvents) + // enable this when sqlkv is ready + // runEventStoreTestWith(t, "sqlkv", setupTestEventStoreSqlKv, testEventStoreCleanupOldEvents) +} +func testEventStoreCleanupOldEvents(t *testing.T, ctx context.Context, store *eventStore) { now := time.Now() oldRV := snowflakeFromTime(now.Add(-48 * time.Hour)) // 48 hours ago recentRV := snowflakeFromTime(now.Add(-1 * time.Hour)) // 1 hour ago @@ -565,9 +615,12 @@ func TestEventStore_CleanupOldEvents(t *testing.T) { } func TestEventStore_CleanupOldEvents_NoOldEvents(t *testing.T) { - ctx := context.Background() - store := setupTestEventStore(t) + runEventStoreTestWith(t, "badger", setupTestEventStore, testEventStoreCleanupOldEventsNoOldEvents) + // enable this when sqlkv is ready + // runEventStoreTestWith(t, "sqlkv", setupTestEventStoreSqlKv, testEventStoreCleanupOldEventsNoOldEvents) +} +func testEventStoreCleanupOldEventsNoOldEvents(t *testing.T, ctx context.Context, store *eventStore) { // Create an event 1 hour old rv := snowflakeFromTime(time.Now().Add(-1 * time.Hour)) event := Event{ @@ -603,9 +656,12 @@ func TestEventStore_CleanupOldEvents_NoOldEvents(t *testing.T) { } func TestEventStore_CleanupOldEvents_EmptyStore(t *testing.T) { - ctx := context.Background() - store := setupTestEventStore(t) + runEventStoreTestWith(t, "badger", setupTestEventStore, testEventStoreCleanupOldEventsEmptyStore) + // enable this when sqlkv is ready + // runEventStoreTestWith(t, "sqlkv", setupTestEventStoreSqlKv, testEventStoreCleanupOldEventsEmptyStore) +} +func testEventStoreCleanupOldEventsEmptyStore(t *testing.T, ctx context.Context, store *eventStore) { // Clean up events from empty store deletedCount, err := store.CleanupOldEvents(ctx, time.Now().Add(-24*time.Hour)) require.NoError(t, err) @@ -613,9 +669,12 @@ func TestEventStore_CleanupOldEvents_EmptyStore(t *testing.T) { } func TestEventStore_BatchDelete(t *testing.T) { - ctx := context.Background() - store := setupTestEventStore(t) + runEventStoreTestWith(t, "badger", setupTestEventStore, testEventStoreBatchDelete) + // enable this when sqlkv is ready + // runEventStoreTestWith(t, "sqlkv", setupTestEventStoreSqlKv, testEventStoreBatchDelete) +} +func testEventStoreBatchDelete(t *testing.T, ctx context.Context, store *eventStore) { // Create multiple events (more than batch size to test batching) eventKeys := make([]string, 75) for i := 0; i < 75; i++ { @@ -722,9 +781,12 @@ func TestSnowflakeFromTime(t *testing.T) { } func TestListKeysSince_WithSnowflakeTime(t *testing.T) { - ctx := context.Background() - store := setupTestEventStore(t) + runEventStoreTestWith(t, "badger", setupTestEventStore, testListKeysSinceWithSnowflakeTime) + // enable this when sqlkv is ready + // runEventStoreTestWith(t, "sqlkv", setupTestEventStoreSqlKv, testListKeysSinceWithSnowflakeTime) +} +func testListKeysSinceWithSnowflakeTime(t *testing.T, ctx context.Context, store *eventStore) { // Create events with snowflake-based resource versions at different times now := time.Now() events := []Event{ diff --git a/pkg/storage/unified/resource/notifier_test.go b/pkg/storage/unified/resource/notifier_test.go index 7b201f47420..060f8eecfbe 100644 --- a/pkg/storage/unified/resource/notifier_test.go +++ b/pkg/storage/unified/resource/notifier_test.go @@ -6,6 +6,9 @@ import ( "time" "github.com/grafana/grafana-app-sdk/logging" + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -22,6 +25,18 @@ func setupTestNotifier(t *testing.T) (*notifier, *eventStore) { return notifier, eventStore } +// nolint:unused +func setupTestNotifierSqlKv(t *testing.T) (*notifier, *eventStore) { + dbstore := db.InitTestDB(t) + eDB, err := dbimpl.ProvideResourceDB(dbstore, setting.NewCfg(), nil) + require.NoError(t, err) + kv, err := NewSQLKV(eDB) + require.NoError(t, err) + eventStore := newEventStore(kv) + notifier := newNotifier(eventStore, notifierOptions{log: &logging.NoOpLogger{}}) + return notifier, eventStore +} + func TestNewNotifier(t *testing.T) { notifier, _ := setupTestNotifier(t) @@ -35,10 +50,21 @@ func TestDefaultWatchOptions(t *testing.T) { assert.Equal(t, defaultBufferSize, opts.BufferSize) } -func TestNotifier_lastEventResourceVersion(t *testing.T) { - ctx := context.Background() - notifier, eventStore := setupTestNotifier(t) +func runNotifierTestWith(t *testing.T, storeName string, newStoreFn func(*testing.T) (*notifier, *eventStore), testFn func(*testing.T, context.Context, *notifier, *eventStore)) { + t.Run(storeName, func(t *testing.T) { + ctx := context.Background() + notifier, eventStore := newStoreFn(t) + testFn(t, ctx, notifier, eventStore) + }) +} +func TestNotifier_lastEventResourceVersion(t *testing.T) { + runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierLastEventResourceVersion) + // enable this when sqlkv is ready + // runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierLastEventResourceVersion) +} + +func testNotifierLastEventResourceVersion(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { // Test with no events rv, err := notifier.lastEventResourceVersion(ctx) assert.Error(t, err) @@ -85,8 +111,12 @@ func TestNotifier_lastEventResourceVersion(t *testing.T) { } func TestNotifier_cachekey(t *testing.T) { - notifier, _ := setupTestNotifier(t) + runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierCachekey) + // enable this when sqlkv is ready + // runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierCachekey) +} +func testNotifierCachekey(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { tests := []struct { name string event Event @@ -136,10 +166,14 @@ func TestNotifier_cachekey(t *testing.T) { } func TestNotifier_Watch_NoEvents(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) - defer cancel() + runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchNoEvents) + // enable this when sqlkv is ready + // runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchNoEvents) +} - notifier, eventStore := setupTestNotifier(t) +func testNotifierWatchNoEvents(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { + ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond) + defer cancel() // Add at least one event so that lastEventResourceVersion doesn't return ErrNotFound initialEvent := Event{ @@ -174,10 +208,14 @@ func TestNotifier_Watch_NoEvents(t *testing.T) { } func TestNotifier_Watch_WithExistingEvents(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() + runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchWithExistingEvents) + // enable this when sqlkv is ready + // runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchWithExistingEvents) +} - notifier, eventStore := setupTestNotifier(t) +func testNotifierWatchWithExistingEvents(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { + ctx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() // Save some initial events initialEvents := []Event{ @@ -245,10 +283,14 @@ func TestNotifier_Watch_WithExistingEvents(t *testing.T) { } func TestNotifier_Watch_EventDeduplication(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() + runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchEventDeduplication) + // enable this when sqlkv is ready + // runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchEventDeduplication) +} - notifier, eventStore := setupTestNotifier(t) +func testNotifierWatchEventDeduplication(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { + ctx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() // Add an initial event so that lastEventResourceVersion doesn't return ErrNotFound initialEvent := Event{ @@ -308,9 +350,13 @@ func TestNotifier_Watch_EventDeduplication(t *testing.T) { } func TestNotifier_Watch_ContextCancellation(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) + runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchContextCancellation) + // enable this when sqlkv is ready + // runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchContextCancellation) +} - notifier, eventStore := setupTestNotifier(t) +func testNotifierWatchContextCancellation(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { + ctx, cancel := context.WithCancel(ctx) // Add an initial event so that lastEventResourceVersion doesn't return ErrNotFound initialEvent := Event{ @@ -351,10 +397,14 @@ func TestNotifier_Watch_ContextCancellation(t *testing.T) { } func TestNotifier_Watch_MultipleEvents(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() + runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchMultipleEvents) + // enable this when sqlkv is ready + // runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchMultipleEvents) +} - notifier, eventStore := setupTestNotifier(t) +func testNotifierWatchMultipleEvents(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { + ctx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() rv := time.Now().UnixNano() // Add an initial event so that lastEventResourceVersion doesn't return ErrNotFound initialEvent := Event{ diff --git a/pkg/storage/unified/resource/sqlkv.go b/pkg/storage/unified/resource/sqlkv.go new file mode 100644 index 00000000000..f6e8d0698f3 --- /dev/null +++ b/pkg/storage/unified/resource/sqlkv.go @@ -0,0 +1,70 @@ +package resource + +import ( + "context" + "fmt" + "io" + "iter" + + "github.com/grafana/grafana/pkg/storage/unified/sql/db" +) + +var _ KV = &sqlKV{} + +type sqlKV struct { + dbProvider db.DBProvider + db db.DB +} + +func NewSQLKV(dbProvider db.DBProvider) (KV, error) { + if dbProvider == nil { + return nil, fmt.Errorf("dbProvider is required") + } + + ctx := context.Background() + dbConn, err := dbProvider.Init(ctx) + if err != nil { + return nil, fmt.Errorf("error initializing DB: %w", err) + } + + return &sqlKV{ + dbProvider: dbProvider, + db: dbConn, + }, nil +} + +func (k *sqlKV) Ping(ctx context.Context) error { + return k.db.PingContext(ctx) +} + +func (k *sqlKV) Keys(ctx context.Context, section string, opt ListOptions) iter.Seq2[string, error] { + return func(yield func(string, error) bool) { + panic("not implemented!") + } +} + +func (k *sqlKV) Get(ctx context.Context, section string, key string) (io.ReadCloser, error) { + panic("not implemented!") +} + +func (k *sqlKV) BatchGet(ctx context.Context, section string, keys []string) iter.Seq2[KeyValue, error] { + return func(yield func(KeyValue, error) bool) { + panic("not implemented!") + } +} + +func (k *sqlKV) Save(ctx context.Context, section string, key string) (io.WriteCloser, error) { + panic("not implemented!") +} + +func (k *sqlKV) Delete(ctx context.Context, section string, key string) error { + panic("not implemented!") +} + +func (k *sqlKV) BatchDelete(ctx context.Context, section string, keys []string) error { + panic("not implemented!") +} + +func (k *sqlKV) UnixTimestamp(ctx context.Context) (int64, error) { + panic("not implemented!") +} diff --git a/pkg/storage/unified/resource/storage_backend.go b/pkg/storage/unified/resource/storage_backend.go index 0de97b0355e..b0f51702775 100644 --- a/pkg/storage/unified/resource/storage_backend.go +++ b/pkg/storage/unified/resource/storage_backend.go @@ -70,7 +70,12 @@ type kvStorageBackend struct { //reg prometheus.Registerer } -var _ StorageBackend = &kvStorageBackend{} +var _ KVBackend = &kvStorageBackend{} + +type KVBackend interface { + StorageBackend + resourcepb.DiagnosticsServer +} type KVBackendOptions struct { KvStore KV @@ -82,7 +87,7 @@ type KVBackendOptions struct { Reg prometheus.Registerer // TODO add metrics } -func NewKVStorageBackend(opts KVBackendOptions) (StorageBackend, error) { +func NewKVStorageBackend(opts KVBackendOptions) (KVBackend, error) { ctx := context.Background() kv := opts.KvStore @@ -126,6 +131,18 @@ func NewKVStorageBackend(opts KVBackendOptions) (StorageBackend, error) { return backend, nil } +func (k *kvStorageBackend) IsHealthy(ctx context.Context, _ *resourcepb.HealthCheckRequest) (*resourcepb.HealthCheckResponse, error) { + type pinger interface { + Ping(context.Context) error + } + if p, ok := k.kv.(pinger); ok { + if err := p.Ping(ctx); err != nil { + return &resourcepb.HealthCheckResponse{Status: resourcepb.HealthCheckResponse_NOT_SERVING}, fmt.Errorf("KV store health check failed: %w", err) + } + } + return &resourcepb.HealthCheckResponse{Status: resourcepb.HealthCheckResponse_SERVING}, nil +} + // runCleanupOldEvents starts a background goroutine that periodically cleans up old events func (k *kvStorageBackend) runCleanupOldEvents(ctx context.Context) { // Run cleanup every hour diff --git a/pkg/storage/unified/sql/server.go b/pkg/storage/unified/sql/server.go index 6723a58dd29..84eda71ca20 100644 --- a/pkg/storage/unified/sql/server.go +++ b/pkg/storage/unified/sql/server.go @@ -97,22 +97,41 @@ func NewResourceServer(opts ServerOptions) (resource.ResourceServer, error) { return nil, err } - isHA := isHighAvailabilityEnabled(opts.Cfg.SectionWithEnvOverrides("database"), - opts.Cfg.SectionWithEnvOverrides("resource_api")) + if opts.Cfg.EnableSQLKVBackend { + sqlkv, err := resource.NewSQLKV(eDB) + if err != nil { + return nil, fmt.Errorf("error creating sqlkv: %s", err) + } - backend, err := NewBackend(BackendOptions{ - DBProvider: eDB, - Reg: opts.Reg, - IsHA: isHA, - storageMetrics: opts.StorageMetrics, - LastImportTimeMaxAge: opts.SearchOptions.MaxIndexAge, // No need to keep last_import_times older than max index age. - }) - if err != nil { - return nil, err + kvBackend, err := resource.NewKVStorageBackend(resource.KVBackendOptions{ + KvStore: sqlkv, + Tracer: opts.Tracer, + Reg: opts.Reg, + }) + if err != nil { + return nil, fmt.Errorf("error creating kv backend: %s", err) + } + + serverOptions.Backend = kvBackend + serverOptions.Diagnostics = kvBackend + } else { + isHA := isHighAvailabilityEnabled(opts.Cfg.SectionWithEnvOverrides("database"), + opts.Cfg.SectionWithEnvOverrides("resource_api")) + + backend, err := NewBackend(BackendOptions{ + DBProvider: eDB, + Reg: opts.Reg, + IsHA: isHA, + storageMetrics: opts.StorageMetrics, + LastImportTimeMaxAge: opts.SearchOptions.MaxIndexAge, // No need to keep last_import_times older than max index age. + }) + if err != nil { + return nil, err + } + serverOptions.Backend = backend + serverOptions.Diagnostics = backend + serverOptions.Lifecycle = backend } - serverOptions.Backend = backend - serverOptions.Diagnostics = backend - serverOptions.Lifecycle = backend } serverOptions.Search = opts.SearchOptions diff --git a/pkg/storage/unified/testing/kv.go b/pkg/storage/unified/testing/kv.go index eab9aa9c845..f30f1d761d7 100644 --- a/pkg/storage/unified/testing/kv.go +++ b/pkg/storage/unified/testing/kv.go @@ -35,7 +35,8 @@ type NewKVFunc func(ctx context.Context) resource.KV // KVTestOptions configures which tests to run type KVTestOptions struct { - NSPrefix string // namespace prefix for isolation + SkipTests map[string]bool + NSPrefix string // namespace prefix for isolation } // GenerateRandomKVPrefix creates a random namespace prefix for test isolation @@ -72,6 +73,11 @@ func RunKVTest(t *testing.T, newKV NewKVFunc, opts *KVTestOptions) { } for _, tc := range cases { + if shouldSkip := opts.SkipTests[tc.name]; shouldSkip { + t.Logf("Skipping test: %s", tc.name) + continue + } + t.Run(tc.name, func(t *testing.T) { tc.fn(t, newKV(context.Background()), opts.NSPrefix) }) diff --git a/pkg/storage/unified/testing/kv_test.go b/pkg/storage/unified/testing/kv_test.go index 1e9b1a16c45..4dbd27d5ec9 100644 --- a/pkg/storage/unified/testing/kv_test.go +++ b/pkg/storage/unified/testing/kv_test.go @@ -7,7 +7,11 @@ import ( badger "github.com/dgraph-io/badger/v4" "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/unified/resource" + "github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl" + "github.com/grafana/grafana/pkg/tests/testsuite" ) func TestBadgerKV(t *testing.T) { @@ -26,3 +30,33 @@ func TestBadgerKV(t *testing.T) { NSPrefix: "badger-kv-test", }) } + +func TestMain(m *testing.M) { + testsuite.Run(m) +} + +func TestSQLKV(t *testing.T) { + RunKVTest(t, func(ctx context.Context) resource.KV { + dbstore := db.InitTestDB(t) + eDB, err := dbimpl.ProvideResourceDB(dbstore, setting.NewCfg(), nil) + require.NoError(t, err) + + kv, err := resource.NewSQLKV(eDB) + require.NoError(t, err) + return kv + }, &KVTestOptions{ + NSPrefix: "sql-kv-test", + SkipTests: map[string]bool{ + TestKVGet: true, + TestKVSave: true, + TestKVDelete: true, + TestKVKeys: true, + TestKVKeysWithLimits: true, + TestKVKeysWithSort: true, + TestKVConcurrent: true, + TestKVUnixTimestamp: true, + TestKVBatchGet: true, + TestKVBatchDelete: true, + }, + }) +} diff --git a/pkg/storage/unified/testing/storage_backend_test.go b/pkg/storage/unified/testing/storage_backend_test.go index 04f34e9102f..70e3b15aa7b 100644 --- a/pkg/storage/unified/testing/storage_backend_test.go +++ b/pkg/storage/unified/testing/storage_backend_test.go @@ -7,7 +7,11 @@ import ( badger "github.com/dgraph-io/badger/v4" "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/unified/resource" + sqldb "github.com/grafana/grafana/pkg/storage/unified/sql/db" + "github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl" ) func TestBadgerKVStorageBackend(t *testing.T) { @@ -25,7 +29,7 @@ func TestBadgerKVStorageBackend(t *testing.T) { require.NoError(t, err) return backend }, &TestOptions{ - NSPrefix: "kvstorage-test", + NSPrefix: "badgerkvstorage-test", SkipTests: map[string]bool{ // TODO: fix these tests and remove this skip TestBlobSupport: true, @@ -35,3 +39,50 @@ func TestBadgerKVStorageBackend(t *testing.T) { }, }) } + +func TestSQLKVStorageBackend(t *testing.T) { + newBackendFunc := func(ctx context.Context) (resource.StorageBackend, sqldb.DB) { + dbstore := db.InitTestDB(t) + eDB, err := dbimpl.ProvideResourceDB(dbstore, setting.NewCfg(), nil) + require.NoError(t, err) + kv, err := resource.NewSQLKV(eDB) + require.NoError(t, err) + kvOpts := resource.KVBackendOptions{ + KvStore: kv, + } + backend, err := resource.NewKVStorageBackend(kvOpts) + require.NoError(t, err) + db, err := eDB.Init(ctx) + require.NoError(t, err) + return backend, db + } + + RunStorageBackendTest(t, func(ctx context.Context) resource.StorageBackend { + backend, _ := newBackendFunc(ctx) + return backend + }, &TestOptions{ + NSPrefix: "sqlkvstorage-test", + SkipTests: map[string]bool{ + TestHappyPath: true, + TestWatchWriteEvents: true, + TestList: true, + TestBlobSupport: true, + TestGetResourceStats: true, + TestListHistory: true, + TestListHistoryErrorReporting: true, + TestListModifiedSince: true, + TestListTrash: true, + TestCreateNewResource: true, + TestGetResourceLastImportTime: true, + TestOptimisticLocking: true, + TestKeyPathGeneration: true, + }, + }) + + RunSQLStorageBackendCompatibilityTest(t, newBackendFunc, &TestOptions{ + NSPrefix: "sqlkvstorage-compatibility-test", + SkipTests: map[string]bool{ + TestKeyPathGeneration: true, + }, + }) +}