Merge branch 'main' of https://github.com/grafana/grafana into kristina/rtk-corr
This commit is contained in:
@@ -11,11 +11,13 @@ import (
|
||||
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
|
||||
)
|
||||
|
||||
func RegisterConversions(s *runtime.Scheme, dsIndexProvider schemaversion.DataSourceIndexProvider, _ schemaversion.LibraryElementIndexProvider) error {
|
||||
func RegisterConversions(s *runtime.Scheme, dsIndexProvider schemaversion.DataSourceIndexProvider, leIndexProvider schemaversion.LibraryElementIndexProvider) error {
|
||||
// Wrap the provider once with 10s caching for all conversions.
|
||||
// This prevents repeated DB queries across multiple conversion calls while allowing
|
||||
// the cache to refresh periodically, making it suitable for long-lived singleton usage.
|
||||
dsIndexProvider = schemaversion.WrapIndexProviderWithCache(dsIndexProvider)
|
||||
// Wrap library element provider with caching as well
|
||||
leIndexProvider = schemaversion.WrapLibraryElementProviderWithCache(leIndexProvider)
|
||||
|
||||
// v0 conversions
|
||||
if err := s.AddConversionFunc((*dashv0.Dashboard)(nil), (*dashv1.Dashboard)(nil),
|
||||
@@ -26,13 +28,13 @@ func RegisterConversions(s *runtime.Scheme, dsIndexProvider schemaversion.DataSo
|
||||
}
|
||||
if err := s.AddConversionFunc((*dashv0.Dashboard)(nil), (*dashv2alpha1.Dashboard)(nil),
|
||||
withConversionMetrics(dashv0.APIVERSION, dashv2alpha1.APIVERSION, func(a, b interface{}, scope conversion.Scope) error {
|
||||
return Convert_V0_to_V2alpha1(a.(*dashv0.Dashboard), b.(*dashv2alpha1.Dashboard), scope, dsIndexProvider)
|
||||
return Convert_V0_to_V2alpha1(a.(*dashv0.Dashboard), b.(*dashv2alpha1.Dashboard), scope, dsIndexProvider, leIndexProvider)
|
||||
})); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.AddConversionFunc((*dashv0.Dashboard)(nil), (*dashv2beta1.Dashboard)(nil),
|
||||
withConversionMetrics(dashv0.APIVERSION, dashv2beta1.APIVERSION, func(a, b interface{}, scope conversion.Scope) error {
|
||||
return Convert_V0_to_V2beta1(a.(*dashv0.Dashboard), b.(*dashv2beta1.Dashboard), scope, dsIndexProvider)
|
||||
return Convert_V0_to_V2beta1(a.(*dashv0.Dashboard), b.(*dashv2beta1.Dashboard), scope, dsIndexProvider, leIndexProvider)
|
||||
})); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -46,13 +48,13 @@ func RegisterConversions(s *runtime.Scheme, dsIndexProvider schemaversion.DataSo
|
||||
}
|
||||
if err := s.AddConversionFunc((*dashv1.Dashboard)(nil), (*dashv2alpha1.Dashboard)(nil),
|
||||
withConversionMetrics(dashv1.APIVERSION, dashv2alpha1.APIVERSION, func(a, b interface{}, scope conversion.Scope) error {
|
||||
return Convert_V1beta1_to_V2alpha1(a.(*dashv1.Dashboard), b.(*dashv2alpha1.Dashboard), scope, dsIndexProvider)
|
||||
return Convert_V1beta1_to_V2alpha1(a.(*dashv1.Dashboard), b.(*dashv2alpha1.Dashboard), scope, dsIndexProvider, leIndexProvider)
|
||||
})); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.AddConversionFunc((*dashv1.Dashboard)(nil), (*dashv2beta1.Dashboard)(nil),
|
||||
withConversionMetrics(dashv1.APIVERSION, dashv2beta1.APIVERSION, func(a, b interface{}, scope conversion.Scope) error {
|
||||
return Convert_V1beta1_to_V2beta1(a.(*dashv1.Dashboard), b.(*dashv2beta1.Dashboard), scope, dsIndexProvider)
|
||||
return Convert_V1beta1_to_V2beta1(a.(*dashv1.Dashboard), b.(*dashv2beta1.Dashboard), scope, dsIndexProvider, leIndexProvider)
|
||||
})); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -33,7 +33,8 @@ import (
|
||||
func TestConversionMatrixExist(t *testing.T) {
|
||||
// Initialize the migrator with a test data source provider
|
||||
dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig)
|
||||
leProvider := migrationtestutil.NewLibraryElementProvider()
|
||||
// Use TestLibraryElementProvider for tests that need library panel models with repeat options
|
||||
leProvider := migrationtestutil.NewTestLibraryElementProvider()
|
||||
migration.Initialize(dsProvider, leProvider)
|
||||
|
||||
versions := []metav1.Object{
|
||||
@@ -86,7 +87,8 @@ func TestDeepCopyValid(t *testing.T) {
|
||||
func TestDashboardConversionToAllVersions(t *testing.T) {
|
||||
// Initialize the migrator with a test data source provider
|
||||
dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig)
|
||||
leProvider := migrationtestutil.NewLibraryElementProvider()
|
||||
// Use TestLibraryElementProvider for tests that need library panel models with repeat options
|
||||
leProvider := migrationtestutil.NewTestLibraryElementProvider()
|
||||
migration.Initialize(dsProvider, leProvider)
|
||||
|
||||
// Set up conversion scheme
|
||||
@@ -246,7 +248,8 @@ func TestDashboardConversionToAllVersions(t *testing.T) {
|
||||
func TestMigratedDashboardsConversion(t *testing.T) {
|
||||
// Initialize the migrator with a test data source provider
|
||||
dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig)
|
||||
leProvider := migrationtestutil.NewLibraryElementProvider()
|
||||
// Use TestLibraryElementProvider for tests that need library panel models with repeat options
|
||||
leProvider := migrationtestutil.NewTestLibraryElementProvider()
|
||||
migration.Initialize(dsProvider, leProvider)
|
||||
|
||||
// Set up conversion scheme
|
||||
@@ -381,7 +384,8 @@ func testConversion(t *testing.T, convertedDash metav1.Object, filename, outputD
|
||||
func TestConversionMetrics(t *testing.T) {
|
||||
// Initialize migration with test providers
|
||||
dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig)
|
||||
leProvider := migrationtestutil.NewLibraryElementProvider()
|
||||
// Use TestLibraryElementProvider for tests that need library panel models with repeat options
|
||||
leProvider := migrationtestutil.NewTestLibraryElementProvider()
|
||||
migration.Initialize(dsProvider, leProvider)
|
||||
|
||||
// Create a test registry for metrics
|
||||
@@ -509,7 +513,8 @@ func TestConversionMetrics(t *testing.T) {
|
||||
// TestConversionMetricsWrapper tests the withConversionMetrics wrapper function
|
||||
func TestConversionMetricsWrapper(t *testing.T) {
|
||||
dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig)
|
||||
leProvider := migrationtestutil.NewLibraryElementProvider()
|
||||
// Use TestLibraryElementProvider for tests that need library panel models with repeat options
|
||||
leProvider := migrationtestutil.NewTestLibraryElementProvider()
|
||||
migration.Initialize(dsProvider, leProvider)
|
||||
|
||||
// Create a test registry for metrics
|
||||
@@ -678,7 +683,8 @@ func TestSchemaVersionExtraction(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Test the schema version extraction logic by creating a wrapper and checking the metrics labels
|
||||
dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig)
|
||||
leProvider := migrationtestutil.NewLibraryElementProvider()
|
||||
// Use TestLibraryElementProvider for tests that need library panel models with repeat options
|
||||
leProvider := migrationtestutil.NewTestLibraryElementProvider()
|
||||
migration.Initialize(dsProvider, leProvider)
|
||||
|
||||
// Create a test registry for metrics
|
||||
@@ -723,7 +729,8 @@ func TestSchemaVersionExtraction(t *testing.T) {
|
||||
// TestConversionLogging tests that conversion-level logging works correctly
|
||||
func TestConversionLogging(t *testing.T) {
|
||||
dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig)
|
||||
leProvider := migrationtestutil.NewLibraryElementProvider()
|
||||
// Use TestLibraryElementProvider for tests that need library panel models with repeat options
|
||||
leProvider := migrationtestutil.NewTestLibraryElementProvider()
|
||||
migration.Initialize(dsProvider, leProvider)
|
||||
|
||||
// Create a test registry for metrics
|
||||
@@ -815,7 +822,8 @@ func TestConversionLogging(t *testing.T) {
|
||||
// TestConversionLogLevels tests that appropriate log levels are used
|
||||
func TestConversionLogLevels(t *testing.T) {
|
||||
dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig)
|
||||
leProvider := migrationtestutil.NewLibraryElementProvider()
|
||||
// Use TestLibraryElementProvider for tests that need library panel models with repeat options
|
||||
leProvider := migrationtestutil.NewTestLibraryElementProvider()
|
||||
migration.Initialize(dsProvider, leProvider)
|
||||
|
||||
t.Run("log levels and structured fields verification", func(t *testing.T) {
|
||||
@@ -887,7 +895,8 @@ func TestConversionLogLevels(t *testing.T) {
|
||||
// TestConversionLoggingFields tests that all expected fields are included in log messages
|
||||
func TestConversionLoggingFields(t *testing.T) {
|
||||
dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig)
|
||||
leProvider := migrationtestutil.NewLibraryElementProvider()
|
||||
// Use TestLibraryElementProvider for tests that need library panel models with repeat options
|
||||
leProvider := migrationtestutil.NewTestLibraryElementProvider()
|
||||
migration.Initialize(dsProvider, leProvider)
|
||||
|
||||
t.Run("verify all log fields are present", func(t *testing.T) {
|
||||
|
||||
Vendored
+93
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"kind": "Dashboard",
|
||||
"apiVersion": "dashboard.grafana.app/v1beta1",
|
||||
"metadata": {
|
||||
"name": "library-panel-repeat-options-test",
|
||||
"labels": {
|
||||
"test": "library-panel-repeat"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"title": "Library Panel Repeat Options Test Dashboard",
|
||||
"description": "Testing library panel repeat options migration from v1beta1 to v2alpha1",
|
||||
"tags": ["test", "library-panels", "repeat"],
|
||||
"schemaVersion": 38,
|
||||
"panels": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Library Panel with Horizontal Repeat",
|
||||
"type": "library-panel-ref",
|
||||
"gridPos": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 12,
|
||||
"h": 8
|
||||
},
|
||||
"libraryPanel": {
|
||||
"uid": "lib-panel-repeat-h",
|
||||
"name": "Library Panel with Horizontal Repeat"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "Library Panel with Vertical Repeat",
|
||||
"type": "library-panel-ref",
|
||||
"gridPos": {
|
||||
"x": 0,
|
||||
"y": 8,
|
||||
"w": 6,
|
||||
"h": 4
|
||||
},
|
||||
"libraryPanel": {
|
||||
"uid": "lib-panel-repeat-v",
|
||||
"name": "Library Panel with Vertical Repeat"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "Library Panel Instance Override",
|
||||
"type": "library-panel-ref",
|
||||
"gridPos": {
|
||||
"x": 6,
|
||||
"y": 8,
|
||||
"w": 12,
|
||||
"h": 8
|
||||
},
|
||||
"libraryPanel": {
|
||||
"uid": "lib-panel-repeat-h",
|
||||
"name": "Library Panel with Horizontal Repeat"
|
||||
},
|
||||
"repeat": "instance-var",
|
||||
"repeatDirection": "v",
|
||||
"maxPerRow": 5
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "Library Panel without Repeat",
|
||||
"type": "library-panel-ref",
|
||||
"gridPos": {
|
||||
"x": 0,
|
||||
"y": 12,
|
||||
"w": 6,
|
||||
"h": 3
|
||||
},
|
||||
"libraryPanel": {
|
||||
"uid": "lib-panel-no-repeat",
|
||||
"name": "Library Panel without Repeat"
|
||||
}
|
||||
}
|
||||
],
|
||||
"time": {
|
||||
"from": "now-1h",
|
||||
"to": "now"
|
||||
},
|
||||
"templating": {
|
||||
"list": []
|
||||
},
|
||||
"annotations": {
|
||||
"list": []
|
||||
},
|
||||
"links": []
|
||||
}
|
||||
}
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"kind": "Dashboard",
|
||||
"apiVersion": "dashboard.grafana.app/v0alpha1",
|
||||
"metadata": {
|
||||
"name": "library-panel-repeat-options-test",
|
||||
"labels": {
|
||||
"test": "library-panel-repeat"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"annotations": {
|
||||
"list": []
|
||||
},
|
||||
"description": "Testing library panel repeat options migration from v1beta1 to v2alpha1",
|
||||
"links": [],
|
||||
"panels": [
|
||||
{
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 1,
|
||||
"libraryPanel": {
|
||||
"name": "Library Panel with Horizontal Repeat",
|
||||
"uid": "lib-panel-repeat-h"
|
||||
},
|
||||
"title": "Library Panel with Horizontal Repeat",
|
||||
"type": "library-panel-ref"
|
||||
},
|
||||
{
|
||||
"gridPos": {
|
||||
"h": 4,
|
||||
"w": 6,
|
||||
"x": 0,
|
||||
"y": 8
|
||||
},
|
||||
"id": 2,
|
||||
"libraryPanel": {
|
||||
"name": "Library Panel with Vertical Repeat",
|
||||
"uid": "lib-panel-repeat-v"
|
||||
},
|
||||
"title": "Library Panel with Vertical Repeat",
|
||||
"type": "library-panel-ref"
|
||||
},
|
||||
{
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 6,
|
||||
"y": 8
|
||||
},
|
||||
"id": 3,
|
||||
"libraryPanel": {
|
||||
"name": "Library Panel with Horizontal Repeat",
|
||||
"uid": "lib-panel-repeat-h"
|
||||
},
|
||||
"maxPerRow": 5,
|
||||
"repeat": "instance-var",
|
||||
"repeatDirection": "v",
|
||||
"title": "Library Panel Instance Override",
|
||||
"type": "library-panel-ref"
|
||||
},
|
||||
{
|
||||
"gridPos": {
|
||||
"h": 3,
|
||||
"w": 6,
|
||||
"x": 0,
|
||||
"y": 12
|
||||
},
|
||||
"id": 4,
|
||||
"libraryPanel": {
|
||||
"name": "Library Panel without Repeat",
|
||||
"uid": "lib-panel-no-repeat"
|
||||
},
|
||||
"title": "Library Panel without Repeat",
|
||||
"type": "library-panel-ref"
|
||||
}
|
||||
],
|
||||
"schemaVersion": 38,
|
||||
"tags": [
|
||||
"test",
|
||||
"library-panels",
|
||||
"repeat"
|
||||
],
|
||||
"templating": {
|
||||
"list": []
|
||||
},
|
||||
"time": {
|
||||
"from": "now-1h",
|
||||
"to": "now"
|
||||
},
|
||||
"title": "Library Panel Repeat Options Test Dashboard"
|
||||
},
|
||||
"status": {
|
||||
"conversion": {
|
||||
"failed": false,
|
||||
"storedVersion": "v1beta1"
|
||||
}
|
||||
}
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
{
|
||||
"kind": "Dashboard",
|
||||
"apiVersion": "dashboard.grafana.app/v2alpha1",
|
||||
"metadata": {
|
||||
"name": "library-panel-repeat-options-test",
|
||||
"labels": {
|
||||
"test": "library-panel-repeat"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"annotations": [],
|
||||
"cursorSync": "Off",
|
||||
"description": "Testing library panel repeat options migration from v1beta1 to v2alpha1",
|
||||
"editable": true,
|
||||
"elements": {
|
||||
"panel-1": {
|
||||
"kind": "LibraryPanel",
|
||||
"spec": {
|
||||
"id": 1,
|
||||
"title": "Library Panel with Horizontal Repeat",
|
||||
"libraryPanel": {
|
||||
"name": "Library Panel with Horizontal Repeat",
|
||||
"uid": "lib-panel-repeat-h"
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel-2": {
|
||||
"kind": "LibraryPanel",
|
||||
"spec": {
|
||||
"id": 2,
|
||||
"title": "Library Panel with Vertical Repeat",
|
||||
"libraryPanel": {
|
||||
"name": "Library Panel with Vertical Repeat",
|
||||
"uid": "lib-panel-repeat-v"
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel-3": {
|
||||
"kind": "LibraryPanel",
|
||||
"spec": {
|
||||
"id": 3,
|
||||
"title": "Library Panel Instance Override",
|
||||
"libraryPanel": {
|
||||
"name": "Library Panel with Horizontal Repeat",
|
||||
"uid": "lib-panel-repeat-h"
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel-4": {
|
||||
"kind": "LibraryPanel",
|
||||
"spec": {
|
||||
"id": 4,
|
||||
"title": "Library Panel without Repeat",
|
||||
"libraryPanel": {
|
||||
"name": "Library Panel without Repeat",
|
||||
"uid": "lib-panel-no-repeat"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"layout": {
|
||||
"kind": "GridLayout",
|
||||
"spec": {
|
||||
"items": [
|
||||
{
|
||||
"kind": "GridLayoutItem",
|
||||
"spec": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 12,
|
||||
"height": 8,
|
||||
"element": {
|
||||
"kind": "ElementReference",
|
||||
"name": "panel-1"
|
||||
},
|
||||
"repeat": {
|
||||
"mode": "variable",
|
||||
"value": "server",
|
||||
"direction": "h",
|
||||
"maxPerRow": 3
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "GridLayoutItem",
|
||||
"spec": {
|
||||
"x": 0,
|
||||
"y": 8,
|
||||
"width": 6,
|
||||
"height": 4,
|
||||
"element": {
|
||||
"kind": "ElementReference",
|
||||
"name": "panel-2"
|
||||
},
|
||||
"repeat": {
|
||||
"mode": "variable",
|
||||
"value": "datacenter",
|
||||
"direction": "v"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "GridLayoutItem",
|
||||
"spec": {
|
||||
"x": 6,
|
||||
"y": 8,
|
||||
"width": 12,
|
||||
"height": 8,
|
||||
"element": {
|
||||
"kind": "ElementReference",
|
||||
"name": "panel-3"
|
||||
},
|
||||
"repeat": {
|
||||
"mode": "variable",
|
||||
"value": "instance-var",
|
||||
"direction": "v",
|
||||
"maxPerRow": 5
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "GridLayoutItem",
|
||||
"spec": {
|
||||
"x": 0,
|
||||
"y": 12,
|
||||
"width": 6,
|
||||
"height": 3,
|
||||
"element": {
|
||||
"kind": "ElementReference",
|
||||
"name": "panel-4"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"liveNow": false,
|
||||
"preload": false,
|
||||
"tags": [
|
||||
"test",
|
||||
"library-panels",
|
||||
"repeat"
|
||||
],
|
||||
"timeSettings": {
|
||||
"timezone": "browser",
|
||||
"from": "now-1h",
|
||||
"to": "now",
|
||||
"autoRefresh": "",
|
||||
"autoRefreshIntervals": [
|
||||
"5s",
|
||||
"10s",
|
||||
"30s",
|
||||
"1m",
|
||||
"5m",
|
||||
"15m",
|
||||
"30m",
|
||||
"1h",
|
||||
"2h",
|
||||
"1d"
|
||||
],
|
||||
"hideTimepicker": false,
|
||||
"fiscalYearStartMonth": 0
|
||||
},
|
||||
"title": "Library Panel Repeat Options Test Dashboard",
|
||||
"variables": []
|
||||
},
|
||||
"status": {}
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
{
|
||||
"kind": "Dashboard",
|
||||
"apiVersion": "dashboard.grafana.app/v2beta1",
|
||||
"metadata": {
|
||||
"name": "library-panel-repeat-options-test",
|
||||
"labels": {
|
||||
"test": "library-panel-repeat"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"annotations": [],
|
||||
"cursorSync": "Off",
|
||||
"description": "Testing library panel repeat options migration from v1beta1 to v2alpha1",
|
||||
"editable": true,
|
||||
"elements": {
|
||||
"panel-1": {
|
||||
"kind": "LibraryPanel",
|
||||
"spec": {
|
||||
"id": 1,
|
||||
"title": "Library Panel with Horizontal Repeat",
|
||||
"libraryPanel": {
|
||||
"name": "Library Panel with Horizontal Repeat",
|
||||
"uid": "lib-panel-repeat-h"
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel-2": {
|
||||
"kind": "LibraryPanel",
|
||||
"spec": {
|
||||
"id": 2,
|
||||
"title": "Library Panel with Vertical Repeat",
|
||||
"libraryPanel": {
|
||||
"name": "Library Panel with Vertical Repeat",
|
||||
"uid": "lib-panel-repeat-v"
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel-3": {
|
||||
"kind": "LibraryPanel",
|
||||
"spec": {
|
||||
"id": 3,
|
||||
"title": "Library Panel Instance Override",
|
||||
"libraryPanel": {
|
||||
"name": "Library Panel with Horizontal Repeat",
|
||||
"uid": "lib-panel-repeat-h"
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel-4": {
|
||||
"kind": "LibraryPanel",
|
||||
"spec": {
|
||||
"id": 4,
|
||||
"title": "Library Panel without Repeat",
|
||||
"libraryPanel": {
|
||||
"name": "Library Panel without Repeat",
|
||||
"uid": "lib-panel-no-repeat"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"layout": {
|
||||
"kind": "GridLayout",
|
||||
"spec": {
|
||||
"items": [
|
||||
{
|
||||
"kind": "GridLayoutItem",
|
||||
"spec": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 12,
|
||||
"height": 8,
|
||||
"element": {
|
||||
"kind": "ElementReference",
|
||||
"name": "panel-1"
|
||||
},
|
||||
"repeat": {
|
||||
"mode": "variable",
|
||||
"value": "server",
|
||||
"direction": "h",
|
||||
"maxPerRow": 3
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "GridLayoutItem",
|
||||
"spec": {
|
||||
"x": 0,
|
||||
"y": 8,
|
||||
"width": 6,
|
||||
"height": 4,
|
||||
"element": {
|
||||
"kind": "ElementReference",
|
||||
"name": "panel-2"
|
||||
},
|
||||
"repeat": {
|
||||
"mode": "variable",
|
||||
"value": "datacenter",
|
||||
"direction": "v"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "GridLayoutItem",
|
||||
"spec": {
|
||||
"x": 6,
|
||||
"y": 8,
|
||||
"width": 12,
|
||||
"height": 8,
|
||||
"element": {
|
||||
"kind": "ElementReference",
|
||||
"name": "panel-3"
|
||||
},
|
||||
"repeat": {
|
||||
"mode": "variable",
|
||||
"value": "instance-var",
|
||||
"direction": "v",
|
||||
"maxPerRow": 5
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "GridLayoutItem",
|
||||
"spec": {
|
||||
"x": 0,
|
||||
"y": 12,
|
||||
"width": 6,
|
||||
"height": 3,
|
||||
"element": {
|
||||
"kind": "ElementReference",
|
||||
"name": "panel-4"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"liveNow": false,
|
||||
"preload": false,
|
||||
"tags": [
|
||||
"test",
|
||||
"library-panels",
|
||||
"repeat"
|
||||
],
|
||||
"timeSettings": {
|
||||
"timezone": "browser",
|
||||
"from": "now-1h",
|
||||
"to": "now",
|
||||
"autoRefresh": "",
|
||||
"autoRefreshIntervals": [
|
||||
"5s",
|
||||
"10s",
|
||||
"30s",
|
||||
"1m",
|
||||
"5m",
|
||||
"15m",
|
||||
"30m",
|
||||
"1h",
|
||||
"2h",
|
||||
"1d"
|
||||
],
|
||||
"hideTimepicker": false,
|
||||
"fiscalYearStartMonth": 0
|
||||
},
|
||||
"title": "Library Panel Repeat Options Test Dashboard",
|
||||
"variables": []
|
||||
},
|
||||
"status": {}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ func Convert_V0_to_V1beta1(in *dashv0.Dashboard, out *dashv1.Dashboard, scope co
|
||||
return nil
|
||||
}
|
||||
|
||||
func Convert_V0_to_V2alpha1(in *dashv0.Dashboard, out *dashv2alpha1.Dashboard, scope conversion.Scope, dsIndexProvider schemaversion.DataSourceIndexProvider) error {
|
||||
func Convert_V0_to_V2alpha1(in *dashv0.Dashboard, out *dashv2alpha1.Dashboard, scope conversion.Scope, dsIndexProvider schemaversion.DataSourceIndexProvider, leIndexProvider schemaversion.LibraryElementIndexProvider) error {
|
||||
v1beta1 := &dashv1.Dashboard{}
|
||||
if err := ConvertDashboard_V0_to_V1beta1(in, v1beta1, scope); err != nil {
|
||||
out.Status = dashv2alpha1.DashboardStatus{
|
||||
@@ -48,7 +48,7 @@ func Convert_V0_to_V2alpha1(in *dashv0.Dashboard, out *dashv2alpha1.Dashboard, s
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := ConvertDashboard_V1beta1_to_V2alpha1(v1beta1, out, scope, dsIndexProvider); err != nil {
|
||||
if err := ConvertDashboard_V1beta1_to_V2alpha1(v1beta1, out, scope, dsIndexProvider, leIndexProvider); err != nil {
|
||||
out.Status = dashv2alpha1.DashboardStatus{
|
||||
Conversion: &dashv2alpha1.DashboardConversionStatus{
|
||||
StoredVersion: ptr.To(dashv0.VERSION),
|
||||
@@ -72,7 +72,7 @@ func Convert_V0_to_V2alpha1(in *dashv0.Dashboard, out *dashv2alpha1.Dashboard, s
|
||||
return nil
|
||||
}
|
||||
|
||||
func Convert_V0_to_V2beta1(in *dashv0.Dashboard, out *dashv2beta1.Dashboard, scope conversion.Scope, dsIndexProvider schemaversion.DataSourceIndexProvider) error {
|
||||
func Convert_V0_to_V2beta1(in *dashv0.Dashboard, out *dashv2beta1.Dashboard, scope conversion.Scope, dsIndexProvider schemaversion.DataSourceIndexProvider, leIndexProvider schemaversion.LibraryElementIndexProvider) error {
|
||||
v1beta1 := &dashv1.Dashboard{}
|
||||
if err := ConvertDashboard_V0_to_V1beta1(in, v1beta1, scope); err != nil {
|
||||
out.Status = dashv2beta1.DashboardStatus{
|
||||
@@ -86,7 +86,7 @@ func Convert_V0_to_V2beta1(in *dashv0.Dashboard, out *dashv2beta1.Dashboard, sco
|
||||
}
|
||||
|
||||
v2alpha1 := &dashv2alpha1.Dashboard{}
|
||||
if err := ConvertDashboard_V1beta1_to_V2alpha1(v1beta1, v2alpha1, scope, dsIndexProvider); err != nil {
|
||||
if err := ConvertDashboard_V1beta1_to_V2alpha1(v1beta1, v2alpha1, scope, dsIndexProvider, leIndexProvider); err != nil {
|
||||
out.Status = dashv2beta1.DashboardStatus{
|
||||
Conversion: &dashv2beta1.DashboardConversionStatus{
|
||||
StoredVersion: ptr.To(dashv0.VERSION),
|
||||
|
||||
@@ -109,9 +109,9 @@ func TestV0ConversionErrorHandling(t *testing.T) {
|
||||
case *dashv1.Dashboard:
|
||||
err = Convert_V0_to_V1beta1(tt.source, target, nil)
|
||||
case *dashv2alpha1.Dashboard:
|
||||
err = Convert_V0_to_V2alpha1(tt.source, target, nil, dsProvider)
|
||||
err = Convert_V0_to_V2alpha1(tt.source, target, nil, dsProvider, leProvider)
|
||||
case *dashv2beta1.Dashboard:
|
||||
err = Convert_V0_to_V2beta1(tt.source, target, nil, dsProvider)
|
||||
err = Convert_V0_to_V2beta1(tt.source, target, nil, dsProvider, leProvider)
|
||||
default:
|
||||
t.Fatalf("unexpected target type: %T", target)
|
||||
}
|
||||
@@ -192,7 +192,7 @@ func TestV0ConversionErrorPropagation(t *testing.T) {
|
||||
}
|
||||
target := &dashv2beta1.Dashboard{}
|
||||
|
||||
err := Convert_V0_to_V2beta1(source, target, nil, dsProvider)
|
||||
err := Convert_V0_to_V2beta1(source, target, nil, dsProvider, leProvider)
|
||||
|
||||
require.Error(t, err, "expected error to be returned on first step failure")
|
||||
require.NotNil(t, target.Status.Conversion)
|
||||
@@ -243,7 +243,7 @@ func TestV0ConversionSuccessPaths(t *testing.T) {
|
||||
}
|
||||
target := &dashv2alpha1.Dashboard{}
|
||||
|
||||
err := Convert_V0_to_V2alpha1(source, target, nil, dsProvider)
|
||||
err := Convert_V0_to_V2alpha1(source, target, nil, dsProvider, leProvider)
|
||||
|
||||
require.NoError(t, err, "expected successful conversion")
|
||||
// Layout should be set even on success
|
||||
@@ -264,7 +264,7 @@ func TestV0ConversionSuccessPaths(t *testing.T) {
|
||||
}
|
||||
target := &dashv2beta1.Dashboard{}
|
||||
|
||||
err := Convert_V0_to_V2beta1(source, target, nil, dsProvider)
|
||||
err := Convert_V0_to_V2beta1(source, target, nil, dsProvider, leProvider)
|
||||
|
||||
require.NoError(t, err, "expected successful conversion")
|
||||
})
|
||||
@@ -293,7 +293,7 @@ func TestV0ConversionSecondStepErrors(t *testing.T) {
|
||||
}
|
||||
target := &dashv2alpha1.Dashboard{}
|
||||
|
||||
err := Convert_V0_to_V2alpha1(source, target, nil, dsProvider)
|
||||
err := Convert_V0_to_V2alpha1(source, target, nil, dsProvider, leProvider)
|
||||
|
||||
// Convert_V0_to_V2alpha1 doesn't return error, just sets status
|
||||
require.NoError(t, err, "Convert_V0_to_V2alpha1 doesn't return error")
|
||||
@@ -327,7 +327,7 @@ func TestV0ConversionSecondStepErrors(t *testing.T) {
|
||||
}
|
||||
target := &dashv2alpha1.Dashboard{}
|
||||
|
||||
err := Convert_V0_to_V2alpha1(source, target, nil, dsProvider)
|
||||
err := Convert_V0_to_V2alpha1(source, target, nil, dsProvider, leProvider)
|
||||
|
||||
// Convert_V0_to_V2alpha1 doesn't return error, just sets status
|
||||
require.NoError(t, err, "Convert_V0_to_V2alpha1 doesn't return error")
|
||||
@@ -357,7 +357,7 @@ func TestV0ConversionSecondStepErrors(t *testing.T) {
|
||||
}
|
||||
target := &dashv2beta1.Dashboard{}
|
||||
|
||||
err := Convert_V0_to_V2beta1(source, target, nil, dsProvider)
|
||||
err := Convert_V0_to_V2beta1(source, target, nil, dsProvider, leProvider)
|
||||
|
||||
// May or may not error depending on dashboard content
|
||||
// But if it does error on second step, status should be set
|
||||
@@ -383,7 +383,7 @@ func TestV0ConversionSecondStepErrors(t *testing.T) {
|
||||
}
|
||||
target := &dashv2beta1.Dashboard{}
|
||||
|
||||
err := Convert_V0_to_V2beta1(source, target, nil, dsProvider)
|
||||
err := Convert_V0_to_V2beta1(source, target, nil, dsProvider, leProvider)
|
||||
|
||||
// May or may not error depending on dashboard content
|
||||
// But if it does error on third step, status should be set
|
||||
|
||||
@@ -25,8 +25,8 @@ func Convert_V1beta1_to_V0(in *dashv1.Dashboard, out *dashv0.Dashboard, scope co
|
||||
return nil
|
||||
}
|
||||
|
||||
func Convert_V1beta1_to_V2alpha1(in *dashv1.Dashboard, out *dashv2alpha1.Dashboard, scope conversion.Scope, dsIndexProvider schemaversion.DataSourceIndexProvider) error {
|
||||
if err := ConvertDashboard_V1beta1_to_V2alpha1(in, out, scope, dsIndexProvider); err != nil {
|
||||
func Convert_V1beta1_to_V2alpha1(in *dashv1.Dashboard, out *dashv2alpha1.Dashboard, scope conversion.Scope, dsIndexProvider schemaversion.DataSourceIndexProvider, leIndexProvider schemaversion.LibraryElementIndexProvider) error {
|
||||
if err := ConvertDashboard_V1beta1_to_V2alpha1(in, out, scope, dsIndexProvider, leIndexProvider); err != nil {
|
||||
out.Status = dashv2alpha1.DashboardStatus{
|
||||
Conversion: &dashv2alpha1.DashboardConversionStatus{
|
||||
StoredVersion: ptr.To(dashv1.VERSION),
|
||||
@@ -60,9 +60,9 @@ func Convert_V1beta1_to_V2alpha1(in *dashv1.Dashboard, out *dashv2alpha1.Dashboa
|
||||
return nil
|
||||
}
|
||||
|
||||
func Convert_V1beta1_to_V2beta1(in *dashv1.Dashboard, out *dashv2beta1.Dashboard, scope conversion.Scope, dsIndexProvider schemaversion.DataSourceIndexProvider) error {
|
||||
func Convert_V1beta1_to_V2beta1(in *dashv1.Dashboard, out *dashv2beta1.Dashboard, scope conversion.Scope, dsIndexProvider schemaversion.DataSourceIndexProvider, leIndexProvider schemaversion.LibraryElementIndexProvider) error {
|
||||
v2alpha1 := &dashv2alpha1.Dashboard{}
|
||||
if err := ConvertDashboard_V1beta1_to_V2alpha1(in, v2alpha1, scope, dsIndexProvider); err != nil {
|
||||
if err := ConvertDashboard_V1beta1_to_V2alpha1(in, v2alpha1, scope, dsIndexProvider, leIndexProvider); err != nil {
|
||||
out.Status = dashv2beta1.DashboardStatus{
|
||||
Conversion: &dashv2beta1.DashboardConversionStatus{
|
||||
StoredVersion: ptr.To(dashv1.VERSION),
|
||||
|
||||
@@ -37,7 +37,7 @@ func TestV1ConversionErrorHandling(t *testing.T) {
|
||||
}
|
||||
target := &dashv2alpha1.Dashboard{}
|
||||
|
||||
err := Convert_V1beta1_to_V2alpha1(source, target, nil, dsProvider)
|
||||
err := Convert_V1beta1_to_V2alpha1(source, target, nil, dsProvider, leProvider)
|
||||
|
||||
// Convert_V1beta1_to_V2alpha1 doesn't return error, just sets status
|
||||
require.NoError(t, err, "Convert_V1beta1_to_V2alpha1 doesn't return error")
|
||||
@@ -64,7 +64,7 @@ func TestV1ConversionErrorHandling(t *testing.T) {
|
||||
}
|
||||
target := &dashv2beta1.Dashboard{}
|
||||
|
||||
err := Convert_V1beta1_to_V2beta1(source, target, nil, dsProvider)
|
||||
err := Convert_V1beta1_to_V2beta1(source, target, nil, dsProvider, leProvider)
|
||||
|
||||
// May or may not error depending on dashboard content
|
||||
// But if it does error on first step, status should be set with correct StoredVersion
|
||||
@@ -91,7 +91,7 @@ func TestV1ConversionErrorHandling(t *testing.T) {
|
||||
}
|
||||
target := &dashv2beta1.Dashboard{}
|
||||
|
||||
err := Convert_V1beta1_to_V2beta1(source, target, nil, dsProvider)
|
||||
err := Convert_V1beta1_to_V2beta1(source, target, nil, dsProvider, leProvider)
|
||||
|
||||
// May or may not error depending on dashboard content
|
||||
// But if it does error on second step, status should be set with correct StoredVersion
|
||||
@@ -117,7 +117,7 @@ func TestV1ConversionErrorHandling(t *testing.T) {
|
||||
}
|
||||
target := &dashv2beta1.Dashboard{}
|
||||
|
||||
err := Convert_V1beta1_to_V2beta1(source, target, nil, dsProvider)
|
||||
err := Convert_V1beta1_to_V2beta1(source, target, nil, dsProvider, leProvider)
|
||||
|
||||
// Should succeed if dashboard is valid
|
||||
if err == nil {
|
||||
|
||||
@@ -80,7 +80,7 @@ func prepareV1beta1ConversionContext(in *dashv1.Dashboard, dsIndexProvider schem
|
||||
return ctx, &nsInfo, nil
|
||||
}
|
||||
|
||||
func ConvertDashboard_V1beta1_to_V2alpha1(in *dashv1.Dashboard, out *dashv2alpha1.Dashboard, scope conversion.Scope, dsIndexProvider schemaversion.DataSourceIndexProvider) error {
|
||||
func ConvertDashboard_V1beta1_to_V2alpha1(in *dashv1.Dashboard, out *dashv2alpha1.Dashboard, scope conversion.Scope, dsIndexProvider schemaversion.DataSourceIndexProvider, leIndexProvider schemaversion.LibraryElementIndexProvider) error {
|
||||
out.ObjectMeta = in.ObjectMeta
|
||||
out.APIVersion = dashv2alpha1.APIVERSION
|
||||
out.Kind = in.Kind
|
||||
@@ -94,10 +94,10 @@ func ConvertDashboard_V1beta1_to_V2alpha1(in *dashv1.Dashboard, out *dashv2alpha
|
||||
return fmt.Errorf("failed to prepare conversion context: %w", err)
|
||||
}
|
||||
|
||||
return convertDashboardSpec_V1beta1_to_V2alpha1(&in.Spec, &out.Spec, scope, ctx, dsIndexProvider)
|
||||
return convertDashboardSpec_V1beta1_to_V2alpha1(&in.Spec, &out.Spec, scope, ctx, dsIndexProvider, leIndexProvider)
|
||||
}
|
||||
|
||||
func convertDashboardSpec_V1beta1_to_V2alpha1(in *dashv1.DashboardSpec, out *dashv2alpha1.DashboardSpec, scope conversion.Scope, ctx context.Context, dsIndexProvider schemaversion.DataSourceIndexProvider) error {
|
||||
func convertDashboardSpec_V1beta1_to_V2alpha1(in *dashv1.DashboardSpec, out *dashv2alpha1.DashboardSpec, scope conversion.Scope, ctx context.Context, dsIndexProvider schemaversion.DataSourceIndexProvider, leIndexProvider schemaversion.LibraryElementIndexProvider) error {
|
||||
// Parse the unstructured spec into a dashboard JSON structure
|
||||
dashboardJSON, ok := in.Object["dashboard"]
|
||||
if !ok {
|
||||
@@ -161,7 +161,7 @@ func convertDashboardSpec_V1beta1_to_V2alpha1(in *dashv1.DashboardSpec, out *das
|
||||
out.Links = transformLinks(dashboard)
|
||||
|
||||
// Transform panels to elements and layout
|
||||
elements, layout, err := transformPanelsToElementsAndLayout(ctx, dashboard, dsIndexProvider)
|
||||
elements, layout, err := transformPanelsToElementsAndLayout(ctx, dashboard, dsIndexProvider, leIndexProvider)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to transform panels: %w", err)
|
||||
}
|
||||
@@ -387,7 +387,7 @@ func transformLinks(dashboard map[string]interface{}) []dashv2alpha1.DashboardDa
|
||||
// Panel transformation constants
|
||||
const GRID_ROW_HEIGHT = 1
|
||||
|
||||
func transformPanelsToElementsAndLayout(ctx context.Context, dashboard map[string]interface{}, dsIndexProvider schemaversion.DataSourceIndexProvider) (map[string]dashv2alpha1.DashboardElement, dashv2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind, error) {
|
||||
func transformPanelsToElementsAndLayout(ctx context.Context, dashboard map[string]interface{}, dsIndexProvider schemaversion.DataSourceIndexProvider, leIndexProvider schemaversion.LibraryElementIndexProvider) (map[string]dashv2alpha1.DashboardElement, dashv2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind, error) {
|
||||
panels, ok := dashboard["panels"].([]interface{})
|
||||
if !ok {
|
||||
// Return empty elements and default grid layout
|
||||
@@ -415,13 +415,13 @@ func transformPanelsToElementsAndLayout(ctx context.Context, dashboard map[strin
|
||||
}
|
||||
|
||||
if hasRowPanels {
|
||||
return convertToRowsLayout(ctx, panels, dsIndexProvider)
|
||||
return convertToRowsLayout(ctx, panels, dsIndexProvider, leIndexProvider)
|
||||
}
|
||||
|
||||
return convertToGridLayout(ctx, panels, dsIndexProvider)
|
||||
return convertToGridLayout(ctx, panels, dsIndexProvider, leIndexProvider)
|
||||
}
|
||||
|
||||
func convertToGridLayout(ctx context.Context, panels []interface{}, dsIndexProvider schemaversion.DataSourceIndexProvider) (map[string]dashv2alpha1.DashboardElement, dashv2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind, error) {
|
||||
func convertToGridLayout(ctx context.Context, panels []interface{}, dsIndexProvider schemaversion.DataSourceIndexProvider, leIndexProvider schemaversion.LibraryElementIndexProvider) (map[string]dashv2alpha1.DashboardElement, dashv2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind, error) {
|
||||
elements := make(map[string]dashv2alpha1.DashboardElement)
|
||||
items := make([]dashv2alpha1.DashboardGridLayoutItemKind, 0, len(panels))
|
||||
|
||||
@@ -437,7 +437,7 @@ func convertToGridLayout(ctx context.Context, panels []interface{}, dsIndexProvi
|
||||
}
|
||||
|
||||
elements[elementName] = element
|
||||
items = append(items, buildGridItemKind(panelMap, elementName, nil))
|
||||
items = append(items, buildGridItemKind(ctx, panelMap, elementName, nil, leIndexProvider))
|
||||
}
|
||||
|
||||
layout := dashv2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind{
|
||||
@@ -452,7 +452,7 @@ func convertToGridLayout(ctx context.Context, panels []interface{}, dsIndexProvi
|
||||
return elements, layout, nil
|
||||
}
|
||||
|
||||
func convertToRowsLayout(ctx context.Context, panels []interface{}, dsIndexProvider schemaversion.DataSourceIndexProvider) (map[string]dashv2alpha1.DashboardElement, dashv2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind, error) {
|
||||
func convertToRowsLayout(ctx context.Context, panels []interface{}, dsIndexProvider schemaversion.DataSourceIndexProvider, leIndexProvider schemaversion.LibraryElementIndexProvider) (map[string]dashv2alpha1.DashboardElement, dashv2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind, error) {
|
||||
elements := make(map[string]dashv2alpha1.DashboardElement)
|
||||
rows := make([]dashv2alpha1.DashboardRowsLayoutRowKind, 0)
|
||||
|
||||
@@ -491,7 +491,7 @@ func convertToRowsLayout(ctx context.Context, panels []interface{}, dsIndexProvi
|
||||
element, name, err := buildElement(ctx, collapsedPanelMap, dsIndexProvider)
|
||||
if err == nil {
|
||||
elements[name] = element
|
||||
rowElements = append(rowElements, buildGridItemKind(collapsedPanelMap, name, int64Ptr(yOffsetInRows(collapsedPanelMap, legacyRowY))))
|
||||
rowElements = append(rowElements, buildGridItemKind(ctx, collapsedPanelMap, name, int64Ptr(yOffsetInRows(collapsedPanelMap, legacyRowY)), leIndexProvider))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -512,7 +512,7 @@ func convertToRowsLayout(ctx context.Context, panels []interface{}, dsIndexProvi
|
||||
if currentRow.Spec.Layout.GridLayoutKind != nil {
|
||||
currentRow.Spec.Layout.GridLayoutKind.Spec.Items = append(
|
||||
currentRow.Spec.Layout.GridLayoutKind.Spec.Items,
|
||||
buildGridItemKind(panelMap, elementName, int64Ptr(yOffsetInRows(panelMap, legacyRowY))),
|
||||
buildGridItemKind(ctx, panelMap, elementName, int64Ptr(yOffsetInRows(panelMap, legacyRowY)), leIndexProvider),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -521,7 +521,7 @@ func convertToRowsLayout(ctx context.Context, panels []interface{}, dsIndexProvi
|
||||
// The Y position does not matter for the rows layout, but it's used to calculate the position of the panels in the grid layout in the row.
|
||||
legacyRowY = -1
|
||||
gridItems := []dashv2alpha1.DashboardGridLayoutItemKind{
|
||||
buildGridItemKind(panelMap, elementName, int64Ptr(0)),
|
||||
buildGridItemKind(ctx, panelMap, elementName, int64Ptr(0), leIndexProvider),
|
||||
}
|
||||
|
||||
hideHeader := true
|
||||
@@ -645,7 +645,7 @@ func buildPanelKind(ctx context.Context, panelMap map[string]interface{}, dsInde
|
||||
return panelKind, nil
|
||||
}
|
||||
|
||||
func buildGridItemKind(panelMap map[string]interface{}, elementName string, yOverride *int64) dashv2alpha1.DashboardGridLayoutItemKind {
|
||||
func buildGridItemKind(ctx context.Context, panelMap map[string]interface{}, elementName string, yOverride *int64, leIndexProvider schemaversion.LibraryElementIndexProvider) dashv2alpha1.DashboardGridLayoutItemKind {
|
||||
// Default grid position (matches frontend PanelModel defaults: w=6, h=3)
|
||||
x, y, width, height := int64(0), int64(0), int64(6), int64(3)
|
||||
|
||||
@@ -677,34 +677,78 @@ func buildGridItemKind(panelMap map[string]interface{}, elementName string, yOve
|
||||
}
|
||||
|
||||
// Handle repeat options
|
||||
if repeat := schemaversion.GetStringValue(panelMap, "repeat"); repeat != "" {
|
||||
repeatOptions := &dashv2alpha1.DashboardRepeatOptions{
|
||||
Mode: "variable",
|
||||
Value: repeat,
|
||||
}
|
||||
// First check if repeat options are set on the panel itself (dashboard instance level)
|
||||
repeatOptions := getRepeatOptionsFromPanel(panelMap)
|
||||
|
||||
if repeatDirection := schemaversion.GetStringValue(panelMap, "repeatDirection"); repeatDirection != "" {
|
||||
switch repeatDirection {
|
||||
case "h":
|
||||
direction := dashv2alpha1.DashboardRepeatOptionsDirectionH
|
||||
repeatOptions.Direction = &direction
|
||||
case "v":
|
||||
direction := dashv2alpha1.DashboardRepeatOptionsDirectionV
|
||||
repeatOptions.Direction = &direction
|
||||
// If no repeat options on the panel and it's a library panel, try to get them from the library panel definition
|
||||
if repeatOptions == nil {
|
||||
if libraryPanel, ok := panelMap["libraryPanel"].(map[string]interface{}); ok {
|
||||
libraryPanelUID := schemaversion.GetStringValue(libraryPanel, "uid")
|
||||
if libraryPanelUID != "" && leIndexProvider != nil {
|
||||
repeatOptions = getRepeatOptionsFromLibraryPanel(ctx, libraryPanelUID, leIndexProvider)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if maxPerRow := getIntField(panelMap, "maxPerRow", 0); maxPerRow > 0 {
|
||||
maxPerRowInt64 := int64(maxPerRow)
|
||||
repeatOptions.MaxPerRow = &maxPerRowInt64
|
||||
}
|
||||
|
||||
if repeatOptions != nil {
|
||||
item.Spec.Repeat = repeatOptions
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
// getRepeatOptionsFromPanel extracts repeat options from a panel map (dashboard instance level)
|
||||
func getRepeatOptionsFromPanel(panelMap map[string]any) *dashv2alpha1.DashboardRepeatOptions {
|
||||
repeat := schemaversion.GetStringValue(panelMap, "repeat")
|
||||
if repeat == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
repeatOptions := &dashv2alpha1.DashboardRepeatOptions{
|
||||
Mode: "variable",
|
||||
Value: repeat,
|
||||
}
|
||||
|
||||
if repeatDirection := schemaversion.GetStringValue(panelMap, "repeatDirection"); repeatDirection != "" {
|
||||
switch repeatDirection {
|
||||
case "h":
|
||||
direction := dashv2alpha1.DashboardRepeatOptionsDirectionH
|
||||
repeatOptions.Direction = &direction
|
||||
case "v":
|
||||
direction := dashv2alpha1.DashboardRepeatOptionsDirectionV
|
||||
repeatOptions.Direction = &direction
|
||||
}
|
||||
}
|
||||
|
||||
if maxPerRow := getIntField(panelMap, "maxPerRow", 0); maxPerRow > 0 {
|
||||
maxPerRowInt64 := int64(maxPerRow)
|
||||
repeatOptions.MaxPerRow = &maxPerRowInt64
|
||||
}
|
||||
|
||||
return repeatOptions
|
||||
}
|
||||
|
||||
// getRepeatOptionsFromLibraryPanel retrieves repeat options from a library panel by UID
|
||||
func getRepeatOptionsFromLibraryPanel(ctx context.Context, libraryPanelUID string, leIndexProvider schemaversion.LibraryElementIndexProvider) *dashv2alpha1.DashboardRepeatOptions {
|
||||
libraryElements := leIndexProvider.GetLibraryElementInfo(ctx)
|
||||
|
||||
// Find the library panel by UID
|
||||
var libraryPanelModel map[string]any
|
||||
for _, elem := range libraryElements {
|
||||
if elem.UID == libraryPanelUID {
|
||||
libraryPanelModel = elem.Model.Object
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if libraryPanelModel == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extract repeat options from the library panel model
|
||||
return getRepeatOptionsFromPanel(libraryPanelModel)
|
||||
}
|
||||
|
||||
func buildRowKind(rowPanelMap map[string]interface{}, elements []dashv2alpha1.DashboardGridLayoutItemKind) *dashv2alpha1.DashboardRowsLayoutRowKind {
|
||||
collapsed := getBoolField(rowPanelMap, "collapsed", false)
|
||||
title := schemaversion.GetStringValue(rowPanelMap, "title")
|
||||
|
||||
@@ -216,3 +216,60 @@ func MigrateDatasourceNameToRef(nameOrRef interface{}, options map[string]bool,
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// cachedLibraryElementProvider wraps a LibraryElementIndexProvider with time-based caching.
|
||||
// This prevents multiple DB queries during operations that may call GetLibraryElementInfo()
|
||||
// multiple times (e.g., dashboard conversions with many library panel lookups).
|
||||
// The cache expires after 10 seconds, allowing it to be used as a long-lived singleton
|
||||
// while still refreshing periodically.
|
||||
//
|
||||
// Thread-safe: Uses sync.RWMutex to guarantee safe concurrent access.
|
||||
type cachedLibraryElementProvider struct {
|
||||
provider LibraryElementIndexProvider
|
||||
mu sync.RWMutex
|
||||
elements []LibraryElementInfo
|
||||
cachedAt time.Time
|
||||
cacheTTL time.Duration
|
||||
}
|
||||
|
||||
// GetLibraryElementInfo returns the cached library elements if they're still valid (< 10s old), otherwise rebuilds the cache.
|
||||
// Uses RWMutex for efficient concurrent reads when cache is valid.
|
||||
func (p *cachedLibraryElementProvider) GetLibraryElementInfo(ctx context.Context) []LibraryElementInfo {
|
||||
// Fast path: check if cache is still valid using read lock
|
||||
p.mu.RLock()
|
||||
if p.elements != nil && time.Since(p.cachedAt) < p.cacheTTL {
|
||||
elements := p.elements
|
||||
p.mu.RUnlock()
|
||||
return elements
|
||||
}
|
||||
p.mu.RUnlock()
|
||||
|
||||
// Slow path: cache expired or not yet built, acquire write lock
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
// Double-check: another goroutine might have refreshed the cache
|
||||
// while we were waiting for the write lock
|
||||
if p.elements != nil && time.Since(p.cachedAt) < p.cacheTTL {
|
||||
return p.elements
|
||||
}
|
||||
|
||||
// Rebuild the cache
|
||||
p.elements = p.provider.GetLibraryElementInfo(ctx)
|
||||
p.cachedAt = time.Now()
|
||||
return p.elements
|
||||
}
|
||||
|
||||
// WrapLibraryElementProviderWithCache wraps a provider to cache library elements with a 10-second TTL.
|
||||
// Useful for conversions or migrations that may call GetLibraryElementInfo() multiple times.
|
||||
// The cache expires after 10 seconds, making it suitable for use as a long-lived singleton
|
||||
// at the top level of dependency injection while still refreshing periodically.
|
||||
func WrapLibraryElementProviderWithCache(provider LibraryElementIndexProvider) LibraryElementIndexProvider {
|
||||
if provider == nil {
|
||||
return nil
|
||||
}
|
||||
return &cachedLibraryElementProvider{
|
||||
provider: provider,
|
||||
cacheTTL: 10 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package schemaversion
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -34,6 +36,7 @@ type LibraryElementInfo struct {
|
||||
Type string
|
||||
Description string
|
||||
FolderUID string
|
||||
Model common.Unstructured // JSON model of the library element, used to extract repeat options during migration
|
||||
}
|
||||
|
||||
type LibraryElementIndexProvider interface {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
|
||||
)
|
||||
|
||||
// EmptyLibraryElementProvider provides an empty library element list for tests
|
||||
@@ -187,3 +188,101 @@ func (p *ConfigurableDataSourceProvider) getDevDashboardDataSources() []schemave
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestLibraryElementProvider provides library elements with models for testing repeat options migration
|
||||
type TestLibraryElementProvider struct {
|
||||
elements []schemaversion.LibraryElementInfo
|
||||
}
|
||||
|
||||
// NewTestLibraryElementProvider creates a new test library element provider with sample library panels
|
||||
func NewTestLibraryElementProvider() *TestLibraryElementProvider {
|
||||
// Create library panel models with repeat options
|
||||
libPanelWithRepeatH := map[string]any{
|
||||
"id": 1,
|
||||
"type": "timeseries",
|
||||
"title": "Library Panel with Horizontal Repeat",
|
||||
"repeat": "server",
|
||||
"repeatDirection": "h",
|
||||
"maxPerRow": 3,
|
||||
"gridPos": map[string]any{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 12,
|
||||
"h": 8,
|
||||
},
|
||||
"targets": []any{},
|
||||
"options": map[string]any{},
|
||||
}
|
||||
|
||||
libPanelWithRepeatV := map[string]any{
|
||||
"id": 2,
|
||||
"type": "stat",
|
||||
"title": "Library Panel with Vertical Repeat",
|
||||
"repeat": "datacenter",
|
||||
"repeatDirection": "v",
|
||||
"gridPos": map[string]any{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 6,
|
||||
"h": 4,
|
||||
},
|
||||
"targets": []any{},
|
||||
"options": map[string]any{},
|
||||
}
|
||||
|
||||
libPanelWithoutRepeat := map[string]any{
|
||||
"id": 3,
|
||||
"type": "text",
|
||||
"title": "Library Panel without Repeat",
|
||||
"gridPos": map[string]any{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 6,
|
||||
"h": 3,
|
||||
},
|
||||
"targets": []any{},
|
||||
"options": map[string]any{},
|
||||
}
|
||||
|
||||
// Convert models to Unstructured
|
||||
modelWithRepeatH := v0alpha1.Unstructured{Object: libPanelWithRepeatH}
|
||||
modelWithRepeatV := v0alpha1.Unstructured{Object: libPanelWithRepeatV}
|
||||
modelWithoutRepeat := v0alpha1.Unstructured{Object: libPanelWithoutRepeat}
|
||||
|
||||
return &TestLibraryElementProvider{
|
||||
elements: []schemaversion.LibraryElementInfo{
|
||||
{
|
||||
UID: "lib-panel-repeat-h",
|
||||
Name: "Library Panel with Horizontal Repeat",
|
||||
Kind: 1, // Panel element
|
||||
Type: "timeseries",
|
||||
Description: "A library panel with horizontal repeat options",
|
||||
FolderUID: "",
|
||||
Model: modelWithRepeatH,
|
||||
},
|
||||
{
|
||||
UID: "lib-panel-repeat-v",
|
||||
Name: "Library Panel with Vertical Repeat",
|
||||
Kind: 1, // Panel element
|
||||
Type: "stat",
|
||||
Description: "A library panel with vertical repeat options",
|
||||
FolderUID: "",
|
||||
Model: modelWithRepeatV,
|
||||
},
|
||||
{
|
||||
UID: "lib-panel-no-repeat",
|
||||
Name: "Library Panel without Repeat",
|
||||
Kind: 1, // Panel element
|
||||
Type: "text",
|
||||
Description: "A library panel without repeat options",
|
||||
FolderUID: "",
|
||||
Model: modelWithoutRepeat,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetLibraryElementInfo returns the test library elements
|
||||
func (p *TestLibraryElementProvider) GetLibraryElementInfo(_ context.Context) []schemaversion.LibraryElementInfo {
|
||||
return p.elements
|
||||
}
|
||||
|
||||
@@ -29,13 +29,6 @@ SecureValueSpec: {
|
||||
// +optional
|
||||
ref?: string & strings.MinRunes(1) & strings.MaxRunes(1024)
|
||||
|
||||
// Name of the keeper, being the actual storage of the secure value.
|
||||
// If not specified, the default keeper for the namespace will be used.
|
||||
// +k8s:validation:minLength=1
|
||||
// +k8s:validation:maxLength=253
|
||||
// +optional
|
||||
keeper?: string & strings.MinRunes(1) & strings.MaxRunes(253)
|
||||
|
||||
// The Decrypters that are allowed to decrypt this secret.
|
||||
// An empty list means no service can decrypt it.
|
||||
// +k8s:validation:maxItems=64
|
||||
@@ -53,4 +46,7 @@ SecureValueStatus: {
|
||||
// External ID where the secret is stored. Cannot be set.
|
||||
// +optional
|
||||
externalID: string
|
||||
|
||||
// The name of the keeper used to create the secure value. Cannot be set.
|
||||
keeper: string
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ var SecureValuesResourceInfo = utils.NewResourceInfo(
|
||||
},
|
||||
Reader: func(obj any) ([]any, error) {
|
||||
if r, ok := obj.(*SecureValue); ok {
|
||||
return []any{r.Name, r.Spec.Description, r.Spec.Keeper, r.Spec.Ref}, nil
|
||||
return []any{r.Name, r.Spec.Description, r.Status.Keeper, r.Spec.Ref}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("expected SecureValue but got %T", obj)
|
||||
|
||||
@@ -25,12 +25,6 @@ type SecureValueSpec struct {
|
||||
// +k8s:validation:maxLength=1024
|
||||
// +optional
|
||||
Ref *string `json:"ref,omitempty"`
|
||||
// Name of the keeper, being the actual storage of the secure value.
|
||||
// If not specified, the default keeper for the namespace will be used.
|
||||
// +k8s:validation:minLength=1
|
||||
// +k8s:validation:maxLength=253
|
||||
// +optional
|
||||
Keeper *string `json:"keeper,omitempty"`
|
||||
// The Decrypters that are allowed to decrypt this secret.
|
||||
// An empty list means no service can decrypt it.
|
||||
// +k8s:validation:maxItems=64
|
||||
|
||||
@@ -25,12 +25,14 @@ type SecureValueStatus struct {
|
||||
// Version of the secure value. Cannot be set.
|
||||
// +optional
|
||||
Version int64 `json:"version"`
|
||||
// operatorStates is a map of operator ID to operator state evaluations.
|
||||
// Any operator which consumes this kind SHOULD add its state evaluation information to this field.
|
||||
OperatorStates map[string]SecureValuestatusOperatorState `json:"operatorStates,omitempty"`
|
||||
// External ID where the secret is stored. Cannot be set.
|
||||
// +optional
|
||||
ExternalID string `json:"externalID"`
|
||||
// operatorStates is a map of operator ID to operator state evaluations.
|
||||
// Any operator which consumes this kind SHOULD add its state evaluation information to this field.
|
||||
OperatorStates map[string]SecureValuestatusOperatorState `json:"operatorStates,omitempty"`
|
||||
// The name of the keeper used to create the secure value. Cannot be set.
|
||||
Keeper string `json:"keeper"`
|
||||
// additionalFields is reserved for future use
|
||||
AdditionalFields map[string]interface{} `json:"additionalFields,omitempty"`
|
||||
}
|
||||
|
||||
+11
-11
@@ -587,15 +587,6 @@ func schema_pkg_apis_secret_v1beta1_SecureValueSpec(ref common.ReferenceCallback
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"keeper": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "Name of the keeper, being the actual storage of the secure value. If not specified, the default keeper for the namespace will be used.",
|
||||
MinLength: ptr.To[int64](1),
|
||||
MaxLength: ptr.To[int64](253),
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"decrypters": {
|
||||
VendorExtensible: spec.VendorExtensible{
|
||||
Extensions: spec.Extensions{
|
||||
@@ -639,6 +630,14 @@ func schema_pkg_apis_secret_v1beta1_SecureValueStatus(ref common.ReferenceCallba
|
||||
Format: "int64",
|
||||
},
|
||||
},
|
||||
"externalID": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "External ID where the secret is stored. Cannot be set.",
|
||||
Default: "",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"operatorStates": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "operatorStates is a map of operator ID to operator state evaluations. Any operator which consumes this kind SHOULD add its state evaluation information to this field.",
|
||||
@@ -654,9 +653,9 @@ func schema_pkg_apis_secret_v1beta1_SecureValueStatus(ref common.ReferenceCallba
|
||||
},
|
||||
},
|
||||
},
|
||||
"externalID": {
|
||||
"keeper": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Description: "External ID where the secret is stored. Cannot be set.",
|
||||
Description: "The name of the keeper used to create the secure value. Cannot be set.",
|
||||
Default: "",
|
||||
Type: []string{"string"},
|
||||
Format: "",
|
||||
@@ -678,6 +677,7 @@ func schema_pkg_apis_secret_v1beta1_SecureValueStatus(ref common.ReferenceCallba
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"keeper"},
|
||||
},
|
||||
},
|
||||
Dependencies: []string{
|
||||
|
||||
@@ -2179,6 +2179,8 @@ enabled = true
|
||||
|
||||
###################################### Cloud Migration ######################################
|
||||
[cloud_migration]
|
||||
# Set to false to disable the Cloud Migration feature
|
||||
enabled = true
|
||||
# Set to true to enable target-side migration UI
|
||||
is_target = false
|
||||
# Token used to send requests to grafana com
|
||||
|
||||
@@ -2071,6 +2071,8 @@ default_datasource_uid =
|
||||
|
||||
###################################### Cloud Migration ######################################
|
||||
[cloud_migration]
|
||||
# Set to false to disable the Cloud Migration feature
|
||||
;enabled = true
|
||||
# Set to true to enable target-side migration UI
|
||||
;is_target = false
|
||||
# Token used to send requests to grafana com
|
||||
|
||||
+12
-11
@@ -29,7 +29,7 @@ refs:
|
||||
- pattern: /docs/grafana/
|
||||
destination: docs/grafana/<GRAFANA_VERSION>/administration/roles-and-permissions/access-control/custom-role-actions-scopes/#cloud-access-policies-action-definitions
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/roles-and-permissions/access-control/custom-role-actions-scopes/#cloud-access-policies-action-definitions
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/roles-and-permissions/access-control/custom-role-actions-scopes/#create-access-policies
|
||||
rbac-role-definitions:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/
|
||||
@@ -66,16 +66,17 @@ Please refer to plugin documentation to see what RBAC permissions the plugin has
|
||||
|
||||
The following list contains app plugins that have fine-grained RBAC support.
|
||||
|
||||
| App plugin | App plugin ID | App plugin permission documentation |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [Access policies](https://grafana.com/docs/grafana-cloud/account-management/authentication-and-permissions/access-policies/) | `grafana-auth-app` | [RBAC actions for Access Policies](ref:cloud-access-policies-action-definitions) |
|
||||
| [Adaptive metrics](https://grafana.com/docs/grafana-cloud/cost-management-and-billing/reduce-costs/metrics-costs/control-metrics-usage-via-adaptive-metrics/adaptive-metrics-plugin/) | `grafana-adaptive-metrics-app` | [RBAC actions for Adaptive Metrics](ref:adaptive-metrics-permissions) |
|
||||
| [Incident](https://grafana.com/docs/grafana-cloud/alerting-and-irm/irm/incident/) | `grafana-incident-app` | n/a |
|
||||
| [OnCall](https://grafana.com/docs/grafana-cloud/alerting-and-irm/irm/oncall/) | `grafana-oncall-app` | [Configure RBAC for OnCall](https://grafana.com/docs/grafana-cloud/alerting-and-irm/irm/oncall/manage/user-and-team-management/#manage-users-and-teams-for-grafana-oncall) |
|
||||
| [Performance Testing (K6)](https://grafana.com/docs/grafana-cloud/testing/k6/) | `k6-app` | [Configure RBAC for K6](https://grafana.com/docs/grafana-cloud/testing/k6/projects-and-users/configure-rbac/) |
|
||||
| [Private data source connect (PDC)](https://grafana.com/docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/) | `grafana-pdc-app` | n/a |
|
||||
| [Service Level Objective (SLO)](https://grafana.com/docs/grafana-cloud/alerting-and-irm/slo/) | `grafana-slo-app` | [Configure RBAC for SLO](https://grafana.com/docs/grafana-cloud/alerting-and-irm/slo/set-up/rbac/) |
|
||||
| [Cloud Provider](https://grafana.com/docs/grafana-cloud/monitor-infrastructure/monitor-cloud-provider/) | `grafana-csp-app` | [Cloud Provider Observability role-based access control](https://grafana.com/docs/grafana-cloud/monitor-infrastructure/monitor-cloud-provider/rbac/) |
|
||||
| App plugin | App plugin ID | App plugin permission documentation |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| [Access policies](https://grafana.com/docs/grafana-cloud/account-management/authentication-and-permissions/access-policies/) | `grafana-auth-app` | [RBAC actions for Access Policies](ref:cloud-access-policies-action-definitions) |
|
||||
| [Adaptive Metrics](https://grafana.com/docs/grafana-cloud/cost-management-and-billing/reduce-costs/metrics-costs/control-metrics-usage-via-adaptive-metrics/adaptive-metrics-plugin/) | `grafana-adaptive-metrics-app` | [RBAC actions for Adaptive Metrics](ref:adaptive-metrics-permissions) |
|
||||
| [Cloud Provider](https://grafana.com/docs/grafana-cloud/monitor-infrastructure/monitor-cloud-provider/) | `grafana-csp-app` | [Cloud Provider Observability role-based access control](https://grafana.com/docs/grafana-cloud/monitor-infrastructure/monitor-cloud-provider/rbac/) |
|
||||
| [Incident](https://grafana.com/docs/grafana-cloud/alerting-and-irm/irm/incident/) | `grafana-incident-app` | n/a |
|
||||
| [Kubernetes Monitoring](/docs/grafana-cloud/monitor-infrastructure/kubernetes-monitoring/) | `grafana-k8s-app` | [Kubernetes Monitoring role-based access control](/docs/grafana-cloud/monitor-infrastructure/kubernetes-monitoring/configuration/control-access/#precision-access-with-rbac-custom-plugin-roles) |
|
||||
| [OnCall](https://grafana.com/docs/grafana-cloud/alerting-and-irm/irm/oncall/) | `grafana-oncall-app` | [Configure RBAC for OnCall](https://grafana.com/docs/grafana-cloud/alerting-and-irm/irm/oncall/manage/user-and-team-management/#manage-users-and-teams-for-grafana-oncall) |
|
||||
| [Performance Testing (K6)](https://grafana.com/docs/grafana-cloud/testing/k6/) | `k6-app` | [Configure RBAC for K6](https://grafana.com/docs/grafana-cloud/testing/k6/projects-and-users/configure-rbac/) |
|
||||
| [Private data source connect (PDC)](https://grafana.com/docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/) | `grafana-pdc-app` | n/a |
|
||||
| [Service Level Objective (SLO)](https://grafana.com/docs/grafana-cloud/alerting-and-irm/slo/) | `grafana-slo-app` | [Configure RBAC for SLO](https://grafana.com/docs/grafana-cloud/alerting-and-irm/slo/set-up/rbac/) |
|
||||
|
||||
### Revoke fine-grained access from app plugins
|
||||
|
||||
|
||||
@@ -73,6 +73,37 @@ To access the History page, complete the following steps.
|
||||
|
||||
{{< figure src="/media/docs/alerting/alerting-alert-history-tab.png" max-width="750px" alt="Alert History tab in Grafana Alerting" >}}
|
||||
|
||||
## Use Grafana Assistant to analyze alert state history
|
||||
|
||||
{{< admonition type="note" >}}
|
||||
This feature is available in Grafana Cloud when Grafana Assistant is enabled.
|
||||
{{< /admonition >}}
|
||||
|
||||
The **Analyze with Assistant** button provides AI-powered analysis of your alert history to help you understand and troubleshoot alert patterns. Located in the top-right corner of the History page event list, this button uses Grafana Assistant to analyze the events displayed in your current view.
|
||||
|
||||
When you click the AI Triage button, the Grafana Assistant analyzes:
|
||||
|
||||
- Alert state transitions over the selected time range
|
||||
- Alert instance patterns and frequency
|
||||
- Common labels and characteristics of firing alerts
|
||||
- Temporal patterns in alert behavior
|
||||
|
||||
The AI assistant can help you:
|
||||
|
||||
- Identify root causes of alert storms
|
||||
- Detect patterns in alert firing behavior
|
||||
- Understand correlations between different alert instances
|
||||
- Get suggestions for improving alert configurations
|
||||
|
||||
To use the Analyze with Assistant feature:
|
||||
|
||||
1. Navigate to the History page as described above.
|
||||
2. Filter the events to focus on the alerts you want to analyze using labels, states, or time range.
|
||||
3. Click the **Analyze with Assistant** button in the top-right corner of the event list.
|
||||
4. Review the AI-generated analysis and recommendations.
|
||||
|
||||
The AI analysis is based on the currently displayed events, so filtering your view to specific alerts or time periods will result in more focused insights.
|
||||
|
||||
## View from the State history view
|
||||
|
||||
Use the State history view to get insight into how your individual alert instances behave over time.
|
||||
|
||||
@@ -62,9 +62,9 @@ refs:
|
||||
destination: /docs/grafana/<GRAFANA_VERSION>/datasources/aws-CloudWatch/aws-authentication/
|
||||
private-data-source-connect:
|
||||
- pattern: /docs/grafana/
|
||||
destination: docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/
|
||||
destination: /docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/
|
||||
- pattern: /docs/grafana-cloud/
|
||||
destination: docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/
|
||||
destination: /docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/
|
||||
configure-pdc:
|
||||
- pattern: /docs/grafana/
|
||||
destination: /docs/grafana-cloud/connect-externally-hosted/private-data-source-connect/configure-pdc/#configure-grafana-private-data-source-connect-pdc
|
||||
|
||||
@@ -41,9 +41,7 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
|
||||
| `dashboardSceneForViewers` | Enables dashboard rendering using Scenes for viewer roles | Yes |
|
||||
| `dashboardSceneSolo` | Enables rendering dashboards using scenes for solo panels | Yes |
|
||||
| `dashboardScene` | Enables dashboard rendering using scenes for all roles | Yes |
|
||||
| `logsInfiniteScrolling` | Enables infinite scrolling for the Logs panel in Explore and Dashboards | Yes |
|
||||
| `alertingQueryOptimization` | Optimizes eligible queries in order to reduce load on datasources | |
|
||||
| `onPremToCloudMigrations` | Enable the Grafana Migration Assistant, which helps you easily migrate various on-prem resources to your Grafana Cloud stack. | Yes |
|
||||
| `cloudWatchNewLabelParsing` | Updates CloudWatch label parsing to be more accurate | Yes |
|
||||
| `pluginProxyPreserveTrailingSlash` | Preserve plugin proxy trailing slash. | |
|
||||
| `azureMonitorPrometheusExemplars` | Allows configuration of Azure Monitor as a data source that can provide Prometheus exemplars | Yes |
|
||||
@@ -90,6 +88,7 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
|
||||
| `alertingSaveStateCompressed` | Enables the compressed protobuf-based alert state storage. Default is enabled. |
|
||||
| `sqlExpressions` | Enables SQL Expressions, which can execute SQL queries against data source results. |
|
||||
| `queryLibrary` | Enables Saved queries (query library) feature |
|
||||
| `dashboardTemplates` | Enables a flow to get started with a new dashboard from a template |
|
||||
| `enableSCIM` | Enables SCIM support for user and group management |
|
||||
| `alertRuleRestore` | Enables the alert rule restore feature |
|
||||
| `azureMonitorLogsBuilderEditor` | Enables the logs builder mode for the Azure Monitor data source |
|
||||
|
||||
+1
@@ -1,5 +1,6 @@
|
||||
---
|
||||
aliases:
|
||||
- ../../panels-visualizations/query-transform-data/sql-expressions/ # /docs/grafana/next/panels-visualizations/query-transform-data/sql-expressions/
|
||||
labels:
|
||||
products:
|
||||
- cloud
|
||||
|
||||
@@ -7,13 +7,13 @@ test.describe(
|
||||
tag: ['@acceptance'],
|
||||
},
|
||||
() => {
|
||||
test.skip('Tests each panel type in the panel edit view to ensure no crash', async ({
|
||||
test('Tests each panel type in the panel edit view to ensure no crash', async ({
|
||||
gotoDashboardPage,
|
||||
selectors,
|
||||
page,
|
||||
}) => {
|
||||
// this test can absolutely take longer than the default 30s timeout
|
||||
test.setTimeout(60000);
|
||||
test.setTimeout(120000);
|
||||
|
||||
// Create new dashboard
|
||||
const dashboardPage = await gotoDashboardPage({});
|
||||
@@ -29,22 +29,32 @@ test.describe(
|
||||
return win.grafanaBootData?.settings?.panels ?? {};
|
||||
});
|
||||
|
||||
const vizPicker = dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.toggleVizPicker);
|
||||
|
||||
// Loop through every panel type and ensure no crash
|
||||
for (const [_, panel] of Object.entries(panelTypes)) {
|
||||
if (panel.hideFromList || panel.state === 'deprecated') {
|
||||
continue; // Skip hidden and deprecated panels
|
||||
}
|
||||
|
||||
// Select the panel type in the viz picker
|
||||
const vizPicker = dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.toggleVizPicker);
|
||||
await vizPicker.click();
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.PluginVisualization.item(panel.name)).click();
|
||||
try {
|
||||
// Select the panel type in the viz picker
|
||||
await expect(vizPicker).toBeVisible();
|
||||
await vizPicker.click({ force: true });
|
||||
|
||||
// Verify panel type is selected
|
||||
await expect(vizPicker).toHaveText(panel.name);
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.PluginVisualization.item(panel.name)).click();
|
||||
|
||||
// Ensure no unexpected error occurred
|
||||
await expect(page.getByText('An unexpected error happened')).toBeHidden();
|
||||
// Verify panel type is selected
|
||||
await expect(vizPicker).toHaveText(panel.name, { timeout: 10000 });
|
||||
|
||||
// Wait for panel to finish rendering
|
||||
await expect(page.getByLabel('Panel loading bar')).toHaveCount(0, { timeout: 10000 });
|
||||
|
||||
// Ensure no unexpected error occurred
|
||||
await expect(page.getByText('An unexpected error happened')).toBeHidden();
|
||||
} catch (error) {
|
||||
throw new Error(`Panel '${panel.name}' failed: ${error}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3706,16 +3706,6 @@
|
||||
"count": 7
|
||||
}
|
||||
},
|
||||
"public/app/plugins/datasource/azuremonitor/types/query.ts": {
|
||||
"no-barrel-files/no-barrel-files": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"public/app/plugins/datasource/azuremonitor/types/templateVariables.ts": {
|
||||
"no-barrel-files/no-barrel-files": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/plugins/datasource/azuremonitor/utils/common.ts": {
|
||||
"@typescript-eslint/consistent-type-assertions": {
|
||||
"count": 1
|
||||
|
||||
@@ -52,6 +52,7 @@ require (
|
||||
github.com/crewjam/saml v0.4.14 // @grafana/identity-access-team
|
||||
github.com/dgraph-io/badger/v4 v4.7.0 // @grafana/grafana-search-and-storage
|
||||
github.com/dlmiddlecote/sqlstats v1.0.2 // @grafana/grafana-backend-group
|
||||
github.com/docker/go-connections v0.6.0 // @grafana/grafana-app-platform-squad
|
||||
github.com/dolthub/go-mysql-server v0.19.1-0.20250410182021-5632d67cd46e // @grafana/grafana-datasources-core-services
|
||||
github.com/dolthub/vitess v0.0.0-20250930230441-70c2c6a98e33 // @grafana/grafana-datasources-core-services
|
||||
github.com/dustin/go-humanize v1.0.1 // @grafana/observability-traces-and-profiling
|
||||
@@ -138,7 +139,6 @@ require (
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.4 // @grafana/alerting-backend
|
||||
github.com/microsoft/go-mssqldb v1.9.2 // @grafana/partner-datasources
|
||||
github.com/migueleliasweb/go-github-mock v1.1.0 // @grafana/grafana-git-ui-sync-team
|
||||
github.com/mitchellh/copystructure v1.2.0 // @grafana/grafana-operator-experience-squad
|
||||
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c //@grafana/identity-access-team
|
||||
github.com/mocktools/go-smtp-mock/v2 v2.5.1 // @grafana/grafana-backend-group
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // @grafana/alerting-backend
|
||||
@@ -197,7 +197,6 @@ require (
|
||||
go.uber.org/goleak v1.3.0 // @grafana/grafana-search-and-storage
|
||||
go.uber.org/mock v0.6.0 // @grafana/grafana-operator-experience-squad
|
||||
go.uber.org/zap v1.27.0 // @grafana/identity-access-team
|
||||
go.yaml.in/yaml/v2 v2.4.3 // @grafana/alerting-backend
|
||||
go.yaml.in/yaml/v3 v3.0.4 // @grafana/alerting-backend
|
||||
gocloud.dev v0.43.0 // @grafana/grafana-app-platform-squad
|
||||
gocloud.dev/secrets/hashivault v0.43.0 // @grafana/grafana-operator-experience-squad
|
||||
@@ -405,7 +404,6 @@ require (
|
||||
github.com/diegoholiveira/jsonlogic/v3 v3.7.4 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/docker v28.4.0+incompatible // indirect
|
||||
github.com/docker/go-connections v0.6.0 // indirect; @grafana/grafana-app-platform-squad
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/dolthub/flatbuffers/v23 v23.3.3-dh.2 // indirect
|
||||
github.com/dolthub/jsonpath v0.0.2-0.20240227200619-19675ab05c71 // indirect
|
||||
@@ -515,6 +513,7 @@ require (
|
||||
github.com/miekg/dns v1.1.63 // indirect
|
||||
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 // indirect
|
||||
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 // indirect
|
||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
|
||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||
@@ -628,6 +627,7 @@ require (
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.7.1 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
go4.org/netipx v0.0.0-20230125063823-8449b0a6169f // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54 // indirect
|
||||
|
||||
+2
-2
@@ -296,8 +296,8 @@
|
||||
"@grafana/plugin-ui": "^0.11.1",
|
||||
"@grafana/prometheus": "workspace:*",
|
||||
"@grafana/runtime": "workspace:*",
|
||||
"@grafana/scenes": "^6.46.0",
|
||||
"@grafana/scenes-react": "^6.46.0",
|
||||
"@grafana/scenes": "6.47.1",
|
||||
"@grafana/scenes-react": "6.47.1",
|
||||
"@grafana/schema": "workspace:*",
|
||||
"@grafana/sql": "workspace:*",
|
||||
"@grafana/ui": "workspace:*",
|
||||
|
||||
@@ -306,6 +306,7 @@ export interface GrafanaConfig {
|
||||
sharedWithMeFolderUID: string;
|
||||
rootFolderUID: string;
|
||||
localFileSystemAvailable: boolean;
|
||||
cloudMigrationEnabled: boolean;
|
||||
cloudMigrationIsTarget: boolean;
|
||||
cloudMigrationPollIntervalMs: number;
|
||||
pluginCatalogURL: string;
|
||||
|
||||
@@ -646,7 +646,7 @@ export interface MetricFindValue {
|
||||
}
|
||||
|
||||
export interface DataSourceGetDrilldownsApplicabilityOptions<TQuery extends DataQuery = DataQuery> {
|
||||
filters: AdHocVariableFilter[];
|
||||
filters?: AdHocVariableFilter[];
|
||||
groupByKeys?: string[];
|
||||
timeRange?: TimeRange;
|
||||
queries?: TQuery[];
|
||||
|
||||
+23
-21
@@ -329,6 +329,10 @@ export interface FeatureToggles {
|
||||
*/
|
||||
alertingUIUseBackendFilters?: boolean;
|
||||
/**
|
||||
* Enables the UI to use rules backend-side filters 100% compatible with the frontend filters
|
||||
*/
|
||||
alertingUIUseFullyCompatBackendFilters?: boolean;
|
||||
/**
|
||||
* Enable Grafana to have a remote Alertmanager instance as the primary Alertmanager.
|
||||
*/
|
||||
alertmanagerRemotePrimary?: boolean;
|
||||
@@ -365,6 +369,10 @@ export interface FeatureToggles {
|
||||
*/
|
||||
unlimitedLayoutsNesting?: boolean;
|
||||
/**
|
||||
* Enables viewing non-applicable drilldowns on a panel level
|
||||
*/
|
||||
perPanelNonApplicableDrilldowns?: boolean;
|
||||
/**
|
||||
* Enables use of the `systemPanelFilterVar` variable to filter panels in a dashboard
|
||||
*/
|
||||
panelFilterVariable?: boolean;
|
||||
@@ -381,11 +389,6 @@ export interface FeatureToggles {
|
||||
*/
|
||||
timeComparison?: boolean;
|
||||
/**
|
||||
* Enables infinite scrolling for the Logs panel in Explore and Dashboards
|
||||
* @default true
|
||||
*/
|
||||
logsInfiniteScrolling?: boolean;
|
||||
/**
|
||||
* Enables shared crosshair in table panel
|
||||
*/
|
||||
tableSharedCrosshair?: boolean;
|
||||
@@ -407,11 +410,6 @@ export interface FeatureToggles {
|
||||
*/
|
||||
jitterAlertRulesWithinGroups?: boolean;
|
||||
/**
|
||||
* Enable the Grafana Migration Assistant, which helps you easily migrate various on-prem resources to your Grafana Cloud stack.
|
||||
* @default true
|
||||
*/
|
||||
onPremToCloudMigrations?: boolean;
|
||||
/**
|
||||
* Enable the secrets management API and services under app platform
|
||||
*/
|
||||
secretsManagementAppPlatform?: boolean;
|
||||
@@ -502,14 +500,18 @@ export interface FeatureToggles {
|
||||
*/
|
||||
queryLibrary?: boolean;
|
||||
/**
|
||||
* Enable dashboard library experiments that are production ready
|
||||
* Displays datasource provisioned dashboards in dashboard empty page, only when coming from datasource configuration page
|
||||
*/
|
||||
dashboardLibrary?: boolean;
|
||||
/**
|
||||
* Enable suggested dashboards when creating new dashboards
|
||||
* Displays datasource provisioned and community dashboards in dashboard empty page, only when coming from datasource configuration page
|
||||
*/
|
||||
suggestedDashboards?: boolean;
|
||||
/**
|
||||
* Enables a flow to get started with a new dashboard from a template
|
||||
*/
|
||||
dashboardTemplates?: boolean;
|
||||
/**
|
||||
* Sets the logs table as default visualisation in logs explore
|
||||
*/
|
||||
logsExploreTableDefaultVisualization?: boolean;
|
||||
@@ -597,7 +599,7 @@ export interface FeatureToggles {
|
||||
*/
|
||||
alertingPrometheusRulesPrimary?: boolean;
|
||||
/**
|
||||
* Used in Logs Drilldown to split queries into multiple queries based on the number of shards
|
||||
* Deprecated. Replace with lokiShardSplitting. Used in Logs Drilldown to split queries into multiple queries based on the number of shards
|
||||
*/
|
||||
exploreLogsShardSplitting?: boolean;
|
||||
/**
|
||||
@@ -665,6 +667,10 @@ export interface FeatureToggles {
|
||||
*/
|
||||
timeRangePan?: boolean;
|
||||
/**
|
||||
* Enables new keyboard shortcuts for time range zoom operations
|
||||
*/
|
||||
newTimeRangeZoomShortcuts?: boolean;
|
||||
/**
|
||||
* Disables the log limit restriction for Azure Monitor when true. The limit is enabled by default.
|
||||
* @default false
|
||||
*/
|
||||
@@ -915,10 +921,6 @@ export interface FeatureToggles {
|
||||
*/
|
||||
grafanaAssistantInProfilesDrilldown?: boolean;
|
||||
/**
|
||||
* Enables using PGX instead of libpq for PostgreSQL datasource
|
||||
*/
|
||||
postgresDSUsePGX?: boolean;
|
||||
/**
|
||||
* Enables creating alerts from Tempo data source
|
||||
*/
|
||||
tempoAlerting?: boolean;
|
||||
@@ -1157,10 +1159,6 @@ export interface FeatureToggles {
|
||||
*/
|
||||
panelTimeSettings?: boolean;
|
||||
/**
|
||||
* Enable template dashboards
|
||||
*/
|
||||
dashboardTemplates?: boolean;
|
||||
/**
|
||||
* Enables app platform API for annotations
|
||||
* @default false
|
||||
*/
|
||||
@@ -1179,6 +1177,10 @@ export interface FeatureToggles {
|
||||
*/
|
||||
ttlPluginInstanceManager?: boolean;
|
||||
/**
|
||||
* Send X-Loki-Query-Limits-Context header to Loki on first split request
|
||||
*/
|
||||
lokiQueryLimitsContext?: boolean;
|
||||
/**
|
||||
* Enables the new version of rudderstack
|
||||
* @default false
|
||||
*/
|
||||
|
||||
@@ -426,6 +426,9 @@ export const versionedComponents = {
|
||||
loadingBar: {
|
||||
'10.0.0': () => `Panel loading bar`,
|
||||
},
|
||||
PanelNonApplicableDrilldownsSubHeader: {
|
||||
'12.4.0': 'Panel non-applicable drilldowns subheader',
|
||||
},
|
||||
HoverWidget: {
|
||||
container: {
|
||||
'10.1.0': 'data-testid hover-header-container',
|
||||
|
||||
@@ -241,6 +241,7 @@ export class GrafanaBootConfig {
|
||||
sharedWithMeFolderUID?: string;
|
||||
rootFolderUID?: string;
|
||||
localFileSystemAvailable?: boolean;
|
||||
cloudMigrationEnabled?: boolean;
|
||||
cloudMigrationIsTarget?: boolean;
|
||||
cloudMigrationPollIntervalMs = 2000;
|
||||
reportingStaticContext?: Record<string, string>;
|
||||
|
||||
+8
@@ -50,6 +50,14 @@ export interface LokiDataQuery extends common.DataQuery {
|
||||
* Used to override the name of the series.
|
||||
*/
|
||||
legendFormat?: string;
|
||||
/**
|
||||
* The full query plan for split/shard queries. Encoded and sent to Loki via `X-Loki-Query-Limits-Context` header. Requires "lokiQueryLimitsContext" feature flag
|
||||
*/
|
||||
limitsContext?: {
|
||||
expr: string;
|
||||
from: number;
|
||||
to: number;
|
||||
};
|
||||
/**
|
||||
* Used to limit the number of log rows returned.
|
||||
*/
|
||||
|
||||
@@ -421,6 +421,70 @@ Component used for rendering content wrapped in the same style as grafana panels
|
||||
</PanelChrome>
|
||||
</ExampleFrame>
|
||||
|
||||
### Sub-header content
|
||||
|
||||
The panel supports displaying additional content below the main header using the `subHeaderContent` prop. This can be used to show context-specific information.
|
||||
|
||||
```tsx
|
||||
<PanelChrome
|
||||
title="My awesome panel title"
|
||||
subHeaderContent={
|
||||
<div style={{ display: 'flex', gap: '8px', padding: '4px 8px' }}>
|
||||
<span style={{ fontSize: '12px', color: '#999' }}>Additional info</span>
|
||||
</div>
|
||||
}
|
||||
width={400}
|
||||
height={200}
|
||||
>
|
||||
{(innerwidth, innerheight) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: innerwidth,
|
||||
height: innerheight,
|
||||
background: 'rgba(230,0,0,0.05)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
Content
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</PanelChrome>
|
||||
```
|
||||
|
||||
<ExampleFrame>
|
||||
<PanelChrome
|
||||
title="My awesome panel title"
|
||||
subHeaderContent={
|
||||
<div style={{ display: 'flex', gap: '8px', padding: '4px 8px' }}>
|
||||
<span style={{ fontSize: '12px', color: '#999' }}>Additional info</span>
|
||||
</div>
|
||||
}
|
||||
width={400}
|
||||
height={200}
|
||||
>
|
||||
{(innerwidth, innerheight) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: innerwidth,
|
||||
height: innerheight,
|
||||
background: 'rgba(230,0,0,0.05)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
Content
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</PanelChrome>
|
||||
</ExampleFrame>
|
||||
|
||||
### Collapsible
|
||||
|
||||
The panel can be collapsed/expanded by clicking on the chevron or the title.
|
||||
|
||||
@@ -196,3 +196,30 @@ it('collapses the uncontrolled panel when user clicks on the chevron or the titl
|
||||
expect(button).not.toHaveAttribute('aria-controlls');
|
||||
expect(screen.queryByTestId(selectors.components.Panels.Panel.content)?.id).toBe(undefined);
|
||||
});
|
||||
|
||||
it('renders panel with a header if prop subHeaderContent', () => {
|
||||
setup({
|
||||
subHeaderContent: <div key="sub-header-test">This should be a sub-header node</div>,
|
||||
});
|
||||
|
||||
expect(screen.getByTestId(selectors.components.Panels.Panel.headerContainer)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders panel with sub-header content in place if prop subHeaderContent', () => {
|
||||
setup({
|
||||
subHeaderContent: <div key="sub-header-test">This should be a sub-header node</div>,
|
||||
});
|
||||
|
||||
expect(screen.getByText('This should be a sub-header node')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render sub-header content when panel is collapsed', () => {
|
||||
setup({
|
||||
title: 'Test Panel',
|
||||
collapsible: true,
|
||||
collapsed: true,
|
||||
subHeaderContent: <div key="sub-header-test">This should be a sub-header node</div>,
|
||||
});
|
||||
|
||||
expect(screen.queryByText('This should be a sub-header node')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -76,6 +76,11 @@ interface BaseProps {
|
||||
* If true, the VizPanelMenu will always be visible in the panel header. Defaults to false.
|
||||
*/
|
||||
showMenuAlways?: boolean;
|
||||
/**
|
||||
* Content to display in the sub-header area below the main header.
|
||||
* Can contain text, pills, links, buttons, or any other React elements.
|
||||
*/
|
||||
subHeaderContent?: ReactNode;
|
||||
}
|
||||
|
||||
interface FixedDimensions extends BaseProps {
|
||||
@@ -157,6 +162,7 @@ export function PanelChrome({
|
||||
onMouseEnter,
|
||||
onDragStart,
|
||||
showMenuAlways = false,
|
||||
subHeaderContent,
|
||||
}: PanelChromeProps) {
|
||||
const theme = useTheme2();
|
||||
const styles = useStyles2(getStyles);
|
||||
@@ -164,6 +170,7 @@ export function PanelChrome({
|
||||
const panelTitleId = useId().replace(/:/g, '_');
|
||||
const { isSelected, onSelect, isSelectable } = useElementSelection(selectionId);
|
||||
const pointerDistance = usePointerDistance();
|
||||
const [subHeaderRef, { height: measuredSubHeaderHeight }] = useMeasure<HTMLDivElement>();
|
||||
|
||||
const hasHeader = !hoverHeader;
|
||||
|
||||
@@ -184,11 +191,13 @@ export function PanelChrome({
|
||||
const isPanelTransparent = displayMode === 'transparent';
|
||||
|
||||
const headerHeight = getHeaderHeight(theme, hasHeader);
|
||||
const subHeaderHeight = Math.min(measuredSubHeaderHeight, headerHeight);
|
||||
const { contentStyle, innerWidth, innerHeight } = getContentStyle(
|
||||
padding,
|
||||
theme,
|
||||
headerHeight,
|
||||
collapsed,
|
||||
subHeaderHeight,
|
||||
height,
|
||||
width
|
||||
);
|
||||
@@ -395,37 +404,44 @@ export function PanelChrome({
|
||||
)}
|
||||
|
||||
{hasHeader && (
|
||||
<div
|
||||
className={cx(styles.headerContainer, dragClass)}
|
||||
style={headerStyles}
|
||||
data-testid={selectors.components.Panels.Panel.headerContainer}
|
||||
onPointerDown={onPointerDown}
|
||||
onMouseEnter={isSelectable ? onHeaderEnter : undefined}
|
||||
onMouseLeave={isSelectable ? onHeaderLeave : undefined}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
{statusMessage && (
|
||||
<div className={dragClassCancel}>
|
||||
<PanelStatus
|
||||
message={statusMessage}
|
||||
onClick={statusMessageOnClick}
|
||||
ariaLabel={t('grafana-ui.panel-chrome.ariaLabel-panel-status', 'Panel status')}
|
||||
<>
|
||||
<div
|
||||
className={cx(styles.headerContainer, dragClass)}
|
||||
style={headerStyles}
|
||||
data-testid={selectors.components.Panels.Panel.headerContainer}
|
||||
onPointerDown={onPointerDown}
|
||||
onMouseEnter={isSelectable ? onHeaderEnter : undefined}
|
||||
onMouseLeave={isSelectable ? onHeaderLeave : undefined}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
{statusMessage && (
|
||||
<div className={dragClassCancel}>
|
||||
<PanelStatus
|
||||
message={statusMessage}
|
||||
onClick={statusMessageOnClick}
|
||||
ariaLabel={t('grafana-ui.panel-chrome.ariaLabel-panel-status', 'Panel status')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{headerContent}
|
||||
|
||||
{menu && (
|
||||
<PanelMenu
|
||||
menu={menu}
|
||||
title={typeof title === 'string' ? title : undefined}
|
||||
placement="bottom-end"
|
||||
menuButtonClass={cx(styles.menuItem, dragClassCancel, showOnHoverClass)}
|
||||
onOpenMenu={onOpenMenu}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && subHeaderContent && (
|
||||
<div className={styles.subHeader} ref={subHeaderRef}>
|
||||
{subHeaderContent}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{headerContent}
|
||||
|
||||
{menu && (
|
||||
<PanelMenu
|
||||
menu={menu}
|
||||
title={typeof title === 'string' ? title : undefined}
|
||||
placement="bottom-end"
|
||||
menuButtonClass={cx(styles.menuItem, dragClassCancel, showOnHoverClass)}
|
||||
onOpenMenu={onOpenMenu}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!collapsed && (
|
||||
@@ -466,6 +482,7 @@ const getContentStyle = (
|
||||
theme: GrafanaTheme2,
|
||||
headerHeight: number,
|
||||
collapsed: boolean,
|
||||
subHeaderHeight: number,
|
||||
height?: number,
|
||||
width?: number
|
||||
) => {
|
||||
@@ -481,7 +498,7 @@ const getContentStyle = (
|
||||
|
||||
let innerHeight = 0;
|
||||
if (height) {
|
||||
innerHeight = height - headerHeight - panelPadding - panelBorder;
|
||||
innerHeight = height - headerHeight - panelPadding - panelBorder - subHeaderHeight;
|
||||
}
|
||||
|
||||
if (collapsed) {
|
||||
@@ -579,6 +596,15 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
padding: newPanelPadding ? theme.spacing(0, 1, 0, 1.5) : theme.spacing(0, 0.5, 0, 1),
|
||||
gap: theme.spacing(1),
|
||||
}),
|
||||
subHeader: css({
|
||||
label: 'panel-sub-header',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
maxHeight: theme.spacing.gridSize * theme.components.panel.headerHeight,
|
||||
padding: newPanelPadding ? theme.spacing(0, 1, 0, 1.5) : theme.spacing(0, 0.5, 0, 1),
|
||||
overflow: 'hidden',
|
||||
gap: theme.spacing(1),
|
||||
}),
|
||||
pointer: css({
|
||||
cursor: 'pointer',
|
||||
}),
|
||||
|
||||
@@ -26,6 +26,7 @@ class UnthemedValueContainer<Option, isMulti extends boolean, Group extends Grou
|
||||
if (
|
||||
this.ref.current &&
|
||||
this.props.selectProps.autoWidth &&
|
||||
!this.props.selectProps.maxVisibleValues &&
|
||||
!isEqual(prevProps.selectProps.value, this.props.selectProps.value)
|
||||
) {
|
||||
// Reset in order to measure the new width
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { ScaleDistribution } from '@grafana/schema';
|
||||
|
||||
import { ScaleDistributionEditor } from './axis';
|
||||
|
||||
describe('ScaleDistributionEditor', () => {
|
||||
describe('Symlog', () => {
|
||||
it('linear threshold should not dispatch a change for 0', async () => {
|
||||
const onChange = jest.fn();
|
||||
const origValue = { type: ScaleDistribution.Symlog, log: 10 };
|
||||
|
||||
render(<ScaleDistributionEditor value={{ type: ScaleDistribution.Symlog, log: 10 }} onChange={onChange} />);
|
||||
|
||||
// so annoying that this doesn't work.
|
||||
// const el = await screen.findByLabelText('Linear threshold');
|
||||
const el = screen.getByTestId('input-wrapper').querySelector('input')!;
|
||||
|
||||
await userEvent.type(el, '0');
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
await userEvent.type(el, '.');
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
await userEvent.type(el, '5');
|
||||
expect(onChange).toHaveBeenCalledWith({ linearThreshold: 0.5, ...origValue });
|
||||
|
||||
await userEvent.clear(el);
|
||||
expect(onChange).toHaveBeenCalledWith(origValue);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
FieldConfigEditorBuilder,
|
||||
FieldType,
|
||||
@@ -126,12 +128,31 @@ const LOG_DISTRIBUTION_OPTIONS: Array<SelectableValue<number>> = [
|
||||
},
|
||||
];
|
||||
|
||||
const isValidLinearThreshold = (value: number): string | undefined => {
|
||||
if (Number.isNaN(value)) {
|
||||
return t('grafana-ui.axis-builder.linear-threshold.warning.nan', 'Linear threshold must be a number');
|
||||
}
|
||||
if (value === 0) {
|
||||
return t('grafana-ui.axis-builder.linear-threshold.warning.zero', 'Linear threshold cannot be zero');
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export const ScaleDistributionEditor = ({ value, onChange }: StandardEditorProps<ScaleDistributionConfig>) => {
|
||||
export const ScaleDistributionEditor = ({
|
||||
value,
|
||||
onChange,
|
||||
}: Pick<StandardEditorProps<ScaleDistributionConfig>, 'value' | 'onChange'>) => {
|
||||
const type = value?.type ?? ScaleDistribution.Linear;
|
||||
const log = value?.log ?? 2;
|
||||
|
||||
const [localLinearThreshold, setLocalLinearThreshold] = useState<string>(
|
||||
value?.linearThreshold != null ? String(value.linearThreshold) : ''
|
||||
);
|
||||
const [linearThresholdWarning, setLinearThresholdWarning] = useState<string | undefined>();
|
||||
|
||||
const DISTRIBUTION_OPTIONS: Array<SelectableValue<ScaleDistribution>> = [
|
||||
{
|
||||
label: t('grafana-ui.builder.axis.scale-distribution-editor.distribution-options.label-linear', 'Linear'),
|
||||
@@ -161,7 +182,7 @@ export const ScaleDistributionEditor = ({ value, onChange }: StandardEditorProps
|
||||
}}
|
||||
/>
|
||||
{(type === ScaleDistribution.Log || type === ScaleDistribution.Symlog) && (
|
||||
<Field label={t('grafana-ui.axis-builder.log-base', 'Log base')}>
|
||||
<Field label={t('grafana-ui.axis-builder.log-base', 'Log base')} noMargin>
|
||||
<Select
|
||||
options={LOG_DISTRIBUTION_OPTIONS}
|
||||
value={log}
|
||||
@@ -175,16 +196,43 @@ export const ScaleDistributionEditor = ({ value, onChange }: StandardEditorProps
|
||||
</Field>
|
||||
)}
|
||||
{type === ScaleDistribution.Symlog && (
|
||||
<Field label={t('grafana-ui.axis-builder.linear-threshold', 'Linear threshold')} style={{ marginBottom: 0 }}>
|
||||
// ADD error and invalid to field when needed
|
||||
<Field
|
||||
label={t('grafana-ui.axis-builder.linear-threshold.label', 'Linear threshold')}
|
||||
invalid={!!linearThresholdWarning}
|
||||
error={linearThresholdWarning}
|
||||
style={{ marginBottom: 0 }}
|
||||
noMargin
|
||||
>
|
||||
<Input
|
||||
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
|
||||
placeholder="1"
|
||||
value={value?.linearThreshold}
|
||||
value={localLinearThreshold}
|
||||
invalid={!!linearThresholdWarning}
|
||||
type="number"
|
||||
onBlur={(ev) => {
|
||||
if (ev.currentTarget.value) {
|
||||
setLinearThresholdWarning(isValidLinearThreshold(Number(ev.currentTarget.value)));
|
||||
}
|
||||
}}
|
||||
onChange={(v) => {
|
||||
onChange({
|
||||
...value,
|
||||
linearThreshold: Number(v.currentTarget.value),
|
||||
});
|
||||
setLocalLinearThreshold(v.currentTarget.value);
|
||||
if (v.currentTarget.value === '') {
|
||||
const newValue = { ...value };
|
||||
delete newValue.linearThreshold;
|
||||
onChange(newValue);
|
||||
setLinearThresholdWarning(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const asNumber = Number(v.currentTarget.value);
|
||||
if (isValidLinearThreshold(asNumber) == null) {
|
||||
setLinearThresholdWarning(undefined);
|
||||
onChange({
|
||||
...value,
|
||||
linearThreshold: asNumber,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@@ -313,6 +313,7 @@ func (hs *HTTPServer) declareFixedRoles() error {
|
||||
Grants: []string{string(org.RoleEditor)},
|
||||
}
|
||||
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if hs.Features.IsEnabled(context.Background(), featuremgmt.FlagAnnotationPermissionUpdate) {
|
||||
// Keeping the name to avoid breaking changes (for users who have assigned this role to grant permissions on organization annotations)
|
||||
annotationsReaderRole = ac.RoleRegistration{
|
||||
@@ -619,6 +620,7 @@ func (hs *HTTPServer) declareFixedRoles() error {
|
||||
libraryPanelsReaderRole, libraryPanelsWriterRole, libraryPanelsGeneralReaderRole, libraryPanelsGeneralWriterRole,
|
||||
snapshotsCreatorRole, snapshotsDeleterRole, snapshotsReaderRole}
|
||||
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if hs.Features.IsEnabled(context.Background(), featuremgmt.FlagAnnotationPermissionUpdate) {
|
||||
allAnnotationsReaderRole := ac.RoleRegistration{
|
||||
Role: ac.RoleDTO{
|
||||
|
||||
@@ -126,6 +126,7 @@ func (hs *HTTPServer) PostAnnotation(c *contextmodel.ReqContext) response.Respon
|
||||
}
|
||||
|
||||
if canSave, err := hs.canCreateAnnotation(c, cmd.DashboardUID); err != nil || !canSave {
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if !hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagAnnotationPermissionUpdate) {
|
||||
return dashboardGuardianResponse(err)
|
||||
} else if err != nil {
|
||||
@@ -271,6 +272,7 @@ func (hs *HTTPServer) UpdateAnnotation(c *contextmodel.ReqContext) response.Resp
|
||||
return resp
|
||||
}
|
||||
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if !hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagAnnotationPermissionUpdate) {
|
||||
if canSave, err := hs.canSaveAnnotation(c, hs.AccessControl, annotation); err != nil || !canSave {
|
||||
return dashboardGuardianResponse(err)
|
||||
@@ -329,6 +331,7 @@ func (hs *HTTPServer) PatchAnnotation(c *contextmodel.ReqContext) response.Respo
|
||||
return resp
|
||||
}
|
||||
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if !hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagAnnotationPermissionUpdate) {
|
||||
if canSave, err := hs.canSaveAnnotation(c, hs.AccessControl, annotation); err != nil || !canSave {
|
||||
return dashboardGuardianResponse(err)
|
||||
@@ -439,6 +442,7 @@ func (hs *HTTPServer) MassDeleteAnnotations(c *contextmodel.ReqContext) response
|
||||
|
||||
canSave, err := hs.canMassDeleteAnnotations(c, dashboardUID)
|
||||
if err != nil || !canSave {
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if !hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagAnnotationPermissionUpdate) {
|
||||
return dashboardGuardianResponse(err)
|
||||
} else if err != nil {
|
||||
@@ -500,6 +504,7 @@ func (hs *HTTPServer) DeleteAnnotationByID(c *contextmodel.ReqContext) response.
|
||||
return response.Error(http.StatusBadRequest, "annotationId is invalid", err)
|
||||
}
|
||||
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if !hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagAnnotationPermissionUpdate) {
|
||||
annotation, resp := findAnnotationByID(c.Req.Context(), hs.annotationsRepo, annotationID, c.SignedInUser)
|
||||
if resp != nil {
|
||||
@@ -610,6 +615,7 @@ func AnnotationTypeScopeResolver(annotationsRepo annotations.Repository, feature
|
||||
},
|
||||
}
|
||||
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if features.IsEnabled(ctx, featuremgmt.FlagAnnotationPermissionUpdate) {
|
||||
tempUser = &user.SignedInUser{
|
||||
OrgID: orgID,
|
||||
@@ -626,6 +632,7 @@ func AnnotationTypeScopeResolver(annotationsRepo annotations.Repository, feature
|
||||
return nil, errors.New("could not resolve annotation type")
|
||||
}
|
||||
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if !features.IsEnabled(ctx, featuremgmt.FlagAnnotationPermissionUpdate) {
|
||||
switch annotation.GetType() {
|
||||
case annotations.Organization:
|
||||
@@ -662,6 +669,7 @@ func AnnotationTypeScopeResolver(annotationsRepo annotations.Repository, feature
|
||||
}
|
||||
|
||||
func (hs *HTTPServer) canCreateAnnotation(c *contextmodel.ReqContext, dashboardUID string) (bool, error) {
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagAnnotationPermissionUpdate) {
|
||||
if dashboardUID != "" {
|
||||
evaluator := accesscontrol.EvalPermission(accesscontrol.ActionAnnotationsCreate, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(dashboardUID))
|
||||
@@ -686,6 +694,7 @@ func (hs *HTTPServer) canCreateAnnotation(c *contextmodel.ReqContext, dashboardU
|
||||
}
|
||||
|
||||
func (hs *HTTPServer) canMassDeleteAnnotations(c *contextmodel.ReqContext, dashboardUID string) (bool, error) {
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagAnnotationPermissionUpdate) {
|
||||
if dashboardUID == "" {
|
||||
evaluator := accesscontrol.EvalPermission(accesscontrol.ActionAnnotationsDelete, accesscontrol.ScopeAnnotationsTypeOrganization)
|
||||
|
||||
+1
-2
@@ -121,8 +121,7 @@ func (hs *HTTPServer) registerRoutes() {
|
||||
r.Get("/admin/provisioning", reqOrgAdmin, hs.Index)
|
||||
r.Get("/admin/provisioning/*", reqOrgAdmin, hs.Index)
|
||||
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if hs.Features.IsEnabledGlobally(featuremgmt.FlagOnPremToCloudMigrations) {
|
||||
if hs.Cfg.CloudMigration.Enabled {
|
||||
r.Get("/admin/migrate-to-cloud", authorize(cloudmigration.MigrationAssistantAccess), hs.Index)
|
||||
}
|
||||
|
||||
|
||||
@@ -166,6 +166,7 @@ func (hs *HTTPServer) GetDashboard(c *contextmodel.ReqContext) response.Response
|
||||
}
|
||||
|
||||
annotationPermissions := &dashboardsV1.AnnotationPermission{}
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if hs.Features.IsEnabled(ctx, featuremgmt.FlagAnnotationPermissionUpdate) {
|
||||
hs.getAnnotationPermissionsByScope(c, &annotationPermissions.Dashboard, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(dash.UID))
|
||||
} else {
|
||||
|
||||
@@ -287,6 +287,7 @@ type FrontendSettingsDTO struct {
|
||||
PublicDashboardAccessToken string `json:"publicDashboardAccessToken"`
|
||||
PublicDashboardsEnabled bool `json:"publicDashboardsEnabled"`
|
||||
|
||||
CloudMigrationEnabled bool `json:"cloudMigrationEnabled"`
|
||||
CloudMigrationIsTarget bool `json:"cloudMigrationIsTarget"`
|
||||
CloudMigrationPollIntervalMs int `json:"cloudMigrationPollIntervalMs"`
|
||||
|
||||
|
||||
@@ -150,6 +150,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
|
||||
continue
|
||||
}
|
||||
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if panel.ID == "datagrid" && !hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagEnableDatagridEditing) {
|
||||
continue
|
||||
}
|
||||
@@ -190,7 +191,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
|
||||
|
||||
hasAccess := accesscontrol.HasAccess(hs.AccessControl, c)
|
||||
trustedTypesDefaultPolicyEnabled := (hs.Cfg.CSPEnabled && strings.Contains(hs.Cfg.CSPTemplate, "require-trusted-types-for")) || (hs.Cfg.CSPReportOnlyEnabled && strings.Contains(hs.Cfg.CSPReportOnlyTemplate, "require-trusted-types-for"))
|
||||
isCloudMigrationTarget := hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagOnPremToCloudMigrations) && hs.Cfg.CloudMigration.IsTarget
|
||||
isCloudMigrationTarget := hs.Cfg.CloudMigration.Enabled && hs.Cfg.CloudMigration.IsTarget
|
||||
featureToggles := hs.Features.GetEnabled(c.Req.Context())
|
||||
// this is needed for backwards compatibility with external plugins
|
||||
// we should remove this once we can be sure that no external plugins rely on this
|
||||
@@ -258,6 +259,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
|
||||
PluginRestrictedAPIsBlockList: hs.Cfg.PluginRestrictedAPIsBlockList,
|
||||
PublicDashboardAccessToken: c.PublicDashboardAccessToken,
|
||||
PublicDashboardsEnabled: hs.Cfg.PublicDashboardsEnabled,
|
||||
CloudMigrationEnabled: hs.Cfg.CloudMigration.Enabled,
|
||||
CloudMigrationIsTarget: isCloudMigrationTarget,
|
||||
CloudMigrationPollIntervalMs: int(hs.Cfg.CloudMigration.FrontendPollInterval.Milliseconds()),
|
||||
SharedWithMeFolderUID: folder.SharedWithMeFolderUID,
|
||||
@@ -406,6 +408,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
|
||||
DisableSignoutMenu: hs.Cfg.DisableSignoutMenu,
|
||||
}
|
||||
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if hs.Cfg.PasswordlessMagicLinkAuth.Enabled && hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagPasswordlessMagicLinkAuthentication) {
|
||||
hasEnabledProviders := hs.samlEnabled() || hs.authnService.IsClientEnabled(authn.ClientLDAP)
|
||||
|
||||
@@ -444,6 +447,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
|
||||
frontendSettings.Namespace = hs.namespacer(c.OrgID)
|
||||
|
||||
// experimental scope features
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagScopeFilters) {
|
||||
frontendSettings.ListScopesEndpoint = hs.Cfg.ScopesListScopesURL
|
||||
frontendSettings.ListDashboardScopesEndpoint = hs.Cfg.ScopesListDashboardsURL
|
||||
|
||||
@@ -62,6 +62,7 @@ func (hs *HTTPServer) setIndexViewData(c *contextmodel.ReqContext) (*dtos.IndexV
|
||||
return nil, err
|
||||
}
|
||||
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagIndividualCookiePreferences) {
|
||||
if !prefs.Cookies("analytics") {
|
||||
settings.GoogleAnalytics4Id = ""
|
||||
@@ -94,6 +95,7 @@ func (hs *HTTPServer) setIndexViewData(c *contextmodel.ReqContext) (*dtos.IndexV
|
||||
}
|
||||
|
||||
var regionalFormat string
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagLocaleFormatPreference) {
|
||||
regionalFormat = "en"
|
||||
|
||||
|
||||
@@ -361,6 +361,7 @@ func (hs *HTTPServer) RedirectResponseWithError(c *contextmodel.ReqContext, err
|
||||
|
||||
func (hs *HTTPServer) redirectURLWithErrorCookie(c *contextmodel.ReqContext, err error) string {
|
||||
setCookie := true
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagIndividualCookiePreferences) {
|
||||
var userID int64
|
||||
if c.SignedInUser != nil && !c.IsNil() {
|
||||
|
||||
@@ -81,6 +81,7 @@ func (proxy *PluginProxy) HandleRequest() {
|
||||
hasSlash := strings.HasSuffix(proxy.proxyPath, "/")
|
||||
proxy.proxyPath = path
|
||||
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if hasSlash && !strings.HasSuffix(path, "/") && proxy.features.IsEnabled(proxy.ctx.Req.Context(), featuremgmt.FlagPluginProxyPreserveTrailingSlash) {
|
||||
proxy.proxyPath += "/"
|
||||
}
|
||||
|
||||
@@ -144,6 +144,7 @@ func (hs *HTTPServer) GetPluginList(c *contextmodel.ReqContext) response.Respons
|
||||
AngularDetected: pluginDef.Angular.Detected,
|
||||
}
|
||||
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if hs.Cfg.ManagedServiceAccountsEnabled && hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagExternalServiceAccounts) {
|
||||
listItem.IAM = pluginDef.IAM
|
||||
}
|
||||
@@ -490,6 +491,7 @@ func (hs *HTTPServer) InstallPlugin(c *contextmodel.ReqContext) response.Respons
|
||||
return response.ErrOrFallback(http.StatusInternalServerError, "Failed to install plugin", err)
|
||||
}
|
||||
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if hs.Cfg.ManagedServiceAccountsEnabled && hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagExternalServiceAccounts) {
|
||||
// This is a non-blocking function that verifies that the installer has
|
||||
// the permissions that the plugin requests to have on Grafana.
|
||||
|
||||
@@ -83,7 +83,6 @@ func ToUnifiedStorage(c utils.CommandLine, cfg *setting.Cfg, sqlStore db.DB) err
|
||||
legacysql.NewDatabaseProvider(sqlStore),
|
||||
provisioning,
|
||||
acimpl.ProvideAccessControl(featuremgmt.WithFeatures()),
|
||||
featureToggles,
|
||||
)
|
||||
|
||||
if c.Bool("non-interactive") {
|
||||
|
||||
@@ -29,6 +29,7 @@ func (c *ResultConverter) Convert(ctx context.Context,
|
||||
}
|
||||
|
||||
var dt data.FrameType
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
dt, useDataplane, _ := shouldUseDataplane(frames, logger, c.Features.IsEnabled(ctx, featuremgmt.FlagDisableSSEDataplane))
|
||||
if useDataplane {
|
||||
logger.Debug("Handling SSE data source query through dataplane", "datatype", dt)
|
||||
|
||||
@@ -70,6 +70,7 @@ func handleDataplaneFrames(ctx context.Context, tracer tracing.Tracer, features
|
||||
case data.KindTimeSeries:
|
||||
return handleDataplaneTimeseries(frames)
|
||||
case data.KindNumeric:
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
sortMetrics := !features.IsEnabled(ctx, featuremgmt.FlagDisableNumericMetricsSortingInExpressions)
|
||||
return handleDataplaneNumeric(frames, sortMetrics)
|
||||
default:
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ type DataPipeline []Node
|
||||
// map of the refId of the of each command
|
||||
func (dp *DataPipeline) execute(c context.Context, now time.Time, s *Service) (mathexp.Vars, error) {
|
||||
vars := make(mathexp.Vars)
|
||||
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
groupByDSFlag := s.features.IsEnabled(c, featuremgmt.FlagSseGroupByDatasource)
|
||||
// Execute datasource nodes first, and grouped by datasource.
|
||||
if groupByDSFlag {
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
_ "github.com/blugelabs/bluge"
|
||||
_ "github.com/blugelabs/bluge_segment_api"
|
||||
_ "github.com/crewjam/saml"
|
||||
_ "github.com/docker/go-connections/nat"
|
||||
_ "github.com/go-jose/go-jose/v4"
|
||||
_ "github.com/gobwas/glob"
|
||||
_ "github.com/googleapis/gax-go/v2"
|
||||
@@ -30,6 +31,7 @@ import (
|
||||
_ "github.com/spf13/cobra" // used by the standalone apiserver cli
|
||||
_ "github.com/spyzhov/ajson"
|
||||
_ "github.com/stretchr/testify/require"
|
||||
_ "github.com/testcontainers/testcontainers-go"
|
||||
_ "gocloud.dev/secrets/awskms"
|
||||
_ "gocloud.dev/secrets/azurekeyvault"
|
||||
_ "gocloud.dev/secrets/gcpkms"
|
||||
@@ -54,9 +56,7 @@ import (
|
||||
_ "github.com/grafana/e2e"
|
||||
_ "github.com/grafana/gofpdf"
|
||||
_ "github.com/grafana/gomemcache/memcache"
|
||||
_ "github.com/grafana/tempo/pkg/traceql"
|
||||
|
||||
_ "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1"
|
||||
_ "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1"
|
||||
_ "github.com/testcontainers/testcontainers-go"
|
||||
_ "github.com/grafana/tempo/pkg/traceql"
|
||||
)
|
||||
|
||||
@@ -64,6 +64,7 @@ func (l *loggerImpl) Middleware() web.Middleware {
|
||||
// put the start time on context so we can measure it later.
|
||||
r = r.WithContext(log.InitstartTime(r.Context(), time.Now()))
|
||||
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if l.flags.IsEnabled(r.Context(), featuremgmt.FlagUnifiedRequestLog) {
|
||||
r = r.WithContext(errutil.SetUnifiedLogging(r.Context()))
|
||||
}
|
||||
|
||||
@@ -114,6 +114,7 @@ func RequestMetrics(features featuremgmt.FeatureToggles, cfg *setting.Cfg, promR
|
||||
handler = "notfound"
|
||||
} else {
|
||||
// log requests where we could not identify handler so we can register them.
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if features.IsEnabled(r.Context(), featuremgmt.FlagLogRequestsInstrumentedAsUnknown) {
|
||||
log.Warn("request instrumented as unknown", "path", r.URL.Path, "status_code", status)
|
||||
}
|
||||
|
||||
@@ -237,7 +237,7 @@ func NewPlugin(pluginID string, cfg *setting.Cfg, httpClientProvider *httpclient
|
||||
case Tempo:
|
||||
svc = tempo.ProvideService(httpClientProvider, tracer)
|
||||
case PostgreSQL:
|
||||
svc = postgres.ProvideService(cfg)
|
||||
svc = postgres.ProvideService()
|
||||
case MySQL:
|
||||
svc = mysql.ProvideService()
|
||||
case MSSQL:
|
||||
|
||||
@@ -2,8 +2,10 @@ package dashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
@@ -112,6 +114,13 @@ func (l *libraryElementIndexProvider) GetLibraryElementInfo(ctx context.Context)
|
||||
}
|
||||
|
||||
for _, elem := range result.Elements {
|
||||
var modelUnstructured v0alpha1.Unstructured
|
||||
if len(elem.Model) > 0 {
|
||||
var modelObj map[string]any
|
||||
if err := json.Unmarshal(elem.Model, &modelObj); err == nil {
|
||||
modelUnstructured.Object = modelObj
|
||||
}
|
||||
}
|
||||
info = append(info, schemaversion.LibraryElementInfo{
|
||||
UID: elem.UID,
|
||||
Name: elem.Name,
|
||||
@@ -119,6 +128,7 @@ func (l *libraryElementIndexProvider) GetLibraryElementInfo(ctx context.Context)
|
||||
Type: elem.Type,
|
||||
Description: elem.Description,
|
||||
FolderUID: elem.FolderUID,
|
||||
Model: modelUnstructured,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -90,3 +90,8 @@ func (d *directResourceClient) Watch(ctx context.Context, in *resourcepb.WatchRe
|
||||
func (d *directResourceClient) BulkProcess(ctx context.Context, opts ...grpc.CallOption) (resourcepb.BulkStore_BulkProcessClient, error) {
|
||||
return nil, fmt.Errorf("BulkProcess not supported with direct resource client")
|
||||
}
|
||||
|
||||
// RebuildIndexes implements resource.ResourceClient.
|
||||
func (b *directResourceClient) RebuildIndexes(ctx context.Context, req *resourcepb.RebuildIndexesRequest, opts ...grpc.CallOption) (*resourcepb.RebuildIndexesResponse, error) {
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
@@ -83,9 +83,6 @@ type dashboardSqlAccess struct {
|
||||
namespacer request.NamespaceMapper
|
||||
provisioning provisioning.StubProvisioningService
|
||||
|
||||
// TODO: consider enabling this by default for on-prem migrations
|
||||
invalidDashboardParseFallbackEnabled bool
|
||||
|
||||
// Use for writing (not reading)
|
||||
dashStore dashboards.Store
|
||||
dashboardSearchClient legacysearcher.DashboardSearchClient
|
||||
@@ -106,17 +103,15 @@ func ProvideMigratorDashboardAccessor(
|
||||
sql legacysql.LegacyDatabaseProvider,
|
||||
provisioning provisioning.StubProvisioningService,
|
||||
accessControl accesscontrol.AccessControl,
|
||||
features featuremgmt.FeatureToggles,
|
||||
) MigrationDashboardAccessor {
|
||||
return &dashboardSqlAccess{
|
||||
sql: sql,
|
||||
namespacer: claims.OrgNamespaceFormatter,
|
||||
dashStore: nil, // not needed for migration
|
||||
provisioning: provisioning,
|
||||
dashboardPermissionSvc: nil, // not needed for migration
|
||||
libraryPanelSvc: nil, // not needed for migration
|
||||
accessControl: accessControl,
|
||||
invalidDashboardParseFallbackEnabled: features.IsEnabled(context.Background(), featuremgmt.FlagScanRowInvalidDashboardParseFallbackEnabled),
|
||||
sql: sql,
|
||||
namespacer: claims.OrgNamespaceFormatter,
|
||||
dashStore: nil, // not needed for migration
|
||||
provisioning: provisioning,
|
||||
dashboardPermissionSvc: nil, // not needed for migration
|
||||
libraryPanelSvc: nil, // not needed for migration
|
||||
accessControl: accessControl,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,15 +127,14 @@ func NewDashboardSQLAccess(sql legacysql.LegacyDatabaseProvider,
|
||||
) *dashboardSqlAccess {
|
||||
dashboardSearchClient := legacysearcher.NewDashboardSearchClient(dashStore, sorter)
|
||||
return &dashboardSqlAccess{
|
||||
sql: sql,
|
||||
namespacer: namespacer,
|
||||
dashStore: dashStore,
|
||||
provisioning: provisioning,
|
||||
dashboardSearchClient: *dashboardSearchClient,
|
||||
dashboardPermissionSvc: dashboardPermissionSvc,
|
||||
libraryPanelSvc: libraryPanelSvc,
|
||||
accessControl: accessControl,
|
||||
invalidDashboardParseFallbackEnabled: features.IsEnabled(context.Background(), featuremgmt.FlagScanRowInvalidDashboardParseFallbackEnabled),
|
||||
sql: sql,
|
||||
namespacer: namespacer,
|
||||
dashStore: dashStore,
|
||||
provisioning: provisioning,
|
||||
dashboardSearchClient: *dashboardSearchClient,
|
||||
dashboardPermissionSvc: dashboardPermissionSvc,
|
||||
libraryPanelSvc: libraryPanelSvc,
|
||||
accessControl: accessControl,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -591,17 +585,17 @@ func generateFallbackDashboard(data []byte, title, uid string) ([]byte, error) {
|
||||
|
||||
func (a *dashboardSqlAccess) parseDashboard(dash *dashboardV1.Dashboard, data []byte, id int64, title string) error {
|
||||
if err := dash.Spec.UnmarshalJSON(data); err != nil {
|
||||
a.log.Warn("error unmarshalling dashboard spec. Generating fallback dashboard data", "error", err, "uid", dash.UID, "name", dash.Name)
|
||||
a.log.Warn("error unmarshalling dashboard spec. Generating fallback dashboard data", "error", err, "uid", dash.UID, "id", id, "name", dash.Name)
|
||||
dash.Spec = *dashboardV0.NewDashboardSpec()
|
||||
|
||||
dashboardData, err := generateFallbackDashboard(data, title, string(dash.UID))
|
||||
if err != nil {
|
||||
a.log.Warn("error generating fallback dashboard data", "error", err, "uid", dash.UID, "name", dash.Name)
|
||||
a.log.Warn("error generating fallback dashboard data", "error", err, "uid", dash.UID, "id", id, "name", dash.Name)
|
||||
return err
|
||||
}
|
||||
|
||||
if err = dash.Spec.UnmarshalJSON(dashboardData); err != nil {
|
||||
a.log.Warn("error unmarshalling fallback dashboard data", "error", err, "uid", dash.UID, "name", dash.Name)
|
||||
a.log.Warn("error unmarshalling fallback dashboard data", "error", err, "uid", dash.UID, "id", id, "name", dash.Name)
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -713,14 +707,8 @@ func (a *dashboardSqlAccess) scanRow(rows *sql.Rows, history bool) (*dashboardRo
|
||||
}
|
||||
|
||||
if len(data) > 0 {
|
||||
if a.invalidDashboardParseFallbackEnabled {
|
||||
if err := a.parseDashboard(dash, data, dashboard_id, title); err != nil {
|
||||
return row, err
|
||||
}
|
||||
} else {
|
||||
if err := dash.Spec.UnmarshalJSON(data); err != nil {
|
||||
return row, fmt.Errorf("JSON unmarshal error for: %s // %w", dash.Name, err)
|
||||
}
|
||||
if err := a.parseDashboard(dash, data, dashboard_id, title); err != nil {
|
||||
return row, err
|
||||
}
|
||||
}
|
||||
// Ignore any saved values for id/version/uid
|
||||
@@ -1046,3 +1034,7 @@ func parseLibraryPanelRow(p panel) (dashboardV0.LibraryPanel, error) {
|
||||
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (b *dashboardSqlAccess) RebuildIndexes(ctx context.Context, req *resourcepb.RebuildIndexesRequest) (*resourcepb.RebuildIndexesResponse, error) {
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
@@ -35,10 +35,9 @@ func TestScanRow(t *testing.T) {
|
||||
provisioner := provisioning.NewProvisioningServiceMock(context.Background())
|
||||
provisioner.GetDashboardProvisionerResolvedPathFunc = func(name string) string { return "provisioner" }
|
||||
store := &dashboardSqlAccess{
|
||||
namespacer: func(_ int64) string { return "default" },
|
||||
provisioning: provisioner,
|
||||
log: log.New("test"),
|
||||
invalidDashboardParseFallbackEnabled: false,
|
||||
namespacer: func(_ int64) string { return "default" },
|
||||
provisioning: provisioner,
|
||||
log: log.New("test"),
|
||||
}
|
||||
|
||||
columns := []string{"orgId", "dashboard_id", "name", "title", "folder_uid", "deleted", "plugin_id", "origin_name", "origin_path", "origin_hash", "origin_ts", "created", "createdBy", "createdByID", "updated", "updatedBy", "updatedByID", "version", "message", "data", "api_version"}
|
||||
@@ -194,39 +193,25 @@ func TestScanRow(t *testing.T) {
|
||||
require.Equal(t, "dashboard.grafana.app/"+migrationAPIVersion, row.Dash.APIVersion)
|
||||
})
|
||||
|
||||
t.Run("should follow dashboard template when failing to unmarshal dashboard if feature flag X is enabled", func(t *testing.T) {
|
||||
t.Run("should follow dashboard template when failing to unmarshal dashboard", func(t *testing.T) {
|
||||
// row with bad data
|
||||
badData := []byte(`{"rows":[{"panels":[{"targets":[{"refId":"A","target":"aliasSub(alias, '^(.{27}).+', '\1...')"}]}]}]}`)
|
||||
rows := sqlmock.NewRows(columns).AddRow(1, id, uid, title, folderUID, nil, "", "", "", "", 0, timestamp, createdUser, 0, timestamp, updatedUser, 0, version, message, badData, "vXyz")
|
||||
mock.ExpectQuery("SELECT *").WillReturnRows(rows)
|
||||
resultRows, err := mockDB.Query("SELECT *")
|
||||
require.NoError(t, err)
|
||||
defer resultRows.Close() // nolint:errcheck
|
||||
defer func() {
|
||||
_ = resultRows.Close()
|
||||
}()
|
||||
resultRows.Next()
|
||||
|
||||
row, err := store.scanRow(resultRows, false)
|
||||
require.Error(t, err, "JSON unmarshal error for: Test Dashboard // invalid character '1' in string escape code")
|
||||
require.NotNil(t, row)
|
||||
// correctly scans these
|
||||
require.Equal(t, uid, row.Dash.Name)
|
||||
require.Equal(t, version, row.RV)
|
||||
require.Equal(t, "default", row.Dash.Namespace)
|
||||
require.Equal(t, &continueToken{orgId: int64(1), id: id}, row.token)
|
||||
|
||||
// failure case: does NOT parse the dashboard itself
|
||||
require.Equal(t, common.Unstructured{
|
||||
Object: nil,
|
||||
}, row.Dash.Spec)
|
||||
|
||||
// store with feature flag enabled
|
||||
store = &dashboardSqlAccess{
|
||||
namespacer: func(_ int64) string { return "default" },
|
||||
provisioning: provisioner,
|
||||
log: log.New("test"),
|
||||
invalidDashboardParseFallbackEnabled: true,
|
||||
namespacer: func(_ int64) string { return "default" },
|
||||
provisioning: provisioner,
|
||||
log: log.New("test"),
|
||||
}
|
||||
|
||||
row, err = store.scanRow(resultRows, false)
|
||||
row, err := store.scanRow(resultRows, false)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, row)
|
||||
require.Equal(t, uid, row.Dash.Name)
|
||||
|
||||
@@ -33,9 +33,11 @@ func (b *DashboardsAPIBuilder) ValidateDashboardSpec(ctx context.Context, obj ru
|
||||
case *v0.Dashboard:
|
||||
errorOnSchemaMismatches = false // Never error for v0
|
||||
case *v1.Dashboard:
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
errorOnSchemaMismatches = !b.features.IsEnabled(ctx, featuremgmt.FlagDashboardDisableSchemaValidationV1)
|
||||
case *v2alpha1.Dashboard:
|
||||
case *v2beta1.Dashboard:
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
errorOnSchemaMismatches = !b.features.IsEnabled(ctx, featuremgmt.FlagDashboardDisableSchemaValidationV2)
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid dashboard type: %T", obj)
|
||||
@@ -45,6 +47,7 @@ func (b *DashboardsAPIBuilder) ValidateDashboardSpec(ctx context.Context, obj ru
|
||||
return nil, apierrors.NewBadRequest("Not supported: FieldValidationMode: Warn")
|
||||
}
|
||||
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
alwaysLogSchemaValidationErrors := b.features.IsEnabled(ctx, featuremgmt.FlagDashboardSchemaValidationLogging)
|
||||
|
||||
var errors field.ErrorList
|
||||
|
||||
@@ -581,3 +581,8 @@ func (m *mockSearchClient) GetStats(ctx context.Context, in *resourcepb.Resource
|
||||
func (m *mockSearchClient) Search(ctx context.Context, in *resourcepb.ResourceSearchRequest, opts ...grpc.CallOption) (*resourcepb.ResourceSearchResponse, error) {
|
||||
return m.search, m.searchErr
|
||||
}
|
||||
|
||||
// RebuildIndexes implements resourcepb.ResourceIndexClient.
|
||||
func (m *mockSearchClient) RebuildIndexes(ctx context.Context, in *resourcepb.RebuildIndexesRequest, opts ...grpc.CallOption) (*resourcepb.RebuildIndexesResponse, error) {
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
// The name used to refer to the system keeper
|
||||
SystemKeeperName = "system"
|
||||
|
||||
ErrKeeperNotFound = errors.New("keeper not found")
|
||||
ErrKeeperAlreadyExists = errors.New("keeper already exists")
|
||||
)
|
||||
@@ -21,7 +24,9 @@ type KeeperMetadataStorage interface {
|
||||
Update(ctx context.Context, keeper *secretv1beta1.Keeper, actorUID string) (*secretv1beta1.Keeper, error)
|
||||
Delete(ctx context.Context, namespace xkube.Namespace, name string) error
|
||||
List(ctx context.Context, namespace xkube.Namespace) ([]secretv1beta1.Keeper, error)
|
||||
GetKeeperConfig(ctx context.Context, namespace string, name *string, opts ReadOpts) (secretv1beta1.KeeperConfig, error)
|
||||
GetKeeperConfig(ctx context.Context, namespace string, name string, opts ReadOpts) (secretv1beta1.KeeperConfig, error)
|
||||
SetAsActive(ctx context.Context, namespace xkube.Namespace, name string) error
|
||||
GetActiveKeeperConfig(ctx context.Context, namespace string) (string, secretv1beta1.KeeperConfig, error)
|
||||
}
|
||||
|
||||
// ErrKeeperInvalidSecureValues is returned when a Keeper references SecureValues that do not exist.
|
||||
|
||||
@@ -31,7 +31,7 @@ type ReadOpts struct {
|
||||
|
||||
// SecureValueMetadataStorage is the interface for wiring and dependency injection.
|
||||
type SecureValueMetadataStorage interface {
|
||||
Create(ctx context.Context, sv *secretv1beta1.SecureValue, actorUID string) (*secretv1beta1.SecureValue, error)
|
||||
Create(ctx context.Context, keeper string, sv *secretv1beta1.SecureValue, actorUID string) (*secretv1beta1.SecureValue, error)
|
||||
Read(ctx context.Context, namespace xkube.Namespace, name string, opts ReadOpts) (*secretv1beta1.SecureValue, error)
|
||||
List(ctx context.Context, namespace xkube.Namespace) ([]secretv1beta1.SecureValue, error)
|
||||
SetVersionToActive(ctx context.Context, namespace xkube.Namespace, name string, version int64) error
|
||||
@@ -47,6 +47,7 @@ type SecureValueService interface {
|
||||
List(ctx context.Context, namespace xkube.Namespace) (*secretv1beta1.SecureValueList, error)
|
||||
Update(ctx context.Context, newSecureValue *secretv1beta1.SecureValue, actorUID string) (*secretv1beta1.SecureValue, bool, error)
|
||||
Delete(ctx context.Context, namespace xkube.Namespace, name string) (*secretv1beta1.SecureValue, error)
|
||||
SetKeeperAsActive(ctx context.Context, namespace xkube.Namespace, keeperName string) error
|
||||
}
|
||||
|
||||
type SecureValueClient interface {
|
||||
|
||||
@@ -93,14 +93,14 @@ func (w *Worker) CleanupInactiveSecureValues(ctx context.Context) ([]secretv1bet
|
||||
}
|
||||
|
||||
func (w *Worker) Cleanup(ctx context.Context, sv *secretv1beta1.SecureValue) error {
|
||||
keeperCfg, err := w.keeperMetadataStorage.GetKeeperConfig(ctx, sv.Namespace, sv.Spec.Keeper, contracts.ReadOpts{ForUpdate: false})
|
||||
keeperCfg, err := w.keeperMetadataStorage.GetKeeperConfig(ctx, sv.Namespace, sv.Status.Keeper, contracts.ReadOpts{ForUpdate: false})
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetching keeper config: namespace=%+v keeperName=%+v %w", sv.Namespace, sv.Spec.Keeper, err)
|
||||
return fmt.Errorf("fetching keeper config: namespace=%+v keeperName=%+v %w", sv.Namespace, sv.Status.Keeper, err)
|
||||
}
|
||||
|
||||
keeper, err := w.keeperService.KeeperForConfig(keeperCfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting keeper for config: namespace=%+v keeperName=%+v %w", sv.Namespace, sv.Spec.Keeper, err)
|
||||
return fmt.Errorf("getting keeper for config: namespace=%+v keeperName=%+v %w", sv.Namespace, sv.Status.Keeper, err)
|
||||
}
|
||||
|
||||
// Keeper deletion is idempotent
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package garbagecollectionworker_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -11,7 +10,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/registry/apis/secret/testutils"
|
||||
"github.com/grafana/grafana/pkg/registry/apis/secret/xkube"
|
||||
"github.com/grafana/grafana/pkg/storage/secret/encryption"
|
||||
"github.com/mitchellh/copystructure"
|
||||
"github.com/stretchr/testify/require"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
@@ -52,7 +50,7 @@ func TestBasic(t *testing.T) {
|
||||
sv, err := sut.CreateSv(t.Context())
|
||||
require.NoError(t, err)
|
||||
|
||||
keeperCfg, err := sut.KeeperMetadataStorage.GetKeeperConfig(t.Context(), sv.Namespace, sv.Spec.Keeper, contracts.ReadOpts{ForUpdate: false})
|
||||
keeperCfg, err := sut.KeeperMetadataStorage.GetKeeperConfig(t.Context(), sv.Namespace, sv.Status.Keeper, contracts.ReadOpts{ForUpdate: false})
|
||||
require.NoError(t, err)
|
||||
|
||||
keeper, err := sut.KeeperService.KeeperForConfig(keeperCfg)
|
||||
@@ -133,7 +131,7 @@ func TestProperty(t *testing.T) {
|
||||
t.Repeat(map[string]func(*rapid.T){
|
||||
"create": func(t *rapid.T) {
|
||||
sv := anySecureValueGen.Draw(t, "sv")
|
||||
svCopy := deepCopy(sv)
|
||||
svCopy := sv.DeepCopy()
|
||||
|
||||
createdSv, err := sut.CreateSv(t.Context(), testutils.CreateSvWithSv(sv))
|
||||
svCopy.UID = createdSv.UID
|
||||
@@ -194,13 +192,15 @@ func newModel() *model {
|
||||
}
|
||||
|
||||
func (m *model) create(now time.Time, sv *secretv1beta1.SecureValue) error {
|
||||
created := now
|
||||
for _, item := range m.items {
|
||||
if item.active && item.Namespace == sv.Namespace && item.Name == sv.Name {
|
||||
item.active = false
|
||||
created = item.created
|
||||
break
|
||||
}
|
||||
}
|
||||
m.items = append(m.items, &modelSecureValue{SecureValue: sv, active: true, created: now})
|
||||
m.items = append(m.items, &modelSecureValue{SecureValue: sv, active: true, created: created})
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -219,6 +219,16 @@ func (m *model) cleanupInactiveSecureValues(now time.Time, minAge time.Duration,
|
||||
// Using a slice to allow duplicates
|
||||
toDelete := make([]*modelSecureValue, 0)
|
||||
|
||||
// The implementation query sorts by created time ascending
|
||||
slices.SortFunc(m.items, func(a, b *modelSecureValue) int {
|
||||
if a.created.Before(b.created) {
|
||||
return -1
|
||||
} else if a.created.After(b.created) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
for _, sv := range m.items {
|
||||
if len(toDelete) >= int(maxBatchSize) {
|
||||
break
|
||||
@@ -238,11 +248,3 @@ func (m *model) cleanupInactiveSecureValues(now time.Time, minAge time.Duration,
|
||||
|
||||
return toDelete, nil
|
||||
}
|
||||
|
||||
func deepCopy[T any](sv T) T {
|
||||
copied, err := copystructure.Copy(sv)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("failed to copy secure value: %v", err))
|
||||
}
|
||||
return copied.(T)
|
||||
}
|
||||
|
||||
@@ -93,7 +93,13 @@ func (s *SecureValueService) Create(ctx context.Context, sv *secretv1beta1.Secur
|
||||
s.metrics.SecureValueCreateDuration.WithLabelValues(strconv.FormatBool(success)).Observe(time.Since(start).Seconds())
|
||||
}()
|
||||
|
||||
return s.createNewVersion(ctx, sv, actorUID)
|
||||
// Secure value creation uses the active keeper
|
||||
keeperName, keeperCfg, err := s.keeperMetadataStorage.GetActiveKeeperConfig(ctx, sv.Namespace)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching active keeper config: namespace=%+v %w", sv.Namespace, err)
|
||||
}
|
||||
|
||||
return s.createNewVersion(ctx, keeperName, keeperCfg, sv, actorUID)
|
||||
}
|
||||
|
||||
func (s *SecureValueService) Update(ctx context.Context, newSecureValue *secretv1beta1.SecureValue, actorUID string) (_ *secretv1beta1.SecureValue, sync bool, updateErr error) {
|
||||
@@ -128,23 +134,22 @@ func (s *SecureValueService) Update(ctx context.Context, newSecureValue *secretv
|
||||
s.metrics.SecureValueUpdateDuration.WithLabelValues(strconv.FormatBool(success)).Observe(time.Since(start).Seconds())
|
||||
}()
|
||||
|
||||
currentVersion, err := s.secureValueMetadataStorage.Read(ctx, xkube.Namespace(newSecureValue.Namespace), newSecureValue.Name, contracts.ReadOpts{})
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("reading secure value secret: %+w", err)
|
||||
}
|
||||
|
||||
keeperCfg, err := s.keeperMetadataStorage.GetKeeperConfig(ctx, currentVersion.Namespace, currentVersion.Status.Keeper, contracts.ReadOpts{})
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("fetching keeper config: namespace=%+v keeper: %q %w", newSecureValue.Namespace, currentVersion.Status.Keeper, err)
|
||||
}
|
||||
|
||||
if newSecureValue.Spec.Value == nil {
|
||||
currentVersion, err := s.secureValueMetadataStorage.Read(ctx, xkube.Namespace(newSecureValue.Namespace), newSecureValue.Name, contracts.ReadOpts{})
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("reading secure value secret: %+w", err)
|
||||
}
|
||||
|
||||
// TODO: does this need to be for update?
|
||||
keeperCfg, err := s.keeperMetadataStorage.GetKeeperConfig(ctx, newSecureValue.Namespace, newSecureValue.Spec.Keeper, contracts.ReadOpts{ForUpdate: true})
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("fetching keeper config: namespace=%+v keeperName=%+v %w", newSecureValue.Namespace, newSecureValue.Spec.Keeper, err)
|
||||
}
|
||||
|
||||
keeper, err := s.keeperService.KeeperForConfig(keeperCfg)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("getting keeper for config: namespace=%+v keeperName=%+v %w", newSecureValue.Namespace, newSecureValue.Spec.Keeper, err)
|
||||
return nil, false, fmt.Errorf("getting keeper for config: namespace=%+v keeperName=%+v %w", newSecureValue.Namespace, newSecureValue.Status.Keeper, err)
|
||||
}
|
||||
logging.FromContext(ctx).Debug("retrieved keeper", "namespace", newSecureValue.Namespace, "keeperName", newSecureValue.Spec.Keeper, "type", keeperCfg.Type())
|
||||
logging.FromContext(ctx).Debug("retrieved keeper", "namespace", newSecureValue.Namespace, "type", keeperCfg.Type())
|
||||
|
||||
secret, err := keeper.Expose(ctx, keeperCfg, xkube.Namespace(newSecureValue.Namespace), newSecureValue.Name, currentVersion.Status.Version)
|
||||
if err != nil {
|
||||
@@ -154,12 +159,16 @@ func (s *SecureValueService) Update(ctx context.Context, newSecureValue *secretv
|
||||
newSecureValue.Spec.Value = &secret
|
||||
}
|
||||
|
||||
// Secure value updates use the keeper used to create the secure value
|
||||
const updateIsSync = true
|
||||
createdSv, err := s.createNewVersion(ctx, newSecureValue, actorUID)
|
||||
createdSv, err := s.createNewVersion(ctx, currentVersion.Status.Keeper, keeperCfg, newSecureValue, actorUID)
|
||||
return createdSv, updateIsSync, err
|
||||
}
|
||||
|
||||
func (s *SecureValueService) createNewVersion(ctx context.Context, sv *secretv1beta1.SecureValue, actorUID string) (*secretv1beta1.SecureValue, error) {
|
||||
func (s *SecureValueService) createNewVersion(ctx context.Context, keeperName string, keeperCfg secretv1beta1.KeeperConfig, sv *secretv1beta1.SecureValue, actorUID string) (*secretv1beta1.SecureValue, error) {
|
||||
if keeperName == "" {
|
||||
return nil, fmt.Errorf("keeper name is required, got empty string")
|
||||
}
|
||||
if err := s.secureValueMutator.Mutate(sv, admission.Create); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -168,25 +177,21 @@ func (s *SecureValueService) createNewVersion(ctx context.Context, sv *secretv1b
|
||||
return nil, contracts.NewErrValidateSecureValue(errorList)
|
||||
}
|
||||
|
||||
createdSv, err := s.secureValueMetadataStorage.Create(ctx, sv, actorUID)
|
||||
createdSv, err := s.secureValueMetadataStorage.Create(ctx, keeperName, sv, actorUID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating secure value: %w", err)
|
||||
}
|
||||
|
||||
createdSv.Status = secretv1beta1.SecureValueStatus{
|
||||
Version: createdSv.Status.Version,
|
||||
}
|
||||
|
||||
// TODO: does this need to be for update?
|
||||
keeperCfg, err := s.keeperMetadataStorage.GetKeeperConfig(ctx, createdSv.Namespace, createdSv.Spec.Keeper, contracts.ReadOpts{ForUpdate: true})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetching keeper config: namespace=%+v keeperName=%+v %w", createdSv.Namespace, createdSv.Spec.Keeper, err)
|
||||
Keeper: keeperName,
|
||||
}
|
||||
|
||||
keeper, err := s.keeperService.KeeperForConfig(keeperCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting keeper for config: namespace=%+v keeperName=%+v %w", createdSv.Namespace, createdSv.Spec.Keeper, err)
|
||||
return nil, fmt.Errorf("getting keeper for config: namespace=%+v keeperName=%+v %w", createdSv.Namespace, keeperName, err)
|
||||
}
|
||||
logging.FromContext(ctx).Debug("retrieved keeper", "namespace", createdSv.Namespace, "keeperName", createdSv.Spec.Keeper, "type", keeperCfg.Type())
|
||||
logging.FromContext(ctx).Debug("retrieved keeper", "namespace", createdSv.Namespace, "type", keeperCfg.Type())
|
||||
|
||||
// TODO: can we stop using external id?
|
||||
// TODO: store uses only the namespace and returns and id. It could be a kv instead.
|
||||
@@ -364,3 +369,10 @@ func (s *SecureValueService) Delete(ctx context.Context, namespace xkube.Namespa
|
||||
|
||||
return sv, nil
|
||||
}
|
||||
|
||||
func (s *SecureValueService) SetKeeperAsActive(ctx context.Context, namespace xkube.Namespace, name string) error {
|
||||
if err := s.keeperMetadataStorage.SetAsActive(ctx, namespace, name); err != nil {
|
||||
return fmt.Errorf("calling keeper metadata storage to set keeper as active: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -41,6 +41,10 @@ func (v *keeperValidator) Validate(keeper *secretv1beta1.Keeper, oldKeeper *secr
|
||||
return errs
|
||||
}
|
||||
|
||||
if keeper.Name == contracts.SystemKeeperName {
|
||||
errs = append(errs, field.Forbidden(field.NewPath("name"), "the keeper name `system` is reserved"))
|
||||
}
|
||||
|
||||
if keeper.Spec.Description == "" {
|
||||
errs = append(errs, field.Required(field.NewPath("spec", "description"), "a `description` is required"))
|
||||
}
|
||||
|
||||
@@ -35,36 +35,6 @@ func TestValidateKeeper(t *testing.T) {
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("only one `keeper` must be present", func(t *testing.T) {
|
||||
keeper := &secretv1beta1.Keeper{
|
||||
ObjectMeta: objectMeta,
|
||||
Spec: secretv1beta1.KeeperSpec{
|
||||
Description: "short description",
|
||||
Aws: &secretv1beta1.KeeperAWSConfig{},
|
||||
Azure: &secretv1beta1.KeeperAzureConfig{},
|
||||
Gcp: &secretv1beta1.KeeperGCPConfig{},
|
||||
HashiCorpVault: &secretv1beta1.KeeperHashiCorpConfig{},
|
||||
},
|
||||
}
|
||||
|
||||
errs := validator.Validate(keeper, nil, admission.Create)
|
||||
require.Len(t, errs, 1)
|
||||
require.Equal(t, "spec", errs[0].Field)
|
||||
})
|
||||
|
||||
t.Run("at least one `keeper` must be present", func(t *testing.T) {
|
||||
keeper := &secretv1beta1.Keeper{
|
||||
ObjectMeta: objectMeta,
|
||||
Spec: secretv1beta1.KeeperSpec{
|
||||
Description: "description",
|
||||
},
|
||||
}
|
||||
|
||||
errs := validator.Validate(keeper, nil, admission.Create)
|
||||
require.Len(t, errs, 1)
|
||||
require.Equal(t, "spec", errs[0].Field)
|
||||
})
|
||||
|
||||
t.Run("aws keeper validation", func(t *testing.T) {
|
||||
validKeeperAWS := &secretv1beta1.Keeper{
|
||||
ObjectMeta: objectMeta,
|
||||
@@ -341,4 +311,27 @@ func TestValidateKeeper(t *testing.T) {
|
||||
require.Len(t, errs, 1)
|
||||
require.Equal(t, "metadata.namespace", errs[0].Field)
|
||||
})
|
||||
|
||||
t.Run("keeper name `system` is reserved", func(t *testing.T) {
|
||||
keeper := &secretv1beta1.Keeper{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "system",
|
||||
Namespace: "ns1",
|
||||
},
|
||||
Spec: secretv1beta1.KeeperSpec{
|
||||
Description: "description",
|
||||
HashiCorpVault: &secretv1beta1.KeeperHashiCorpConfig{
|
||||
Address: "http://address",
|
||||
Token: secretv1beta1.KeeperCredentialValue{
|
||||
ValueFromConfig: "config.path.value",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
errs := validator.Validate(keeper, nil, admission.Create)
|
||||
require.Len(t, errs, 1)
|
||||
require.Equal(t, "name", errs[0].Field)
|
||||
require.Equal(t, "the keeper name `system` is reserved", errs[0].Detail)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -110,11 +110,6 @@ func validateSecureValueUpdate(sv, oldSv *secretv1beta1.SecureValue) field.Error
|
||||
}
|
||||
}
|
||||
|
||||
// Keeper cannot be changed.
|
||||
if sv.Spec.Keeper != oldSv.Spec.Keeper {
|
||||
errs = append(errs, field.Forbidden(field.NewPath("spec"), "the `keeper` cannot be changed"))
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
|
||||
@@ -25,9 +25,9 @@ func TestValidateSecureValue(t *testing.T) {
|
||||
Spec: secretv1beta1.SecureValueSpec{
|
||||
Description: "description",
|
||||
Value: ptr.To(secretv1beta1.NewExposedSecureValue("value")),
|
||||
Keeper: &keeper,
|
||||
Decrypters: []string{"app1", "app2"},
|
||||
},
|
||||
Status: secretv1beta1.SecureValueStatus{Keeper: keeper},
|
||||
}
|
||||
|
||||
t.Run("the `description` must be present", func(t *testing.T) {
|
||||
@@ -182,28 +182,6 @@ func TestValidateSecureValue(t *testing.T) {
|
||||
require.Len(t, errs, 1)
|
||||
require.Equal(t, "spec", errs[0].Field)
|
||||
})
|
||||
|
||||
t.Run("when trying to change the `keeper`, it returns an error", func(t *testing.T) {
|
||||
keeperA := "a-keeper"
|
||||
keeperAnother := "another-keeper"
|
||||
oldSv := &secretv1beta1.SecureValue{
|
||||
ObjectMeta: objectMeta,
|
||||
Spec: secretv1beta1.SecureValueSpec{
|
||||
Keeper: &keeperA,
|
||||
},
|
||||
}
|
||||
|
||||
sv := &secretv1beta1.SecureValue{
|
||||
ObjectMeta: objectMeta,
|
||||
Spec: secretv1beta1.SecureValueSpec{
|
||||
Keeper: &keeperAnother,
|
||||
},
|
||||
}
|
||||
|
||||
errs := validator.Validate(sv, oldSv, admission.Update)
|
||||
require.Len(t, errs, 1)
|
||||
require.Equal(t, "spec", errs[0].Field)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("`decrypters` must have unique items", func(t *testing.T) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -175,6 +176,28 @@ func TestIntegrationDistributor(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RebuildIndexes", func(t *testing.T) {
|
||||
instanceResponseCount := make(map[string]int)
|
||||
|
||||
// simulate RebuildIndexes for a single namespace
|
||||
testNamespace := testNamespaces[0]
|
||||
|
||||
req := &resourcepb.RebuildIndexesRequest{
|
||||
Namespace: testNamespace,
|
||||
Keys: []*resourcepb.ResourceKey{{
|
||||
Namespace: testNamespace,
|
||||
Group: "folder.grafana.app",
|
||||
Resource: "folders",
|
||||
}},
|
||||
}
|
||||
distributorRes := getDistributorResponse(t, req, distributorServer.resourceClient.RebuildIndexes, instanceResponseCount)
|
||||
require.Nil(t, distributorRes.Error)
|
||||
|
||||
// assert all instances got the response by looking at the merged details
|
||||
count := strings.Count(distributorRes.Details, "{instance:")
|
||||
require.Equal(t, len(testServers), count)
|
||||
})
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for _, testServer := range testServers {
|
||||
wg.Add(1)
|
||||
|
||||
Generated
+6
-6
@@ -400,7 +400,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
|
||||
prometheusService := prometheus.ProvideService(httpclientProvider)
|
||||
tempoService := tempo.ProvideService(httpclientProvider, tracer)
|
||||
testdatasourceService := testdatasource.ProvideService()
|
||||
postgresService := postgres.ProvideService(cfg)
|
||||
postgresService := postgres.ProvideService()
|
||||
mysqlService := mysql.ProvideService()
|
||||
mssqlService := mssql.ProvideService(cfg)
|
||||
entityEventsService := store.ProvideEntityEventsService(cfg, sqlStore, featureToggles)
|
||||
@@ -532,9 +532,9 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
migrationDashboardAccessor := legacy.ProvideMigratorDashboardAccessor(legacyDatabaseProvider, stubProvisioningService, accessControl, featureToggles)
|
||||
migrationDashboardAccessor := legacy.ProvideMigratorDashboardAccessor(legacyDatabaseProvider, stubProvisioningService, accessControl)
|
||||
unifiedMigrator := migrations2.ProvideUnifiedMigrator(migrationDashboardAccessor, resourceClient)
|
||||
unifiedStorageMigrationService := migrations2.ProvideUnifiedStorageMigrationService(unifiedMigrator, cfg, sqlStore, kvStore)
|
||||
unifiedStorageMigrationService := migrations2.ProvideUnifiedStorageMigrationService(unifiedMigrator, cfg, sqlStore, kvStore, resourceClient)
|
||||
dualwriteService, err := dualwrite.ProvideService(featureToggles, kvStore, cfg, unifiedStorageMigrationService)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1047,7 +1047,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
|
||||
prometheusService := prometheus.ProvideService(httpclientProvider)
|
||||
tempoService := tempo.ProvideService(httpclientProvider, tracer)
|
||||
testdatasourceService := testdatasource.ProvideService()
|
||||
postgresService := postgres.ProvideService(cfg)
|
||||
postgresService := postgres.ProvideService()
|
||||
mysqlService := mysql.ProvideService()
|
||||
mssqlService := mssql.ProvideService(cfg)
|
||||
entityEventsService := store.ProvideEntityEventsService(cfg, sqlStore, featureToggles)
|
||||
@@ -1179,9 +1179,9 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
migrationDashboardAccessor := legacy.ProvideMigratorDashboardAccessor(legacyDatabaseProvider, stubProvisioningService, accessControl, featureToggles)
|
||||
migrationDashboardAccessor := legacy.ProvideMigratorDashboardAccessor(legacyDatabaseProvider, stubProvisioningService, accessControl)
|
||||
unifiedMigrator := migrations2.ProvideUnifiedMigrator(migrationDashboardAccessor, resourceClient)
|
||||
unifiedStorageMigrationService := migrations2.ProvideUnifiedStorageMigrationService(unifiedMigrator, cfg, sqlStore, kvStore)
|
||||
unifiedStorageMigrationService := migrations2.ProvideUnifiedStorageMigrationService(unifiedMigrator, cfg, sqlStore, kvStore, resourceClient)
|
||||
dualwriteService, err := dualwrite.ProvideService(featureToggles, kvStore, cfg, unifiedStorageMigrationService)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -741,6 +741,7 @@ func (s *Service) SaveExternalServiceRole(ctx context.Context, cmd accesscontrol
|
||||
ctx, span := tracer.Start(ctx, "accesscontrol.acimpl.SaveExternalServiceRole")
|
||||
defer span.End()
|
||||
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if !s.cfg.ManagedServiceAccountsEnabled || !s.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAccounts) {
|
||||
s.log.Debug("Registering an external service role is behind a feature flag, enable it to use this feature.")
|
||||
return nil
|
||||
@@ -757,6 +758,7 @@ func (s *Service) DeleteExternalServiceRole(ctx context.Context, externalService
|
||||
ctx, span := tracer.Start(ctx, "accesscontrol.acimpl.DeleteExternalServiceRole")
|
||||
defer span.End()
|
||||
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if !s.cfg.ManagedServiceAccountsEnabled || !s.features.IsEnabled(ctx, featuremgmt.FlagExternalServiceAccounts) {
|
||||
s.log.Debug("Deleting an external service role is behind a feature flag, enable it to use this feature.")
|
||||
return nil
|
||||
|
||||
@@ -28,6 +28,7 @@ var DashboardEditActions = append(DashboardViewActions, []string{dashboards.Acti
|
||||
var DashboardAdminActions = append(DashboardEditActions, []string{dashboards.ActionDashboardsPermissionsRead, dashboards.ActionDashboardsPermissionsWrite}...)
|
||||
|
||||
func getDashboardViewActions(features featuremgmt.FeatureToggles) []string {
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if features.IsEnabled(context.Background(), featuremgmt.FlagAnnotationPermissionUpdate) {
|
||||
return append(DashboardViewActions, accesscontrol.ActionAnnotationsRead)
|
||||
}
|
||||
@@ -35,6 +36,7 @@ func getDashboardViewActions(features featuremgmt.FeatureToggles) []string {
|
||||
}
|
||||
|
||||
func getDashboardEditActions(features featuremgmt.FeatureToggles) []string {
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if features.IsEnabled(context.Background(), featuremgmt.FlagAnnotationPermissionUpdate) {
|
||||
return append(DashboardEditActions, []string{accesscontrol.ActionAnnotationsRead, accesscontrol.ActionAnnotationsWrite, accesscontrol.ActionAnnotationsDelete, accesscontrol.ActionAnnotationsCreate}...)
|
||||
}
|
||||
@@ -42,6 +44,7 @@ func getDashboardEditActions(features featuremgmt.FeatureToggles) []string {
|
||||
}
|
||||
|
||||
func getDashboardAdminActions(features featuremgmt.FeatureToggles) []string {
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if features.IsEnabled(context.Background(), featuremgmt.FlagAnnotationPermissionUpdate) {
|
||||
return append(DashboardAdminActions, []string{accesscontrol.ActionAnnotationsRead, accesscontrol.ActionAnnotationsWrite, accesscontrol.ActionAnnotationsDelete, accesscontrol.ActionAnnotationsCreate}...)
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ func (authz *AuthService) Authorize(ctx context.Context, query annotations.ItemQ
|
||||
scopeTypes := annotationScopeTypes(scopes)
|
||||
_, canAccessOrgAnnotations := scopeTypes[annotations.Organization.String()]
|
||||
_, canAccessDashAnnotations := scopeTypes[annotations.Dashboard.String()]
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if authz.features.IsEnabled(ctx, featuremgmt.FlagAnnotationPermissionUpdate) {
|
||||
canAccessDashAnnotations = true
|
||||
}
|
||||
@@ -122,6 +123,7 @@ func (authz *AuthService) dashboardsWithVisibleAnnotations(ctx context.Context,
|
||||
}
|
||||
|
||||
filterType := searchstore.TypeDashboard
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if authz.features.IsEnabled(ctx, featuremgmt.FlagAnnotationPermissionUpdate) {
|
||||
filterType = searchstore.TypeAnnotation
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package authorizer
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
|
||||
@@ -12,6 +13,10 @@ import (
|
||||
|
||||
var _ authorizer.Authorizer = &roleAuthorizer{}
|
||||
|
||||
var orgRoleNoneAsViewerAPIGroups = []string{
|
||||
"productactivation.ext.grafana.com",
|
||||
}
|
||||
|
||||
type roleAuthorizer struct{}
|
||||
|
||||
func newRoleAuthorizer() *roleAuthorizer {
|
||||
@@ -43,6 +48,16 @@ func (auth roleAuthorizer) Authorize(ctx context.Context, a authorizer.Attribute
|
||||
return authorizer.DecisionDeny, errorMessageForGrafanaOrgRole(orgRole, a), nil
|
||||
}
|
||||
case org.RoleNone:
|
||||
// HOTFIX: granting Viewer actions to None roles to a fixed group of APIs,
|
||||
// while we work on a proper fix.
|
||||
if slices.Contains(orgRoleNoneAsViewerAPIGroups, a.GetAPIGroup()) {
|
||||
switch a.GetVerb() {
|
||||
case "get", "list", "watch":
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
default:
|
||||
return authorizer.DecisionDeny, errorMessageForGrafanaOrgRole(orgRole, a), nil
|
||||
}
|
||||
}
|
||||
return authorizer.DecisionDeny, errorMessageForGrafanaOrgRole(orgRole, a), nil
|
||||
}
|
||||
return authorizer.DecisionDeny, "", nil
|
||||
|
||||
@@ -83,6 +83,7 @@ func ProvideRegistration(
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if cfg.PasswordlessMagicLinkAuth.Enabled && features.IsEnabled(context.Background(), featuremgmt.FlagPasswordlessMagicLinkAuthentication) {
|
||||
hasEnabledProviders := authnSvc.IsClientEnabled(authn.ClientSAML) || authnSvc.IsClientEnabled(authn.ClientLDAP)
|
||||
if !hasEnabledProviders {
|
||||
|
||||
@@ -210,6 +210,7 @@ func (c *CachingServiceClient) WithQueryDataCaching(ctx context.Context, req *ba
|
||||
// Update the query cache with the result for this metrics request
|
||||
if err == nil && cr.UpdateCacheFn != nil {
|
||||
// If AWS async caching is not enabled, use the old code path
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if c.features == nil || !c.features.IsEnabled(ctx, featuremgmt.FlagAwsAsyncQueryCaching) {
|
||||
cr.UpdateCacheFn(ctx, resp)
|
||||
} else if reqCtx != nil {
|
||||
|
||||
@@ -61,7 +61,6 @@ type Service struct {
|
||||
|
||||
isSyncSnapshotStatusFromGMSRunning int32
|
||||
|
||||
features featuremgmt.FeatureToggles
|
||||
gmsClient gmsclient.Client
|
||||
objectStorage objectstorage.ObjectStorage
|
||||
|
||||
@@ -119,8 +118,7 @@ func ProvideService(
|
||||
libraryElementsService libraryelements.Service,
|
||||
ngAlert *ngalert.AlertNG,
|
||||
) (cloudmigration.Service, error) {
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if !features.IsEnabledGlobally(featuremgmt.FlagOnPremToCloudMigrations) {
|
||||
if !cfg.CloudMigration.Enabled {
|
||||
return &NoopServiceImpl{}, nil
|
||||
}
|
||||
|
||||
@@ -132,7 +130,6 @@ func ProvideService(
|
||||
store: &sqlStore{db: db, secretsStore: secretsStore, secretsService: secretsService},
|
||||
log: log.New(LogPrefix),
|
||||
cfg: cfg,
|
||||
features: features,
|
||||
dsService: dsService,
|
||||
tracer: tracer,
|
||||
metrics: newMetrics(),
|
||||
|
||||
@@ -907,6 +907,7 @@ func setUpServiceTest(t *testing.T, cfgOverrides ...configOverrides) cloudmigrat
|
||||
_, err = section.NewKey("domain", "localhost:1234")
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg.CloudMigration.Enabled = true
|
||||
cfg.CloudMigration.IsDeveloperMode = true // ensure local implementations are used
|
||||
cfg.CloudMigration.SnapshotFolder = filepath.Join(os.TempDir(), uuid.NewString())
|
||||
|
||||
@@ -919,15 +920,11 @@ func setUpServiceTest(t *testing.T, cfgOverrides ...configOverrides) cloudmigrat
|
||||
},
|
||||
}
|
||||
|
||||
featureToggles := featuremgmt.WithFeatures(
|
||||
featuremgmt.FlagOnPremToCloudMigrations,
|
||||
)
|
||||
featureToggles := featuremgmt.WithFeatures()
|
||||
|
||||
sqlStore := sqlstore.NewTestStore(t,
|
||||
sqlstore.WithCfg(cfg),
|
||||
sqlstore.WithFeatureFlags(
|
||||
featuremgmt.FlagOnPremToCloudMigrations,
|
||||
),
|
||||
sqlstore.WithFeatureFlags(),
|
||||
)
|
||||
|
||||
kvStore := kvstore.ProvideService(sqlStore)
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
@@ -43,7 +42,6 @@ func TestGetAlertMuteTimings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := setUpServiceTest(t).(*Service)
|
||||
s.features = featuremgmt.WithFeatures(featuremgmt.FlagOnPremToCloudMigrations)
|
||||
|
||||
user := &user.SignedInUser{OrgID: 1}
|
||||
|
||||
|
||||
@@ -207,6 +207,33 @@ type AuthHTTPHeaderList struct {
|
||||
Items []string
|
||||
}
|
||||
|
||||
func GetAuthHTTPHeaders(jwtAuth *setting.AuthJWTSettings, authProxy *setting.AuthProxySettings) []string {
|
||||
var items []string
|
||||
|
||||
// used by basic auth, api keys and potentially jwt auth
|
||||
items = append(items, "Authorization")
|
||||
|
||||
// remove X-Grafana-Device-Id as it is only used for auth in authn clients.
|
||||
items = append(items, "X-Grafana-Device-Id")
|
||||
|
||||
// if jwt is enabled we add it to the list. We can ignore in case it is set to Authorization
|
||||
if jwtAuth.Enabled && jwtAuth.HeaderName != "" && jwtAuth.HeaderName != "Authorization" {
|
||||
items = append(items, jwtAuth.HeaderName)
|
||||
}
|
||||
|
||||
// if auth proxy is enabled add the main proxy header and all configured headers
|
||||
if authProxy.Enabled {
|
||||
items = append(items, authProxy.HeaderName)
|
||||
for _, header := range authProxy.Headers {
|
||||
if header != "" {
|
||||
items = append(items, header)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
// WithAuthHTTPHeaders returns a new context in which all possible configured auth header will be included
|
||||
// and later retrievable by AuthHTTPHeaderListFromContext.
|
||||
func WithAuthHTTPHeaders(ctx context.Context, cfg *setting.Cfg) context.Context {
|
||||
@@ -217,26 +244,7 @@ func WithAuthHTTPHeaders(ctx context.Context, cfg *setting.Cfg) context.Context
|
||||
}
|
||||
}
|
||||
|
||||
// used by basic auth, api keys and potentially jwt auth
|
||||
list.Items = append(list.Items, "Authorization")
|
||||
|
||||
// remove X-Grafana-Device-Id as it is only used for auth in authn clients.
|
||||
list.Items = append(list.Items, "X-Grafana-Device-Id")
|
||||
|
||||
// if jwt is enabled we add it to the list. We can ignore in case it is set to Authorization
|
||||
if cfg.JWTAuth.Enabled && cfg.JWTAuth.HeaderName != "" && cfg.JWTAuth.HeaderName != "Authorization" {
|
||||
list.Items = append(list.Items, cfg.JWTAuth.HeaderName)
|
||||
}
|
||||
|
||||
// if auth proxy is enabled add the main proxy header and all configured headers
|
||||
if cfg.AuthProxy.Enabled {
|
||||
list.Items = append(list.Items, cfg.AuthProxy.HeaderName)
|
||||
for _, header := range cfg.AuthProxy.Headers {
|
||||
if header != "" {
|
||||
list.Items = append(list.Items, header)
|
||||
}
|
||||
}
|
||||
}
|
||||
list.Items = append(list.Items, GetAuthHTTPHeaders(&cfg.JWTAuth, &cfg.AuthProxy)...)
|
||||
|
||||
return context.WithValue(ctx, authHTTPHeaderListKey, list)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ import (
|
||||
type FeatureToggles interface {
|
||||
// IsEnabled checks if a feature is enabled for a given context.
|
||||
// The settings may be per user, tenant, or globally set in the cloud
|
||||
//
|
||||
// Deprecated: FeatureToggles.IsEnabled is deprecated and will be removed in a future release.
|
||||
// Evaluate with OpenFeature instead (see [github.com/open-feature/go-sdk/openfeature.Client]), for example:
|
||||
// openfeature.NewDefaultClient().Boolean(ctx, "your-flag", false, openfeature.TransactionContext(ctx))
|
||||
IsEnabled(ctx context.Context, flag string) bool
|
||||
|
||||
// IsEnabledGlobally checks if a flag is configured globally. For now, this is the same
|
||||
@@ -19,7 +23,9 @@ type FeatureToggles interface {
|
||||
// a full server restart for a change to take place.
|
||||
//
|
||||
// Deprecated: FeatureToggles.IsEnabledGlobally is deprecated and will be removed in a future release.
|
||||
// Evaluate with OpenFeature instead (see [github.com/open-feature/go-sdk/openfeature.Client])
|
||||
// Toggles that must be reliably evaluated at the service startup should be
|
||||
// changed to settings (see setting.StartupSettings), and/or removed entirely.
|
||||
// For app registration please use `grafana-apiserver.runtime_config` in settings.ini
|
||||
IsEnabledGlobally(flag string) bool
|
||||
|
||||
// Get the enabled flags -- this *may* also include disabled flags (with value false)
|
||||
|
||||
@@ -527,6 +527,13 @@ var (
|
||||
Owner: grafanaAlertingSquad,
|
||||
HideFromDocs: true,
|
||||
},
|
||||
{
|
||||
Name: "alertingUIUseFullyCompatBackendFilters",
|
||||
Description: "Enables the UI to use rules backend-side filters 100% compatible with the frontend filters",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaAlertingSquad,
|
||||
HideFromDocs: true,
|
||||
},
|
||||
{
|
||||
Name: "alertmanagerRemotePrimary",
|
||||
Description: "Enable Grafana to have a remote Alertmanager instance as the primary Alertmanager.",
|
||||
@@ -586,6 +593,13 @@ var (
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaDashboardsSquad,
|
||||
},
|
||||
{
|
||||
Name: "perPanelNonApplicableDrilldowns",
|
||||
Description: "Enables viewing non-applicable drilldowns on a panel level",
|
||||
Stage: FeatureStageExperimental,
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaDashboardsSquad,
|
||||
},
|
||||
{
|
||||
Name: "panelFilterVariable",
|
||||
Description: "Enables use of the `systemPanelFilterVar` variable to filter panels in a dashboard",
|
||||
@@ -615,14 +629,6 @@ var (
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaDatavizSquad,
|
||||
},
|
||||
{
|
||||
Name: "logsInfiniteScrolling",
|
||||
Description: "Enables infinite scrolling for the Logs panel in Explore and Dashboards",
|
||||
Stage: FeatureStageGeneralAvailability,
|
||||
Expression: "true",
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaObservabilityLogsSquad,
|
||||
},
|
||||
{
|
||||
Name: "tableSharedCrosshair",
|
||||
Description: "Enables shared crosshair in table panel",
|
||||
@@ -661,13 +667,6 @@ var (
|
||||
HideFromDocs: true,
|
||||
RequiresRestart: true,
|
||||
},
|
||||
{
|
||||
Name: "onPremToCloudMigrations",
|
||||
Description: "Enable the Grafana Migration Assistant, which helps you easily migrate various on-prem resources to your Grafana Cloud stack.",
|
||||
Stage: FeatureStageGeneralAvailability,
|
||||
Owner: grafanaOperatorExperienceSquad,
|
||||
Expression: "true",
|
||||
},
|
||||
{
|
||||
Name: "secretsManagementAppPlatform",
|
||||
Description: "Enable the secrets management API and services under app platform",
|
||||
@@ -825,18 +824,25 @@ var (
|
||||
},
|
||||
{
|
||||
Name: "dashboardLibrary",
|
||||
Description: "Enable dashboard library experiments that are production ready",
|
||||
Description: "Displays datasource provisioned dashboards in dashboard empty page, only when coming from datasource configuration page",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaSharingSquad,
|
||||
FrontendOnly: false,
|
||||
},
|
||||
{
|
||||
Name: "suggestedDashboards",
|
||||
Description: "Enable suggested dashboards when creating new dashboards",
|
||||
Description: "Displays datasource provisioned and community dashboards in dashboard empty page, only when coming from datasource configuration page",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaSharingSquad,
|
||||
FrontendOnly: false,
|
||||
},
|
||||
{
|
||||
Name: "dashboardTemplates",
|
||||
Description: "Enables a flow to get started with a new dashboard from a template",
|
||||
Stage: FeatureStagePublicPreview,
|
||||
Owner: grafanaSharingSquad,
|
||||
FrontendOnly: false,
|
||||
},
|
||||
{
|
||||
Name: "logsExploreTableDefaultVisualization",
|
||||
Description: "Sets the logs table as default visualisation in logs explore",
|
||||
@@ -984,7 +990,7 @@ var (
|
||||
},
|
||||
{
|
||||
Name: "exploreLogsShardSplitting",
|
||||
Description: "Used in Logs Drilldown to split queries into multiple queries based on the number of shards",
|
||||
Description: "Deprecated. Replace with lokiShardSplitting. Used in Logs Drilldown to split queries into multiple queries based on the number of shards",
|
||||
Stage: FeatureStageExperimental,
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaObservabilityLogsSquad,
|
||||
@@ -1092,6 +1098,13 @@ var (
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaDatavizSquad,
|
||||
},
|
||||
{
|
||||
Name: "newTimeRangeZoomShortcuts",
|
||||
Description: "Enables new keyboard shortcuts for time range zoom operations",
|
||||
Stage: FeatureStageExperimental,
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaDatavizSquad,
|
||||
},
|
||||
{
|
||||
Name: "azureMonitorDisableLogLimit",
|
||||
Description: "Disables the log limit restriction for Azure Monitor when true. The limit is enabled by default.",
|
||||
@@ -1505,12 +1518,6 @@ var (
|
||||
FrontendOnly: true,
|
||||
Expression: "true",
|
||||
},
|
||||
{
|
||||
Name: "postgresDSUsePGX",
|
||||
Description: "Enables using PGX instead of libpq for PostgreSQL datasource",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaOSSBigTent,
|
||||
},
|
||||
{
|
||||
Name: "tempoAlerting",
|
||||
Description: "Enables creating alerts from Tempo data source",
|
||||
@@ -1904,13 +1911,6 @@ var (
|
||||
RequiresRestart: false,
|
||||
HideFromDocs: false,
|
||||
},
|
||||
{
|
||||
Name: "dashboardTemplates",
|
||||
Description: "Enable template dashboards",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaSharingSquad,
|
||||
FrontendOnly: false,
|
||||
},
|
||||
{
|
||||
Name: "kubernetesAnnotations",
|
||||
Description: "Enables app platform API for annotations",
|
||||
@@ -1939,6 +1939,13 @@ var (
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaPluginsPlatformSquad,
|
||||
},
|
||||
{
|
||||
Name: "lokiQueryLimitsContext",
|
||||
Description: "Send X-Loki-Query-Limits-Context header to Loki on first split request",
|
||||
Stage: FeatureStageExperimental,
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaObservabilityLogsSquad,
|
||||
},
|
||||
{
|
||||
Name: "rudderstackUpgrade",
|
||||
Description: "Enables the new version of rudderstack",
|
||||
|
||||
Generated
+5
-4
@@ -73,6 +73,7 @@ cachingOptimizeSerializationMemoryUsage,experimental,@grafana/grafana-operator-e
|
||||
alertmanagerRemoteSecondary,experimental,@grafana/alerting-squad,false,false,false
|
||||
alertingProvenanceLockWrites,experimental,@grafana/alerting-squad,false,false,false
|
||||
alertingUIUseBackendFilters,experimental,@grafana/alerting-squad,false,false,false
|
||||
alertingUIUseFullyCompatBackendFilters,experimental,@grafana/alerting-squad,false,false,false
|
||||
alertmanagerRemotePrimary,experimental,@grafana/alerting-squad,false,false,false
|
||||
annotationPermissionUpdate,GA,@grafana/identity-access-team,false,false,false
|
||||
dashboardSceneForViewers,GA,@grafana/dashboards-squad,false,false,true
|
||||
@@ -81,17 +82,16 @@ dashboardScene,GA,@grafana/dashboards-squad,false,false,true
|
||||
dashboardNewLayouts,experimental,@grafana/dashboards-squad,false,false,false
|
||||
dashboardUndoRedo,experimental,@grafana/dashboards-squad,false,false,true
|
||||
unlimitedLayoutsNesting,experimental,@grafana/dashboards-squad,false,false,true
|
||||
perPanelNonApplicableDrilldowns,experimental,@grafana/dashboards-squad,false,false,true
|
||||
panelFilterVariable,experimental,@grafana/dashboards-squad,false,false,true
|
||||
pdfTables,preview,@grafana/grafana-operator-experience-squad,false,false,false
|
||||
canvasPanelPanZoom,preview,@grafana/dataviz-squad,false,false,true
|
||||
timeComparison,experimental,@grafana/dataviz-squad,false,false,true
|
||||
logsInfiniteScrolling,GA,@grafana/observability-logs,false,false,true
|
||||
tableSharedCrosshair,experimental,@grafana/dataviz-squad,false,false,true
|
||||
kubernetesFeatureToggles,experimental,@grafana/grafana-operator-experience-squad,false,false,true
|
||||
cloudRBACRoles,preview,@grafana/identity-access-team,false,true,false
|
||||
alertingQueryOptimization,GA,@grafana/alerting-squad,false,false,false
|
||||
jitterAlertRulesWithinGroups,preview,@grafana/alerting-squad,false,true,false
|
||||
onPremToCloudMigrations,GA,@grafana/grafana-operator-experience-squad,false,false,false
|
||||
secretsManagementAppPlatform,experimental,@grafana/grafana-operator-experience-squad,false,false,false
|
||||
secretsManagementAppPlatformUI,experimental,@grafana/grafana-operator-experience-squad,false,false,false
|
||||
alertingSaveStatePeriodic,privatePreview,@grafana/alerting-squad,false,false,false
|
||||
@@ -115,6 +115,7 @@ grafanaManagedRecordingRules,experimental,@grafana/alerting-squad,false,false,fa
|
||||
queryLibrary,preview,@grafana/sharing-squad,false,false,false
|
||||
dashboardLibrary,experimental,@grafana/sharing-squad,false,false,false
|
||||
suggestedDashboards,experimental,@grafana/sharing-squad,false,false,false
|
||||
dashboardTemplates,preview,@grafana/sharing-squad,false,false,false
|
||||
logsExploreTableDefaultVisualization,experimental,@grafana/observability-logs,false,false,true
|
||||
alertingListViewV2,privatePreview,@grafana/alerting-squad,false,false,true
|
||||
alertingDisableSendAlertsExternal,experimental,@grafana/alerting-squad,false,false,false
|
||||
@@ -151,6 +152,7 @@ pluginsSriChecks,GA,@grafana/plugins-platform-backend,false,false,false
|
||||
unifiedStorageBigObjectsSupport,experimental,@grafana/search-and-storage,false,false,false
|
||||
timeRangeProvider,experimental,@grafana/grafana-frontend-platform,false,false,false
|
||||
timeRangePan,experimental,@grafana/dataviz-squad,false,false,true
|
||||
newTimeRangeZoomShortcuts,experimental,@grafana/dataviz-squad,false,false,true
|
||||
azureMonitorDisableLogLimit,GA,@grafana/partner-datasources,false,false,false
|
||||
playlistsReconciler,experimental,@grafana/grafana-app-platform-squad,false,true,false
|
||||
passwordlessMagicLinkAuthentication,experimental,@grafana/identity-access-team,false,false,false
|
||||
@@ -206,7 +208,6 @@ unifiedNavbars,GA,@grafana/plugins-platform-backend,false,false,true
|
||||
logsPanelControls,preview,@grafana/observability-logs,false,false,true
|
||||
metricsFromProfiles,experimental,@grafana/observability-traces-and-profiling,false,false,true
|
||||
grafanaAssistantInProfilesDrilldown,GA,@grafana/observability-traces-and-profiling,false,false,true
|
||||
postgresDSUsePGX,experimental,@grafana/oss-big-tent,false,false,false
|
||||
tempoAlerting,experimental,@grafana/observability-traces-and-profiling,false,false,false
|
||||
pluginsAutoUpdate,experimental,@grafana/plugins-platform-backend,false,false,false
|
||||
alertingListViewV2PreviewToggle,privatePreview,@grafana/alerting-squad,false,false,true
|
||||
@@ -258,9 +259,9 @@ pluginStoreServiceLoading,experimental,@grafana/plugins-platform-backend,false,f
|
||||
newPanelPadding,experimental,@grafana/dashboards-squad,false,false,false
|
||||
onlyStoreActionSets,GA,@grafana/identity-access-team,false,false,false
|
||||
panelTimeSettings,experimental,@grafana/dashboards-squad,false,false,false
|
||||
dashboardTemplates,experimental,@grafana/sharing-squad,false,false,false
|
||||
kubernetesAnnotations,experimental,@grafana/grafana-backend-services-squad,false,false,false
|
||||
awsDatasourcesHttpProxy,experimental,@grafana/aws-datasources,false,false,false
|
||||
transformationsEmptyPlaceholder,preview,@grafana/datapro,false,false,true
|
||||
ttlPluginInstanceManager,experimental,@grafana/plugins-platform-backend,false,false,true
|
||||
lokiQueryLimitsContext,experimental,@grafana/observability-logs,false,false,true
|
||||
rudderstackUpgrade,experimental,@grafana/grafana-frontend-platform,false,false,true
|
||||
|
||||
|
Generated
+10
-14
@@ -243,6 +243,10 @@ const (
|
||||
// Enables the UI to use certain backend-side filters
|
||||
FlagAlertingUIUseBackendFilters = "alertingUIUseBackendFilters"
|
||||
|
||||
// FlagAlertingUIUseFullyCompatBackendFilters
|
||||
// Enables the UI to use rules backend-side filters 100% compatible with the frontend filters
|
||||
FlagAlertingUIUseFullyCompatBackendFilters = "alertingUIUseFullyCompatBackendFilters"
|
||||
|
||||
// FlagAlertmanagerRemotePrimary
|
||||
// Enable Grafana to have a remote Alertmanager instance as the primary Alertmanager.
|
||||
FlagAlertmanagerRemotePrimary = "alertmanagerRemotePrimary"
|
||||
@@ -271,10 +275,6 @@ const (
|
||||
// Distributes alert rule evaluations more evenly over time, including spreading out rules within the same group. Disables sequential evaluation if enabled.
|
||||
FlagJitterAlertRulesWithinGroups = "jitterAlertRulesWithinGroups"
|
||||
|
||||
// FlagOnPremToCloudMigrations
|
||||
// Enable the Grafana Migration Assistant, which helps you easily migrate various on-prem resources to your Grafana Cloud stack.
|
||||
FlagOnPremToCloudMigrations = "onPremToCloudMigrations"
|
||||
|
||||
// FlagSecretsManagementAppPlatform
|
||||
// Enable the secrets management API and services under app platform
|
||||
FlagSecretsManagementAppPlatform = "secretsManagementAppPlatform"
|
||||
@@ -348,13 +348,17 @@ const (
|
||||
FlagQueryLibrary = "queryLibrary"
|
||||
|
||||
// FlagDashboardLibrary
|
||||
// Enable dashboard library experiments that are production ready
|
||||
// Displays datasource provisioned dashboards in dashboard empty page, only when coming from datasource configuration page
|
||||
FlagDashboardLibrary = "dashboardLibrary"
|
||||
|
||||
// FlagSuggestedDashboards
|
||||
// Enable suggested dashboards when creating new dashboards
|
||||
// Displays datasource provisioned and community dashboards in dashboard empty page, only when coming from datasource configuration page
|
||||
FlagSuggestedDashboards = "suggestedDashboards"
|
||||
|
||||
// FlagDashboardTemplates
|
||||
// Enables a flow to get started with a new dashboard from a template
|
||||
FlagDashboardTemplates = "dashboardTemplates"
|
||||
|
||||
// FlagAlertingDisableSendAlertsExternal
|
||||
// Disables the ability to send alerts to an external Alertmanager datasource.
|
||||
FlagAlertingDisableSendAlertsExternal = "alertingDisableSendAlertsExternal"
|
||||
@@ -602,10 +606,6 @@ const (
|
||||
// use multi-tenant path for awsTempCredentials
|
||||
FlagMultiTenantTempCredentials = "multiTenantTempCredentials"
|
||||
|
||||
// FlagPostgresDSUsePGX
|
||||
// Enables using PGX instead of libpq for PostgreSQL datasource
|
||||
FlagPostgresDSUsePGX = "postgresDSUsePGX"
|
||||
|
||||
// FlagTempoAlerting
|
||||
// Enables creating alerts from Tempo data source
|
||||
FlagTempoAlerting = "tempoAlerting"
|
||||
@@ -750,10 +750,6 @@ const (
|
||||
// Enables a new panel time settings drawer
|
||||
FlagPanelTimeSettings = "panelTimeSettings"
|
||||
|
||||
// FlagDashboardTemplates
|
||||
// Enable template dashboards
|
||||
FlagDashboardTemplates = "dashboardTemplates"
|
||||
|
||||
// FlagKubernetesAnnotations
|
||||
// Enables app platform API for annotations
|
||||
FlagKubernetesAnnotations = "kubernetesAnnotations"
|
||||
|
||||
+453
-1297
File diff suppressed because it is too large
Load Diff
@@ -187,7 +187,15 @@
|
||||
// Wrap in an IIFE to avoid polluting the global scope. Intentionally global-scope properties
|
||||
// are explicitly assigned to the `window` object.
|
||||
(() => {
|
||||
// Grafana can only fail to load once
|
||||
// However, it can fail to load in multiple different places
|
||||
// To avoid double reporting the error, we use this boolean to check if we've already failed
|
||||
let hasFailedToBoot = false;
|
||||
window.__grafana_load_failed = function(err) {
|
||||
if (hasFailedToBoot) {
|
||||
return;
|
||||
}
|
||||
hasFailedToBoot = true;
|
||||
console.error('Failed to load Grafana', err);
|
||||
document.querySelector('.fs-variant-loader').classList.add('fs-hidden');
|
||||
document.querySelector('.fs-variant-error').classList.remove('fs-hidden');
|
||||
@@ -356,7 +364,6 @@
|
||||
document.head.appendChild(cssLink);
|
||||
}
|
||||
|
||||
|
||||
window.__grafana_boot_data_promise = initGrafana()
|
||||
window.__grafana_boot_data_promise.catch((err) => {
|
||||
console.error("__grafana_boot_data_promise rejected", err);
|
||||
|
||||
@@ -148,6 +148,7 @@ func (l *LibraryElementService) deleteHandler(c *contextmodel.ReqContext) respon
|
||||
// 404: notFoundError
|
||||
// 500: internalServerError
|
||||
func (l *LibraryElementService) getHandler(c *contextmodel.ReqContext) response.Response {
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if l.features.IsEnabled(c.Req.Context(), featuremgmt.FlagKubernetesLibraryPanels) {
|
||||
l.k8sHandler.getK8sLibraryElement(c)
|
||||
return nil // already handled in the k8s handler
|
||||
|
||||
@@ -44,7 +44,7 @@ func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink
|
||||
Text: "Organizations", SubTitle: "Isolated instances of Grafana running on the same server", Id: "global-orgs", Url: s.cfg.AppSubURL + "/admin/orgs", Icon: "building",
|
||||
})
|
||||
}
|
||||
if hasAccess(cloudmigration.MigrationAssistantAccess) && s.features.IsEnabled(ctx, featuremgmt.FlagOnPremToCloudMigrations) {
|
||||
if hasAccess(cloudmigration.MigrationAssistantAccess) && s.cfg.CloudMigration.Enabled {
|
||||
generalNodeLinks = append(generalNodeLinks, &navtree.NavLink{
|
||||
Text: "Migrate to Grafana Cloud",
|
||||
Id: "migrate-to-cloud",
|
||||
@@ -99,6 +99,7 @@ func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink
|
||||
})
|
||||
}
|
||||
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if (s.cfg.Env == setting.Dev) || s.features.IsEnabled(ctx, featuremgmt.FlagEnableExtensionsAdminPage) && hasAccess(pluginaccesscontrol.AdminAccessEvaluator) {
|
||||
pluginsNodeLinks = append(pluginsNodeLinks, &navtree.NavLink{
|
||||
Text: "Extensions",
|
||||
@@ -147,6 +148,7 @@ func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink
|
||||
})
|
||||
}
|
||||
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if s.license.FeatureEnabled("groupsync") &&
|
||||
s.features.IsEnabled(ctx, featuremgmt.FlagGroupAttributeSync) &&
|
||||
hasAccess(ac.EvalAny(
|
||||
|
||||
@@ -407,6 +407,7 @@ func (s *ServiceImpl) buildDashboardNavLinks(c *contextmodel.ReqContext) []*navt
|
||||
})
|
||||
}
|
||||
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagRestoreDashboards) && (c.GetOrgRole() == org.RoleAdmin || c.IsGrafanaAdmin) {
|
||||
dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{
|
||||
Text: "Recently deleted",
|
||||
@@ -435,6 +436,7 @@ func (s *ServiceImpl) buildAlertNavLinks(c *contextmodel.ReqContext) *navtree.Na
|
||||
hasAccess := ac.HasAccess(s.accessControl, c)
|
||||
var alertChildNavs []*navtree.NavLink
|
||||
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingTriage) {
|
||||
if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleRead), ac.EvalPermission(ac.ActionAlertingRuleExternalRead))) {
|
||||
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
|
||||
@@ -492,6 +494,7 @@ func (s *ServiceImpl) buildAlertNavLinks(c *contextmodel.ReqContext) *navtree.Na
|
||||
alertChildNavs = append(alertChildNavs, &navtree.NavLink{Text: "Alert groups", SubTitle: "See grouped alerts with active notifications", Id: "groups", Url: s.cfg.AppSubURL + "/alerting/groups", Icon: "layer-group"})
|
||||
}
|
||||
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingCentralAlertHistory) {
|
||||
if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleRead))) {
|
||||
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
|
||||
@@ -503,6 +506,7 @@ func (s *ServiceImpl) buildAlertNavLinks(c *contextmodel.ReqContext) *navtree.Na
|
||||
})
|
||||
}
|
||||
}
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if c.GetOrgRole() == org.RoleAdmin && s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertRuleRestore) && s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingRuleRecoverDeleted) {
|
||||
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
|
||||
Text: "Recently deleted",
|
||||
|
||||
@@ -77,6 +77,7 @@ func (srv ConfigSrv) RoutePostNGalertConfig(c *contextmodel.ReqContext, body api
|
||||
return response.Error(http.StatusBadRequest, "Invalid alertmanager choice specified", err)
|
||||
}
|
||||
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
disableExternal := srv.featureManager.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingDisableSendAlertsExternal)
|
||||
if disableExternal && sendAlertsTo != ngmodels.InternalAlertmanager {
|
||||
return response.Error(http.StatusBadRequest, "Sending alerts to external alertmanagers is disallowed on this instance", err)
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/accesscontrol"
|
||||
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/eval"
|
||||
@@ -327,6 +328,7 @@ func withLabels(labels data.Labels) forEachState {
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:gocyclo
|
||||
func TestRouteGetRuleStatuses(t *testing.T) {
|
||||
timeNow = func() time.Time { return time.Date(2022, 3, 10, 14, 0, 0, 0, time.UTC) }
|
||||
orgID := int64(1)
|
||||
@@ -2219,6 +2221,575 @@ func TestRouteGetRuleStatuses(t *testing.T) {
|
||||
require.True(t, foundNoProv, "should find rule without provenance")
|
||||
require.True(t, foundWithProv, "should find rule with provenance")
|
||||
})
|
||||
|
||||
t.Run("filter-aware pagination", func(t *testing.T) {
|
||||
createRulesWithState := func(t *testing.T, store *fakes.RuleStore, aim *fakeAlertInstanceManager,
|
||||
orgID int64, numGroups int, rulesPerGroup int,
|
||||
stateFunc func(groupIdx int) eval.State,
|
||||
healthFunc func(groupIdx int) error,
|
||||
stateMutators ...func(groupIdx int, s *state.State) *state.State) {
|
||||
t.Helper()
|
||||
|
||||
// create folders
|
||||
for i := 1; i <= numGroups; i++ {
|
||||
store.Folders[orgID] = append(store.Folders[orgID], &folder.Folder{
|
||||
ID: int64(i),
|
||||
UID: fmt.Sprintf("ns-%d", i),
|
||||
Title: fmt.Sprintf("Namespace %d", i),
|
||||
Fullpath: fmt.Sprintf("/namespace-%d", i),
|
||||
})
|
||||
}
|
||||
|
||||
for i := 0; i < numGroups; i++ {
|
||||
for j := 0; j < rulesPerGroup; j++ {
|
||||
rule := gen.With(gen.WithOrgID(orgID), func(r *ngmodels.AlertRule) {
|
||||
r.NamespaceUID = fmt.Sprintf("ns-%d", i+1)
|
||||
r.RuleGroup = fmt.Sprintf("group-%d", i+1)
|
||||
r.UID = fmt.Sprintf("rule-%d-%d", i+1, j+1)
|
||||
}, withClassicConditionSingleQuery()).GenerateRef()
|
||||
|
||||
alertState := stateFunc(i)
|
||||
healthErr := healthFunc(i)
|
||||
|
||||
aim.GenerateAlertInstances(orgID, rule.UID, 1, func(s *state.State) *state.State {
|
||||
s.State = alertState
|
||||
s.Error = healthErr
|
||||
s.Labels = data.Labels{"test": "label"}
|
||||
|
||||
for _, mutator := range stateMutators {
|
||||
s = mutator(i, s)
|
||||
}
|
||||
return s
|
||||
})
|
||||
store.PutRule(context.Background(), rule)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("state filter fetches multiple pages to fill group_limit", func(t *testing.T) {
|
||||
fakeStore, fakeAIM, api := setupAPI(t)
|
||||
|
||||
// Create 10 groups (2 rules each = 20 rules total): groups 1,3,5,7,9 firing, groups 2,4,6,8,10 normal
|
||||
// Request group_limit=3 with state=firing should fetch pages until 3 firing groups collected
|
||||
createRulesWithState(t, fakeStore, fakeAIM, orgID, 10, 2,
|
||||
func(i int) eval.State {
|
||||
if i%2 == 0 {
|
||||
return eval.Alerting
|
||||
}
|
||||
return eval.Normal
|
||||
},
|
||||
func(i int) error { return nil })
|
||||
|
||||
// Request 3 groups with state=firing filter
|
||||
req, err := http.NewRequest("GET", "/api/v1/rules?state=firing&group_limit=3", nil)
|
||||
require.NoError(t, err)
|
||||
c := &contextmodel.ReqContext{
|
||||
Context: &web.Context{Req: req},
|
||||
SignedInUser: &user.SignedInUser{
|
||||
OrgID: orgID,
|
||||
Permissions: queryPermissions,
|
||||
},
|
||||
}
|
||||
|
||||
resp := api.RouteGetRuleStatuses(c)
|
||||
require.Equal(t, http.StatusOK, resp.Status())
|
||||
|
||||
var res apimodels.RuleResponse
|
||||
require.NoError(t, json.Unmarshal(resp.Body(), &res))
|
||||
|
||||
// Should return 3 firing groups
|
||||
require.Len(t, res.Data.RuleGroups, 3)
|
||||
require.Equal(t, "group-1", res.Data.RuleGroups[0].Name)
|
||||
require.Equal(t, "group-3", res.Data.RuleGroups[1].Name)
|
||||
require.Equal(t, "group-5", res.Data.RuleGroups[2].Name)
|
||||
|
||||
// Verify all have firing alerts
|
||||
for _, rg := range res.Data.RuleGroups {
|
||||
hasFiring := false
|
||||
for _, rule := range rg.Rules {
|
||||
for _, alert := range rule.Alerts {
|
||||
if alert.State == eval.Alerting.String() {
|
||||
hasFiring = true
|
||||
}
|
||||
}
|
||||
}
|
||||
require.True(t, hasFiring)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("state filter continues when first page has no matches", func(t *testing.T) {
|
||||
fakeStore, fakeAIM, api := setupAPI(t)
|
||||
|
||||
// Create 8 groups (2 rules each = 16 rules total): first 4 normal, last 4 firing
|
||||
// Request state=firing with group_limit=2 should skip first 4 and return groups 5,6
|
||||
createRulesWithState(t, fakeStore, fakeAIM, orgID, 8, 2,
|
||||
func(i int) eval.State {
|
||||
if i < 4 {
|
||||
return eval.Normal // groups 1-4 normal
|
||||
}
|
||||
return eval.Alerting // groups 5-8 firing
|
||||
},
|
||||
func(i int) error { return nil })
|
||||
|
||||
// Request 2 firing groups - should skip past the first page of normal rules
|
||||
req, err := http.NewRequest("GET", "/api/v1/rules?state=firing&group_limit=2", nil)
|
||||
require.NoError(t, err)
|
||||
c := &contextmodel.ReqContext{
|
||||
Context: &web.Context{Req: req},
|
||||
SignedInUser: &user.SignedInUser{
|
||||
OrgID: orgID,
|
||||
Permissions: queryPermissions,
|
||||
},
|
||||
}
|
||||
|
||||
resp := api.RouteGetRuleStatuses(c)
|
||||
require.Equal(t, http.StatusOK, resp.Status())
|
||||
|
||||
var res apimodels.RuleResponse
|
||||
require.NoError(t, json.Unmarshal(resp.Body(), &res))
|
||||
|
||||
// Should return 2 firing groups
|
||||
require.Len(t, res.Data.RuleGroups, 2)
|
||||
require.Equal(t, "group-5", res.Data.RuleGroups[0].Name)
|
||||
require.Equal(t, "group-6", res.Data.RuleGroups[1].Name)
|
||||
})
|
||||
|
||||
t.Run("health filter fetches multiple pages", func(t *testing.T) {
|
||||
fakeStore, fakeAIM, api := setupAPI(t)
|
||||
|
||||
// Create 8 groups (2 rules each = 16 rules total): groups 1,3,5,7 with error health, groups 2,4,6,8 with ok health
|
||||
// Request health=error with group_limit=3 should fetch pages until 3 error groups collected
|
||||
createRulesWithState(t, fakeStore, fakeAIM, orgID, 8, 2,
|
||||
func(i int) eval.State { return eval.Normal },
|
||||
func(i int) error {
|
||||
if i%2 == 0 {
|
||||
return fmt.Errorf("evaluation error")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// Request 3 groups with health=error filter
|
||||
req, err := http.NewRequest("GET", "/api/v1/rules?health=error&group_limit=3", nil)
|
||||
require.NoError(t, err)
|
||||
c := &contextmodel.ReqContext{
|
||||
Context: &web.Context{Req: req},
|
||||
SignedInUser: &user.SignedInUser{
|
||||
OrgID: orgID,
|
||||
Permissions: queryPermissions,
|
||||
},
|
||||
}
|
||||
|
||||
resp := api.RouteGetRuleStatuses(c)
|
||||
require.Equal(t, http.StatusOK, resp.Status())
|
||||
|
||||
var res apimodels.RuleResponse
|
||||
require.NoError(t, json.Unmarshal(resp.Body(), &res))
|
||||
|
||||
// Should return 3 error groups
|
||||
require.Len(t, res.Data.RuleGroups, 3)
|
||||
require.Equal(t, "group-1", res.Data.RuleGroups[0].Name)
|
||||
require.Equal(t, "group-3", res.Data.RuleGroups[1].Name)
|
||||
require.Equal(t, "group-5", res.Data.RuleGroups[2].Name)
|
||||
|
||||
// Verify all have error health
|
||||
for _, rg := range res.Data.RuleGroups {
|
||||
for _, rule := range rg.Rules {
|
||||
require.Equal(t, "error", rule.Health)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("combined state and health filters", func(t *testing.T) {
|
||||
fakeStore, fakeAIM, api := setupAPI(t)
|
||||
|
||||
// Create 10 groups (2 rules each = 20 rules total)
|
||||
// Groups 1-5: firing, groups 6-10: normal
|
||||
// Groups 1,3,5,7,9: ok health, groups 2,4,6,8,10: error health
|
||||
// Groups matching both filters (firing + ok): 1,3,5
|
||||
createRulesWithState(t, fakeStore, fakeAIM, orgID, 10, 2,
|
||||
func(i int) eval.State {
|
||||
if i < 5 {
|
||||
return eval.Alerting
|
||||
}
|
||||
return eval.Normal
|
||||
},
|
||||
func(i int) error {
|
||||
if i%2 == 1 {
|
||||
return fmt.Errorf("evaluation error")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// Request 3 groups with state=firing AND health=ok
|
||||
req, err := http.NewRequest("GET", "/api/v1/rules?state=firing&health=ok&group_limit=3", nil)
|
||||
require.NoError(t, err)
|
||||
c := &contextmodel.ReqContext{
|
||||
Context: &web.Context{Req: req},
|
||||
SignedInUser: &user.SignedInUser{
|
||||
OrgID: orgID,
|
||||
Permissions: queryPermissions,
|
||||
},
|
||||
}
|
||||
|
||||
resp := api.RouteGetRuleStatuses(c)
|
||||
require.Equal(t, http.StatusOK, resp.Status())
|
||||
|
||||
var res apimodels.RuleResponse
|
||||
require.NoError(t, json.Unmarshal(resp.Body(), &res))
|
||||
|
||||
// Should return 3 groups matching both filters
|
||||
require.Len(t, res.Data.RuleGroups, 3)
|
||||
require.Equal(t, "group-1", res.Data.RuleGroups[0].Name)
|
||||
require.Equal(t, "group-3", res.Data.RuleGroups[1].Name)
|
||||
require.Equal(t, "group-5", res.Data.RuleGroups[2].Name)
|
||||
|
||||
// Verify all match both criteria
|
||||
for _, rg := range res.Data.RuleGroups {
|
||||
for _, rule := range rg.Rules {
|
||||
require.Equal(t, "ok", rule.Health)
|
||||
hasFiring := false
|
||||
for _, alert := range rule.Alerts {
|
||||
if alert.State == eval.Alerting.String() {
|
||||
hasFiring = true
|
||||
}
|
||||
}
|
||||
require.True(t, hasFiring)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rule_limit hit before group_limit", func(t *testing.T) {
|
||||
fakeStore, fakeAIM, api := setupAPI(t)
|
||||
|
||||
// Create 5 groups (3 rules each = 15 rules total)
|
||||
// Request: group_limit=10, rule_limit=8
|
||||
// Expected: Should return groups 1-3 (9 rules total, exceeds limit but complete group included)
|
||||
createRulesWithState(t, fakeStore, fakeAIM, orgID, 5, 3,
|
||||
func(i int) eval.State { return eval.Alerting }, // all firing
|
||||
func(i int) error { return nil }) // all healthy
|
||||
|
||||
// Request group_limit=10, rule_limit=8 - rule limit should hit first
|
||||
req, err := http.NewRequest("GET", "/api/v1/rules?group_limit=10&rule_limit=8", nil)
|
||||
require.NoError(t, err)
|
||||
c := &contextmodel.ReqContext{
|
||||
Context: &web.Context{Req: req},
|
||||
SignedInUser: &user.SignedInUser{
|
||||
OrgID: orgID,
|
||||
Permissions: queryPermissions,
|
||||
},
|
||||
}
|
||||
|
||||
resp := api.RouteGetRuleStatuses(c)
|
||||
require.Equal(t, http.StatusOK, resp.Status())
|
||||
|
||||
var res apimodels.RuleResponse
|
||||
require.NoError(t, json.Unmarshal(resp.Body(), &res))
|
||||
|
||||
// Expected behavior with rule_limit=8:
|
||||
// Group 1: 3 rules (total: 3, under 8, continue)
|
||||
// Group 2: 3 rules (total: 6, under 8, continue)
|
||||
// Group 3: 3 rules (total: 9, exceeds 8 but we include complete group)
|
||||
// Result: 3 groups, 9 rules total
|
||||
totalRules := 0
|
||||
for _, rg := range res.Data.RuleGroups {
|
||||
t.Logf("Group %s has %d rules", rg.Name, len(rg.Rules))
|
||||
totalRules += len(rg.Rules)
|
||||
}
|
||||
t.Logf("Total: %d rules, %d groups", totalRules, len(res.Data.RuleGroups))
|
||||
require.Equal(t, 9, totalRules)
|
||||
require.Equal(t, 3, len(res.Data.RuleGroups))
|
||||
})
|
||||
|
||||
t.Run("rule_limit without group_limit", func(t *testing.T) {
|
||||
fakeStore, fakeAIM, api := setupAPI(t)
|
||||
|
||||
// Create 10 groups (2 rules each = 20 rules total)
|
||||
// Request rule_limit=7 should stop after 4 groups (8 rules total)
|
||||
createRulesWithState(t, fakeStore, fakeAIM, orgID, 10, 2,
|
||||
func(i int) eval.State { return eval.Alerting },
|
||||
func(i int) error { return nil })
|
||||
|
||||
// Request rule_limit=7 only (group_limit unlimited)
|
||||
req, err := http.NewRequest("GET", "/api/v1/rules?rule_limit=7", nil)
|
||||
require.NoError(t, err)
|
||||
c := &contextmodel.ReqContext{
|
||||
Context: &web.Context{Req: req},
|
||||
SignedInUser: &user.SignedInUser{
|
||||
OrgID: orgID,
|
||||
Permissions: queryPermissions,
|
||||
},
|
||||
}
|
||||
|
||||
resp := api.RouteGetRuleStatuses(c)
|
||||
require.Equal(t, http.StatusOK, resp.Status())
|
||||
|
||||
var res apimodels.RuleResponse
|
||||
require.NoError(t, json.Unmarshal(resp.Body(), &res))
|
||||
|
||||
// Should return 4 groups (8 rules total, stops after exceeding 7)
|
||||
totalRules := 0
|
||||
for _, rg := range res.Data.RuleGroups {
|
||||
totalRules += len(rg.Rules)
|
||||
}
|
||||
require.Equal(t, 8, totalRules)
|
||||
require.Equal(t, 4, len(res.Data.RuleGroups))
|
||||
require.NotEmpty(t, res.Data.NextToken)
|
||||
})
|
||||
|
||||
t.Run("empty page in middle of pagination", func(t *testing.T) {
|
||||
fakeStore, fakeAIM, api := setupAPI(t)
|
||||
|
||||
// Create 15 groups (2 rules each = 30 rules total): groups 1-3 firing, 4-8 normal, 9-13 firing, 14-15 normal
|
||||
// Request state=firing with group_limit=5 should skip over normal groups
|
||||
createRulesWithState(t, fakeStore, fakeAIM, orgID, 15, 2,
|
||||
func(i int) eval.State {
|
||||
groupNum := i + 1
|
||||
if groupNum <= 3 || (groupNum >= 9 && groupNum <= 13) {
|
||||
return eval.Alerting
|
||||
}
|
||||
return eval.Normal
|
||||
},
|
||||
func(i int) error { return nil })
|
||||
|
||||
// Request state=firing, group_limit=5
|
||||
req, err := http.NewRequest("GET", "/api/v1/rules?state=firing&group_limit=5", nil)
|
||||
require.NoError(t, err)
|
||||
c := &contextmodel.ReqContext{
|
||||
Context: &web.Context{Req: req},
|
||||
SignedInUser: &user.SignedInUser{
|
||||
OrgID: orgID,
|
||||
Permissions: queryPermissions,
|
||||
},
|
||||
}
|
||||
|
||||
resp := api.RouteGetRuleStatuses(c)
|
||||
require.Equal(t, http.StatusOK, resp.Status())
|
||||
|
||||
var res apimodels.RuleResponse
|
||||
require.NoError(t, json.Unmarshal(resp.Body(), &res))
|
||||
|
||||
// Should return 5 firing groups from pages 1 and 3 (skipping empty page 2)
|
||||
require.Len(t, res.Data.RuleGroups, 5)
|
||||
|
||||
// Verify all are firing
|
||||
for _, rg := range res.Data.RuleGroups {
|
||||
for _, rule := range rg.Rules {
|
||||
for _, alert := range rule.Alerts {
|
||||
require.Equal(t, eval.Alerting.String(), alert.State)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("group_limit=0 returns empty response", func(t *testing.T) {
|
||||
fakeStore, fakeAIM, api := setupAPI(t)
|
||||
|
||||
// Create 1 group (2 rules) to verify group_limit=0 returns empty
|
||||
createRulesWithState(t, fakeStore, fakeAIM, orgID, 1, 2,
|
||||
func(i int) eval.State { return eval.Alerting },
|
||||
func(i int) error { return nil })
|
||||
|
||||
// Request group_limit=0
|
||||
req, err := http.NewRequest("GET", "/api/v1/rules?group_limit=0", nil)
|
||||
require.NoError(t, err)
|
||||
c := &contextmodel.ReqContext{
|
||||
Context: &web.Context{Req: req},
|
||||
SignedInUser: &user.SignedInUser{
|
||||
OrgID: orgID,
|
||||
Permissions: queryPermissions,
|
||||
},
|
||||
}
|
||||
|
||||
resp := api.RouteGetRuleStatuses(c)
|
||||
require.Equal(t, http.StatusOK, resp.Status())
|
||||
|
||||
var res apimodels.RuleResponse
|
||||
require.NoError(t, json.Unmarshal(resp.Body(), &res))
|
||||
|
||||
require.Len(t, res.Data.RuleGroups, 0)
|
||||
require.Empty(t, res.Data.NextToken)
|
||||
})
|
||||
|
||||
t.Run("resume with token and filters active", func(t *testing.T) {
|
||||
fakeStore, fakeAIM, api := setupAPI(t)
|
||||
|
||||
// Create 10 groups (2 rules each = 20 rules total): odd groups firing, even groups normal
|
||||
// Test pagination continuation with state filter: first page returns groups 1,3 then second page returns groups 5,7
|
||||
createRulesWithState(t, fakeStore, fakeAIM, orgID, 10, 2,
|
||||
func(i int) eval.State {
|
||||
if i%2 == 0 {
|
||||
return eval.Alerting
|
||||
}
|
||||
return eval.Normal
|
||||
},
|
||||
func(i int) error { return nil })
|
||||
|
||||
// First request: state=firing, group_limit=2
|
||||
req, err := http.NewRequest("GET", "/api/v1/rules?state=firing&group_limit=2", nil)
|
||||
require.NoError(t, err)
|
||||
c := &contextmodel.ReqContext{
|
||||
Context: &web.Context{Req: req},
|
||||
SignedInUser: &user.SignedInUser{
|
||||
OrgID: orgID,
|
||||
Permissions: queryPermissions,
|
||||
},
|
||||
}
|
||||
|
||||
resp := api.RouteGetRuleStatuses(c)
|
||||
require.Equal(t, http.StatusOK, resp.Status())
|
||||
|
||||
var res1 apimodels.RuleResponse
|
||||
require.NoError(t, json.Unmarshal(resp.Body(), &res1))
|
||||
|
||||
// Should return 2 firing groups (1, 3)
|
||||
require.Len(t, res1.Data.RuleGroups, 2)
|
||||
require.NotEmpty(t, res1.Data.NextToken)
|
||||
|
||||
// Verify both are firing
|
||||
for _, rg := range res1.Data.RuleGroups {
|
||||
for _, rule := range rg.Rules {
|
||||
require.Equal(t, "firing", rule.State)
|
||||
}
|
||||
}
|
||||
|
||||
// Second request: resume with token AND filter still active
|
||||
req2, err := http.NewRequest("GET", fmt.Sprintf("/api/v1/rules?state=firing&group_limit=2&group_next_token=%s", res1.Data.NextToken), nil)
|
||||
require.NoError(t, err)
|
||||
c2 := &contextmodel.ReqContext{
|
||||
Context: &web.Context{Req: req2},
|
||||
SignedInUser: &user.SignedInUser{
|
||||
OrgID: orgID,
|
||||
Permissions: queryPermissions,
|
||||
},
|
||||
}
|
||||
|
||||
resp2 := api.RouteGetRuleStatuses(c2)
|
||||
require.Equal(t, http.StatusOK, resp2.Status())
|
||||
|
||||
var res2 apimodels.RuleResponse
|
||||
require.NoError(t, json.Unmarshal(resp2.Body(), &res2))
|
||||
|
||||
// Should return next 2 firing groups (5, 7)
|
||||
require.Len(t, res2.Data.RuleGroups, 2)
|
||||
require.NotEmpty(t, res2.Data.NextToken)
|
||||
|
||||
// Verify both are firing
|
||||
for _, rg := range res2.Data.RuleGroups {
|
||||
for _, rule := range rg.Rules {
|
||||
require.Equal(t, "firing", rule.State)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify we got different groups than first request
|
||||
firstGroupNames := make(map[string]bool)
|
||||
for _, rg := range res1.Data.RuleGroups {
|
||||
firstGroupNames[rg.Name] = true
|
||||
}
|
||||
for _, rg := range res2.Data.RuleGroups {
|
||||
require.False(t, firstGroupNames[rg.Name])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rule_limit with state filter", func(t *testing.T) {
|
||||
fakeStore, fakeAIM, api := setupAPI(t)
|
||||
|
||||
// Create 10 groups (2 rules each = 20 rules total), alternating firing/normal
|
||||
// Firing groups: 1,3,5,7,9 (5 groups × 2 rules = 10 firing rules)
|
||||
// Request state=firing with rule_limit=7 should return groups 1,3,5,7 (8 rules)
|
||||
createRulesWithState(t, fakeStore, fakeAIM, orgID, 10, 2,
|
||||
func(i int) eval.State {
|
||||
if i%2 == 0 {
|
||||
return eval.Alerting
|
||||
}
|
||||
return eval.Normal
|
||||
},
|
||||
func(i int) error { return nil })
|
||||
|
||||
// Request state=firing, rule_limit=7
|
||||
// Should fetch multiple pages to accumulate 7+ firing rules
|
||||
// Groups 1, 3, 5 = 6 rules (under limit)
|
||||
// Group 7 = +2 rules = 8 total (exceeds 7, but we include full group)
|
||||
req, err := http.NewRequest("GET", "/api/v1/rules?state=firing&rule_limit=7", nil)
|
||||
require.NoError(t, err)
|
||||
c := &contextmodel.ReqContext{
|
||||
Context: &web.Context{Req: req},
|
||||
SignedInUser: &user.SignedInUser{
|
||||
OrgID: orgID,
|
||||
Permissions: queryPermissions,
|
||||
},
|
||||
}
|
||||
|
||||
resp := api.RouteGetRuleStatuses(c)
|
||||
require.Equal(t, http.StatusOK, resp.Status())
|
||||
|
||||
var res apimodels.RuleResponse
|
||||
require.NoError(t, json.Unmarshal(resp.Body(), &res))
|
||||
|
||||
// Count total firing rules returned
|
||||
totalRules := 0
|
||||
for _, rg := range res.Data.RuleGroups {
|
||||
for _, rule := range rg.Rules {
|
||||
require.Equal(t, "firing", rule.State)
|
||||
totalRules++
|
||||
}
|
||||
}
|
||||
|
||||
// Should return 4 firing groups (1,3,5,7) with 8 rules total
|
||||
require.Equal(t, 8, totalRules)
|
||||
require.Equal(t, 4, len(res.Data.RuleGroups))
|
||||
require.NotEmpty(t, res.Data.NextToken)
|
||||
})
|
||||
|
||||
t.Run("rule_limit with health filter", func(t *testing.T) {
|
||||
fakeStore, fakeAIM, api := setupAPI(t)
|
||||
|
||||
// Create 8 groups (3 rules each = 24 rules total), alternating ok/error health
|
||||
// Error groups: 1,3,5,7 (4 groups × 3 rules = 12 error rules)
|
||||
// Request health=error with rule_limit=8 should return groups 1,3,5 (9 rules)
|
||||
createRulesWithState(t, fakeStore, fakeAIM, orgID, 8, 3,
|
||||
func(i int) eval.State { return eval.Normal },
|
||||
func(i int) error {
|
||||
if i%2 == 0 {
|
||||
return fmt.Errorf("evaluation error")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// Request health=error, rule_limit=8
|
||||
// Should fetch multiple pages to accumulate 8+ error rules
|
||||
// Groups 1, 3 = 6 rules (under limit)
|
||||
// Group 5 = +3 rules = 9 total (exceeds 8, but we include full group)
|
||||
req, err := http.NewRequest("GET", "/api/v1/rules?health=error&rule_limit=8", nil)
|
||||
require.NoError(t, err)
|
||||
c := &contextmodel.ReqContext{
|
||||
Context: &web.Context{Req: req},
|
||||
SignedInUser: &user.SignedInUser{
|
||||
OrgID: orgID,
|
||||
Permissions: queryPermissions,
|
||||
},
|
||||
}
|
||||
|
||||
resp := api.RouteGetRuleStatuses(c)
|
||||
require.Equal(t, http.StatusOK, resp.Status())
|
||||
|
||||
var res apimodels.RuleResponse
|
||||
require.NoError(t, json.Unmarshal(resp.Body(), &res))
|
||||
|
||||
// Count total error rules returned
|
||||
totalRules := 0
|
||||
for _, rg := range res.Data.RuleGroups {
|
||||
for _, rule := range rg.Rules {
|
||||
require.Equal(t, "error", rule.Health)
|
||||
totalRules++
|
||||
}
|
||||
}
|
||||
|
||||
// Should return 3 error groups (1,3,5) with 9 rules total
|
||||
require.Equal(t, 9, totalRules)
|
||||
require.Equal(t, 3, len(res.Data.RuleGroups))
|
||||
require.NotEmpty(t, res.Data.NextToken)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func setupAPI(t *testing.T) (*fakes.RuleStore, *fakeAlertInstanceManager, PrometheusSrv) {
|
||||
|
||||
@@ -79,6 +79,7 @@ func (srv TestingApiSrv) RouteTestGrafanaRuleConfig(c *contextmodel.ReqContext,
|
||||
return response.ErrOrFallback(http.StatusInternalServerError, "failed to authorize access to rule group", err)
|
||||
}
|
||||
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if srv.featureManager.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingQueryOptimization) {
|
||||
if _, err := store.OptimizeAlertQueries(rule.Data); err != nil {
|
||||
return ErrResp(http.StatusInternalServerError, err, "Failed to optimize query")
|
||||
@@ -178,6 +179,7 @@ func (srv TestingApiSrv) RouteEvalQueries(c *contextmodel.ReqContext, cmd apimod
|
||||
}
|
||||
|
||||
var optimizations []store.Optimization
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if srv.featureManager.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingQueryOptimization) {
|
||||
var err error
|
||||
optimizations, err = store.OptimizeAlertQueries(cond.Data)
|
||||
@@ -223,6 +225,7 @@ func addOptimizedQueryWarnings(evalResults *backend.QueryDataResponse, optimizat
|
||||
}
|
||||
|
||||
func (srv TestingApiSrv) BacktestAlertRule(c *contextmodel.ReqContext, cmd apimodels.BacktestConfig) response.Response {
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
if !srv.featureManager.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingBacktesting) {
|
||||
return ErrResp(http.StatusNotFound, nil, "Backgtesting API is not enabled")
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/url"
|
||||
"slices"
|
||||
"sort"
|
||||
@@ -26,6 +27,10 @@ import (
|
||||
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/state"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
type RuleStoreReader interface {
|
||||
@@ -54,6 +59,9 @@ type PrometheusSrv struct {
|
||||
provenanceStore ProvenanceStore
|
||||
}
|
||||
|
||||
// Package-level OpenTelemetry tracer per Grafana instrumentation conventions.
|
||||
var tracer = otel.Tracer("github.com/grafana/grafana/pkg/services/ngalert/api/prometheus")
|
||||
|
||||
func NewPrometheusSrv(log log.Logger, manager state.AlertInstanceManager, status StatusReader, store RuleStoreReader, authz RuleGroupAccessControlService, provenanceStore ProvenanceStore) *PrometheusSrv {
|
||||
return &PrometheusSrv{
|
||||
log,
|
||||
@@ -219,6 +227,14 @@ func GetStatesFromQuery(v url.Values) (map[eval.State]struct{}, error) {
|
||||
return states, nil
|
||||
}
|
||||
|
||||
func MapStateSetToStrings(stateSet map[eval.State]struct{}) []string {
|
||||
states := make([]string, 0, len(stateSet))
|
||||
for state := range stateSet {
|
||||
states = append(states, state.String())
|
||||
}
|
||||
return states
|
||||
}
|
||||
|
||||
func GetHealthFromQuery(v url.Values) (map[string]struct{}, error) {
|
||||
health := make(map[string]struct{})
|
||||
for _, s := range v["health"] {
|
||||
@@ -252,6 +268,13 @@ func (srv PrometheusSrv) RouteGetRuleStatuses(c *contextmodel.ReqContext) respon
|
||||
// As we are using req.Form directly, this triggers a call to ParseForm() if needed.
|
||||
c.Query("")
|
||||
|
||||
ctx, span := tracer.Start(c.Req.Context(), "api.prometheus.RouteGetRuleStatuses")
|
||||
defer span.End()
|
||||
// Propagate the new context so child spans can attach to it.
|
||||
c.Req = c.Req.WithContext(ctx)
|
||||
orgID := c.GetOrgID()
|
||||
span.SetAttributes(attribute.Int64("org_id", orgID))
|
||||
|
||||
ruleResponse := apimodels.RuleResponse{
|
||||
DiscoveryBase: apimodels.DiscoveryBase{
|
||||
Status: "success",
|
||||
@@ -261,13 +284,14 @@ func (srv PrometheusSrv) RouteGetRuleStatuses(c *contextmodel.ReqContext) respon
|
||||
},
|
||||
}
|
||||
|
||||
namespaceMap, err := srv.store.GetUserVisibleNamespaces(c.Req.Context(), c.GetOrgID(), c.SignedInUser)
|
||||
namespaceMap, err := srv.store.GetUserVisibleNamespaces(c.Req.Context(), orgID, c.SignedInUser)
|
||||
if err != nil {
|
||||
ruleResponse.Status = "error"
|
||||
ruleResponse.Error = fmt.Sprintf("failed to get namespaces visible to the user: %s", err.Error())
|
||||
ruleResponse.ErrorType = apiv1.ErrServer
|
||||
return response.JSON(ruleResponse.HTTPStatusCode(), ruleResponse)
|
||||
}
|
||||
span.AddEvent("User visible namespaces retrieved")
|
||||
|
||||
allowedNamespaces := map[string]string{}
|
||||
for namespaceUID, folder := range namespaceMap {
|
||||
@@ -283,6 +307,8 @@ func (srv PrometheusSrv) RouteGetRuleStatuses(c *contextmodel.ReqContext) respon
|
||||
allowedNamespaces[namespaceUID] = folder.Fullpath
|
||||
}
|
||||
}
|
||||
span.AddEvent("User permissions checked")
|
||||
span.SetAttributes(attribute.Int("allowedNamespaces", len(allowedNamespaces)))
|
||||
|
||||
provenanceRecords, err := srv.provenanceStore.GetProvenances(c.Req.Context(), c.GetOrgID(), (&ngmodels.AlertRule{}).ResourceType())
|
||||
if err != nil {
|
||||
@@ -297,7 +323,7 @@ func (srv PrometheusSrv) RouteGetRuleStatuses(c *contextmodel.ReqContext) respon
|
||||
srv.store,
|
||||
RuleGroupStatusesOptions{
|
||||
Ctx: c.Req.Context(),
|
||||
OrgID: c.OrgID,
|
||||
OrgID: orgID,
|
||||
Query: c.Req.Form,
|
||||
AllowedNamespaces: allowedNamespaces,
|
||||
},
|
||||
@@ -404,7 +430,182 @@ func RuleAlertStateMutatorGenerator(manager state.AlertInstanceManager) RuleAler
|
||||
}
|
||||
}
|
||||
|
||||
// paginationContext holds limits and filters for filter-aware pagination
|
||||
type paginationContext struct {
|
||||
opts RuleGroupStatusesOptions
|
||||
provenanceRecords map[string]ngmodels.Provenance
|
||||
ruleStatusMutator RuleStatusMutator
|
||||
alertStateMutator RuleAlertStateMutator
|
||||
|
||||
// Query parameters
|
||||
namespaceUIDs []string
|
||||
ruleUIDs []string
|
||||
dashboardUID string
|
||||
panelID int64
|
||||
ruleGroups []string
|
||||
receiverName string
|
||||
title string
|
||||
searchRuleGroup string
|
||||
ruleType ngmodels.RuleTypeFilter
|
||||
ruleNamesSet map[string]struct{}
|
||||
|
||||
// Filters
|
||||
stateFilterSet map[eval.State]struct{}
|
||||
healthFilterSet map[string]struct{}
|
||||
matchers labels.Matchers
|
||||
labelOptions []ngmodels.LabelOption
|
||||
limitAlertsPerRule int64
|
||||
limitRulesPerGroup int64
|
||||
}
|
||||
|
||||
// pageResult is the result of fetching and filtering of one page
|
||||
type pageResult struct {
|
||||
groups []apimodels.RuleGroup
|
||||
totalsDelta map[string]int64
|
||||
nextToken string
|
||||
hasMore bool
|
||||
}
|
||||
|
||||
func accumulateTotals(dest, source map[string]int64) {
|
||||
for k, v := range source {
|
||||
dest[k] += v
|
||||
}
|
||||
}
|
||||
|
||||
// fetchAndFilterPage fetches one page from the store and applies filters
|
||||
func (ctx *paginationContext) fetchAndFilterPage(log log.Logger, store ListAlertRulesStoreV2, span trace.Span, token string, remainingGroups, remainingRules int64) (pageResult, error) {
|
||||
byGroupQuery := ngmodels.ListAlertRulesExtendedQuery{
|
||||
ListAlertRulesQuery: ngmodels.ListAlertRulesQuery{
|
||||
OrgID: ctx.opts.OrgID,
|
||||
NamespaceUIDs: ctx.namespaceUIDs,
|
||||
RuleUIDs: ctx.ruleUIDs,
|
||||
DashboardUID: ctx.dashboardUID,
|
||||
PanelID: ctx.panelID,
|
||||
RuleGroups: ctx.ruleGroups,
|
||||
ReceiverName: ctx.receiverName,
|
||||
SearchTitle: ctx.title,
|
||||
SearchRuleGroup: ctx.searchRuleGroup,
|
||||
},
|
||||
RuleType: ctx.ruleType,
|
||||
Limit: remainingGroups,
|
||||
RuleLimit: remainingRules,
|
||||
ContinueToken: token,
|
||||
}
|
||||
|
||||
ruleList, newToken, err := store.ListAlertRulesByGroup(ctx.opts.Ctx, &byGroupQuery)
|
||||
if err != nil {
|
||||
return pageResult{}, err
|
||||
}
|
||||
|
||||
span.SetAttributes(
|
||||
attribute.Int("store_rule_list_len", len(ruleList)),
|
||||
attribute.Bool("store_continue_token_set", newToken != ""),
|
||||
)
|
||||
span.AddEvent("Alert rules retrieved from store")
|
||||
|
||||
groupedRules := getGroupedRules(log, ruleList, ctx.ruleNamesSet, ctx.opts.AllowedNamespaces)
|
||||
|
||||
result := pageResult{
|
||||
groups: make([]apimodels.RuleGroup, 0, len(groupedRules)),
|
||||
totalsDelta: make(map[string]int64),
|
||||
nextToken: newToken,
|
||||
hasMore: newToken != "",
|
||||
}
|
||||
|
||||
for _, rg := range groupedRules {
|
||||
ruleGroup, totals := toRuleGroup(
|
||||
log, rg.GroupKey, rg.Folder, rg.Rules,
|
||||
ctx.provenanceRecords, ctx.limitAlertsPerRule,
|
||||
ctx.stateFilterSet, ctx.matchers, ctx.labelOptions,
|
||||
ctx.ruleStatusMutator, ctx.alertStateMutator,
|
||||
)
|
||||
ruleGroup.Totals = totals
|
||||
accumulateTotals(result.totalsDelta, totals)
|
||||
|
||||
if len(ctx.stateFilterSet) > 0 {
|
||||
filterRulesByState(ruleGroup, ctx.stateFilterSet)
|
||||
}
|
||||
|
||||
if len(ctx.healthFilterSet) > 0 {
|
||||
filterRulesByHealth(ruleGroup, ctx.healthFilterSet)
|
||||
}
|
||||
|
||||
if ctx.limitRulesPerGroup > -1 && int64(len(ruleGroup.Rules)) > ctx.limitRulesPerGroup {
|
||||
ruleGroup.Rules = ruleGroup.Rules[0:ctx.limitRulesPerGroup]
|
||||
}
|
||||
|
||||
if len(ruleGroup.Rules) > 0 {
|
||||
result.groups = append(result.groups, *ruleGroup)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// paginateRuleGroups fetches pages until limits are satisfied applying filters at each step
|
||||
func paginateRuleGroups(log log.Logger, store ListAlertRulesStoreV2, ctx *paginationContext, span trace.Span, maxGroups, maxRules int64, startToken string) ([]apimodels.RuleGroup, map[string]int64, string, error) {
|
||||
allGroups := []apimodels.RuleGroup{}
|
||||
rulesTotals := make(map[string]int64)
|
||||
|
||||
continueToken := startToken
|
||||
groupsReturned := int64(0)
|
||||
rulesReturned := int64(0)
|
||||
|
||||
for {
|
||||
remainingGroups := maxGroups
|
||||
if maxGroups > 0 {
|
||||
remainingGroups = maxGroups - groupsReturned
|
||||
if remainingGroups <= 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
remainingRules := maxRules
|
||||
if maxRules > 0 {
|
||||
remainingRules = maxRules - rulesReturned
|
||||
if remainingRules <= 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
page, err := ctx.fetchAndFilterPage(log, store, span, continueToken, remainingGroups, remainingRules)
|
||||
if err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
|
||||
accumulateTotals(rulesTotals, page.totalsDelta)
|
||||
|
||||
// Add groups and check limits
|
||||
for _, group := range page.groups {
|
||||
allGroups = append(allGroups, group)
|
||||
groupsReturned++
|
||||
rulesReturned += int64(len(group.Rules))
|
||||
|
||||
// Check if we've hit limits
|
||||
if (maxGroups > 0 && groupsReturned == maxGroups) || (maxRules > 0 && rulesReturned >= maxRules) {
|
||||
return allGroups, rulesTotals, page.nextToken, nil
|
||||
}
|
||||
}
|
||||
|
||||
if !page.hasMore {
|
||||
return allGroups, rulesTotals, "", nil
|
||||
}
|
||||
|
||||
if page.nextToken == continueToken {
|
||||
log.Warn("Pagination loop detected same token, stopping", "token", page.nextToken)
|
||||
return allGroups, rulesTotals, page.nextToken, nil
|
||||
}
|
||||
|
||||
continueToken = page.nextToken
|
||||
}
|
||||
|
||||
return allGroups, rulesTotals, continueToken, nil
|
||||
}
|
||||
|
||||
func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opts RuleGroupStatusesOptions, ruleStatusMutator RuleStatusMutator, alertStateMutator RuleAlertStateMutator, provenanceRecords map[string]ngmodels.Provenance) apimodels.RuleResponse {
|
||||
ctx, span := tracer.Start(opts.Ctx, "api.prometheus.PrepareRuleGroupStatusesV2")
|
||||
defer span.End()
|
||||
opts.Ctx = ctx
|
||||
|
||||
ruleResponse := apimodels.RuleResponse{
|
||||
DiscoveryBase: apimodels.DiscoveryBase{
|
||||
Status: "success",
|
||||
@@ -428,9 +629,17 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt
|
||||
ruleResponse.ErrorType = apiv1.ErrBadData
|
||||
return ruleResponse
|
||||
}
|
||||
span.SetAttributes(
|
||||
attribute.String("dashboard_uid", dashboardUID),
|
||||
attribute.Int64("panel_id", panelID),
|
||||
)
|
||||
|
||||
limitRulesPerGroup := getInt64WithDefault(opts.Query, "limit_rules", -1)
|
||||
limitAlertsPerRule := getInt64WithDefault(opts.Query, "limit_alerts", -1)
|
||||
span.SetAttributes(
|
||||
attribute.Int64("limit_rules", limitRulesPerGroup),
|
||||
attribute.Int64("limit_alerts", limitAlertsPerRule),
|
||||
)
|
||||
matchers, err := getMatchersFromQuery(opts.Query)
|
||||
if err != nil {
|
||||
ruleResponse.Status = "error"
|
||||
@@ -438,6 +647,8 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt
|
||||
ruleResponse.ErrorType = apiv1.ErrBadData
|
||||
return ruleResponse
|
||||
}
|
||||
span.SetAttributes(attribute.Int("matcher_count", len(matchers)))
|
||||
|
||||
stateFilterSet, err := GetStatesFromQuery(opts.Query)
|
||||
if err != nil {
|
||||
ruleResponse.Status = "error"
|
||||
@@ -445,6 +656,10 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt
|
||||
ruleResponse.ErrorType = apiv1.ErrBadData
|
||||
return ruleResponse
|
||||
}
|
||||
span.SetAttributes(
|
||||
attribute.Int("state_filter_count", len(stateFilterSet)),
|
||||
attribute.StringSlice("state_filter", MapStateSetToStrings(stateFilterSet)),
|
||||
)
|
||||
|
||||
healthFilterSet, err := GetHealthFromQuery(opts.Query)
|
||||
if err != nil {
|
||||
@@ -453,11 +668,18 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt
|
||||
ruleResponse.ErrorType = apiv1.ErrBadData
|
||||
return ruleResponse
|
||||
}
|
||||
span.SetAttributes(
|
||||
attribute.Int("health_filter_count", len(healthFilterSet)),
|
||||
attribute.StringSlice("health_filter", slices.Collect(maps.Keys(healthFilterSet))),
|
||||
)
|
||||
|
||||
var labelOptions []ngmodels.LabelOption
|
||||
if !getBoolWithDefault(opts.Query, queryIncludeInternalLabels, false) {
|
||||
labelOptions = append(labelOptions, ngmodels.WithoutInternalLabels())
|
||||
}
|
||||
span.SetAttributes(
|
||||
attribute.Bool("include_internal_labels", len(labelOptions) == 0),
|
||||
)
|
||||
|
||||
if len(opts.AllowedNamespaces) == 0 {
|
||||
log.Debug("User does not have access to any namespaces")
|
||||
@@ -476,19 +698,36 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt
|
||||
}
|
||||
}
|
||||
|
||||
span.SetAttributes(
|
||||
attribute.Bool("folder_uid_set", folderUID != ""),
|
||||
attribute.Int("namespace_count", len(namespaceUIDs)),
|
||||
)
|
||||
|
||||
ruleGroups := opts.Query["rule_group"]
|
||||
ruleUIDs := opts.Query["rule_uid"]
|
||||
|
||||
span.SetAttributes(
|
||||
attribute.Int("rule_group_count", len(ruleGroups)),
|
||||
attribute.Int("rule_uid_count", len(ruleUIDs)),
|
||||
)
|
||||
|
||||
receiverName := opts.Query.Get("receiver_name")
|
||||
span.SetAttributes(attribute.Bool("receiver_name_set", receiverName != ""))
|
||||
|
||||
title := opts.Query.Get("search.rule_name")
|
||||
span.SetAttributes(attribute.Bool("search_rule_name_set", title != ""))
|
||||
|
||||
searchRuleGroup := opts.Query.Get("search.rule_group")
|
||||
span.SetAttributes(attribute.Bool("search_rule_group_set", searchRuleGroup != ""))
|
||||
|
||||
var ruleType ngmodels.RuleTypeFilter
|
||||
switch ngmodels.RuleType(opts.Query.Get("rule_type")) {
|
||||
case ngmodels.RuleTypeAlerting:
|
||||
ruleType = ngmodels.RuleTypeFilterAlerting
|
||||
span.SetAttributes(attribute.Bool("alerting_only", true))
|
||||
case ngmodels.RuleTypeRecording:
|
||||
ruleType = ngmodels.RuleTypeFilterRecording
|
||||
span.SetAttributes(attribute.Bool("recording_only", true))
|
||||
default:
|
||||
ruleType = ngmodels.RuleTypeFilterAll
|
||||
}
|
||||
@@ -507,68 +746,55 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt
|
||||
maxGroups := getInt64WithDefault(opts.Query, "group_limit", -1)
|
||||
maxRules := getInt64WithDefault(opts.Query, "rule_limit", -1)
|
||||
nextToken := opts.Query.Get("group_next_token")
|
||||
span.SetAttributes(
|
||||
attribute.Int64("group_limit", maxGroups),
|
||||
attribute.Int64("rule_limit", maxRules),
|
||||
attribute.Bool("group_next_token_set", nextToken != ""),
|
||||
)
|
||||
|
||||
if maxGroups == 0 || maxRules == 0 {
|
||||
return ruleResponse
|
||||
}
|
||||
|
||||
byGroupQuery := ngmodels.ListAlertRulesExtendedQuery{
|
||||
ListAlertRulesQuery: ngmodels.ListAlertRulesQuery{
|
||||
OrgID: opts.OrgID,
|
||||
NamespaceUIDs: namespaceUIDs,
|
||||
RuleUIDs: ruleUIDs,
|
||||
DashboardUID: dashboardUID,
|
||||
PanelID: panelID,
|
||||
RuleGroups: ruleGroups,
|
||||
ReceiverName: receiverName,
|
||||
SearchTitle: title,
|
||||
SearchRuleGroup: searchRuleGroup,
|
||||
},
|
||||
RuleType: ruleType,
|
||||
Limit: maxGroups,
|
||||
RuleLimit: maxRules,
|
||||
ContinueToken: nextToken,
|
||||
}
|
||||
ruleList, continueToken, err := store.ListAlertRulesByGroup(opts.Ctx, &byGroupQuery)
|
||||
if err != nil {
|
||||
ruleResponse.Status = "error"
|
||||
ruleResponse.Error = fmt.Sprintf("failure getting rules: %s", err.Error())
|
||||
ruleResponse.ErrorType = apiv1.ErrServer
|
||||
return ruleResponse
|
||||
}
|
||||
|
||||
ruleNames := opts.Query["rule_name"]
|
||||
ruleNamesSet := make(map[string]struct{}, len(ruleNames))
|
||||
for _, rn := range ruleNames {
|
||||
ruleNamesSet[rn] = struct{}{}
|
||||
}
|
||||
span.SetAttributes(attribute.Int("rule_name_count", len(ruleNamesSet)))
|
||||
|
||||
groupedRules := getGroupedRules(log, ruleList, ruleNamesSet, opts.AllowedNamespaces)
|
||||
rulesTotals := make(map[string]int64, len(groupedRules))
|
||||
for _, rg := range groupedRules {
|
||||
ruleGroup, totals := toRuleGroup(log, rg.GroupKey, rg.Folder, rg.Rules, provenanceRecords, limitAlertsPerRule, stateFilterSet, matchers, labelOptions, ruleStatusMutator, alertStateMutator)
|
||||
ruleGroup.Totals = totals
|
||||
for k, v := range totals {
|
||||
rulesTotals[k] += v
|
||||
}
|
||||
|
||||
if len(stateFilterSet) > 0 {
|
||||
filterRulesByState(ruleGroup, stateFilterSet)
|
||||
}
|
||||
|
||||
if len(healthFilterSet) > 0 {
|
||||
filterRulesByHealth(ruleGroup, healthFilterSet)
|
||||
}
|
||||
|
||||
if limitRulesPerGroup > -1 && int64(len(ruleGroup.Rules)) > limitRulesPerGroup {
|
||||
ruleGroup.Rules = ruleGroup.Rules[0:limitRulesPerGroup]
|
||||
}
|
||||
|
||||
if len(ruleGroup.Rules) > 0 {
|
||||
ruleResponse.Data.RuleGroups = append(ruleResponse.Data.RuleGroups, *ruleGroup)
|
||||
}
|
||||
pagCtx := &paginationContext{
|
||||
opts: opts,
|
||||
provenanceRecords: provenanceRecords,
|
||||
ruleStatusMutator: ruleStatusMutator,
|
||||
alertStateMutator: alertStateMutator,
|
||||
namespaceUIDs: namespaceUIDs,
|
||||
ruleUIDs: ruleUIDs,
|
||||
dashboardUID: dashboardUID,
|
||||
panelID: panelID,
|
||||
ruleGroups: ruleGroups,
|
||||
receiverName: receiverName,
|
||||
title: title,
|
||||
searchRuleGroup: searchRuleGroup,
|
||||
ruleType: ruleType,
|
||||
ruleNamesSet: ruleNamesSet,
|
||||
stateFilterSet: stateFilterSet,
|
||||
healthFilterSet: healthFilterSet,
|
||||
matchers: matchers,
|
||||
labelOptions: labelOptions,
|
||||
limitAlertsPerRule: limitAlertsPerRule,
|
||||
limitRulesPerGroup: limitRulesPerGroup,
|
||||
}
|
||||
|
||||
groups, rulesTotals, continueToken, err := paginateRuleGroups(log, store, pagCtx, span, maxGroups, maxRules, nextToken)
|
||||
if err != nil {
|
||||
ruleResponse.Status = "error"
|
||||
ruleResponse.Error = fmt.Sprintf("failure getting rules: %s", err.Error())
|
||||
ruleResponse.ErrorType = apiv1.ErrServer
|
||||
return ruleResponse
|
||||
}
|
||||
|
||||
ruleResponse.Data.RuleGroups = groups
|
||||
ruleResponse.Data.NextToken = continueToken
|
||||
|
||||
// Only return Totals if there is no pagination
|
||||
@@ -903,6 +1129,7 @@ func toRuleGroup(log log.Logger, groupKey ngmodels.AlertRuleGroupKey, folderFull
|
||||
|
||||
// mutate rule for alert states
|
||||
totals, totalsFiltered := ruleAlertStateMutator(rule, &alertingRule, stateFilterSet, matchers, labelOptions)
|
||||
|
||||
if alertingRule.State != "" {
|
||||
rulesTotals[alertingRule.State] += 1
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user