Compare commits
29 Commits
sriram/SQL
...
alerting/r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
774551589b | ||
|
|
dee9bc8fb9 | ||
|
|
6395a753d8 | ||
|
|
5b228fd7fa | ||
|
|
307cce059c | ||
|
|
a5d240751d | ||
|
|
d93867479f | ||
|
|
9f53141368 | ||
|
|
0413b76461 | ||
|
|
417d3d914a | ||
|
|
2a7f698c4c | ||
|
|
212bdb4400 | ||
|
|
2432756be8 | ||
|
|
a59df66e21 | ||
|
|
5bec0f1af7 | ||
|
|
954156d5b3 | ||
|
|
b96a1ae722 | ||
|
|
a53875e621 | ||
|
|
9598ae6434 | ||
|
|
ab0b05550f | ||
|
|
4518add556 | ||
|
|
00b89b0d29 | ||
|
|
a3eedfeb73 | ||
|
|
1e8f1f74ea | ||
|
|
66b05914e2 | ||
|
|
0c60d356d1 | ||
|
|
41d7213d7e | ||
|
|
efad6c7be0 | ||
|
|
e116254f32 |
@@ -135,9 +135,12 @@ You can use the **Span Limit** field in **Options** section of the TraceQL query
|
|||||||
This field sets the maximum number of spans to return for each span set.
|
This field sets the maximum number of spans to return for each span set.
|
||||||
By default, the maximum value that you can set for the **Span Limit** value (or the spss query) is 100.
|
By default, the maximum value that you can set for the **Span Limit** value (or the spss query) is 100.
|
||||||
In Tempo configuration, this value is controlled by the `max_spans_per_span_set` parameter and can be modified by your Tempo administrator.
|
In Tempo configuration, this value is controlled by the `max_spans_per_span_set` parameter and can be modified by your Tempo administrator.
|
||||||
Grafana Cloud users can contact Grafana Support to request a change.
|
|
||||||
Entering a value higher than the default results in an error.
|
Entering a value higher than the default results in an error.
|
||||||
|
|
||||||
|
{{< admonition type="note" >}}
|
||||||
|
Changing the value of `max_spans_per_span_set` isn't supported in Grafana Cloud.
|
||||||
|
{{< /admonition >}}
|
||||||
|
|
||||||
### Focus on traces or spans
|
### Focus on traces or spans
|
||||||
|
|
||||||
Under **Options**, you can choose to display the table as **Traces** or **Spans** focused.
|
Under **Options**, you can choose to display the table as **Traces** or **Spans** focused.
|
||||||
|
|||||||
4
go.mod
4
go.mod
@@ -33,12 +33,14 @@ require (
|
|||||||
github.com/armon/go-radix v1.0.0 // @grafana/grafana-app-platform-squad
|
github.com/armon/go-radix v1.0.0 // @grafana/grafana-app-platform-squad
|
||||||
github.com/aws/aws-sdk-go v1.55.7 // @grafana/aws-datasources
|
github.com/aws/aws-sdk-go v1.55.7 // @grafana/aws-datasources
|
||||||
github.com/aws/aws-sdk-go-v2 v1.40.0 // @grafana/aws-datasources
|
github.com/aws/aws-sdk-go-v2 v1.40.0 // @grafana/aws-datasources
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.18.21 // @grafana/grafana-operator-experience-squad
|
||||||
github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.45.3 // @grafana/aws-datasources
|
github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.45.3 // @grafana/aws-datasources
|
||||||
github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.51.0 // @grafana/aws-datasources
|
github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs v1.51.0 // @grafana/aws-datasources
|
||||||
github.com/aws/aws-sdk-go-v2/service/ec2 v1.225.2 // @grafana/aws-datasources
|
github.com/aws/aws-sdk-go-v2/service/ec2 v1.225.2 // @grafana/aws-datasources
|
||||||
github.com/aws/aws-sdk-go-v2/service/oam v1.18.3 // @grafana/aws-datasources
|
github.com/aws/aws-sdk-go-v2/service/oam v1.18.3 // @grafana/aws-datasources
|
||||||
github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.26.6 // @grafana/aws-datasources
|
github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.26.6 // @grafana/aws-datasources
|
||||||
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.40.1 // @grafana/grafana-operator-experience-squad
|
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.40.1 // @grafana/grafana-operator-experience-squad
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 // @grafana/grafana-operator-experience-squad
|
||||||
github.com/aws/smithy-go v1.23.2 // @grafana/aws-datasources
|
github.com/aws/smithy-go v1.23.2 // @grafana/aws-datasources
|
||||||
github.com/beevik/etree v1.4.1 // @grafana/grafana-backend-group
|
github.com/beevik/etree v1.4.1 // @grafana/grafana-backend-group
|
||||||
github.com/benbjohnson/clock v1.3.5 // @grafana/alerting-backend
|
github.com/benbjohnson/clock v1.3.5 // @grafana/alerting-backend
|
||||||
@@ -343,7 +345,6 @@ require (
|
|||||||
github.com/at-wat/mqtt-go v0.19.6 // indirect
|
github.com/at-wat/mqtt-go v0.19.6 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 // indirect
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.31.17 // indirect
|
github.com/aws/aws-sdk-go-v2/config v1.31.17 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.21 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 // indirect
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84 // indirect
|
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.84 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // indirect
|
||||||
@@ -358,7 +359,6 @@ require (
|
|||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0 // indirect
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.84.0 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 // indirect
|
github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 // indirect
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 // indirect
|
|
||||||
github.com/axiomhq/hyperloglog v0.0.0-20240507144631-af9851f82b27 // indirect
|
github.com/axiomhq/hyperloglog v0.0.0-20240507144631-af9851f82b27 // indirect
|
||||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||||
github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df // indirect
|
github.com/barkimedes/go-deepcopy v0.0.0-20220514131651-17c30cfc62df // indirect
|
||||||
|
|||||||
@@ -165,9 +165,17 @@ describe('DateMath', () => {
|
|||||||
expect(date!.valueOf()).toEqual(dateTime([2014, 1, 3]).valueOf());
|
expect(date!.valueOf()).toEqual(dateTime([2014, 1, 3]).valueOf());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle multiple math expressions', () => {
|
it.each([
|
||||||
const date = dateMath.parseDateMath('-2d-6h', dateTime([2014, 1, 5]));
|
['-2d-6h', [2014, 1, 5], [2014, 1, 2, 18]],
|
||||||
expect(date!.valueOf()).toEqual(dateTime([2014, 1, 2, 18]).valueOf());
|
['-30m-2d', [2014, 1, 5], [2014, 1, 2, 23, 30]],
|
||||||
|
['-2d-1d', [2014, 1, 5], [2014, 1, 2]],
|
||||||
|
['-1h-30m', [2014, 1, 5, 12, 0], [2014, 1, 5, 10, 30]],
|
||||||
|
['-1d-1h-30m', [2014, 1, 5, 12, 0], [2014, 1, 4, 10, 30]],
|
||||||
|
['+1d-6h', [2014, 1, 5], [2014, 1, 5, 18]],
|
||||||
|
['-1w-1d', [2014, 1, 14], [2014, 1, 6]],
|
||||||
|
])('should handle multiple math expressions: %s', (expression, inputDate, expectedDate) => {
|
||||||
|
const date = dateMath.parseDateMath(expression, dateTime(inputDate));
|
||||||
|
expect(date!.valueOf()).toEqual(dateTime(expectedDate).valueOf());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return false when invalid expression', () => {
|
it('should return false when invalid expression', () => {
|
||||||
|
|||||||
@@ -547,6 +547,11 @@ export interface FeatureToggles {
|
|||||||
*/
|
*/
|
||||||
alertingCentralAlertHistory?: boolean;
|
alertingCentralAlertHistory?: boolean;
|
||||||
/**
|
/**
|
||||||
|
* Enable new grouped navigation structure for Alerting
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
alertingNavigationV2?: boolean;
|
||||||
|
/**
|
||||||
* Preserve plugin proxy trailing slash.
|
* Preserve plugin proxy trailing slash.
|
||||||
* @default false
|
* @default false
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ func (hs *HTTPServer) DeleteDataSourceById(c *contextmodel.ReqContext) response.
|
|||||||
func (hs *HTTPServer) GetDataSourceByUID(c *contextmodel.ReqContext) response.Response {
|
func (hs *HTTPServer) GetDataSourceByUID(c *contextmodel.ReqContext) response.Response {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
defer func() {
|
defer func() {
|
||||||
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "GetDataSourceByUID"), time.Since(start).Seconds())
|
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("GetDataSourceByUID"), time.Since(start).Seconds())
|
||||||
}()
|
}()
|
||||||
|
|
||||||
ds, err := hs.getRawDataSourceByUID(c.Req.Context(), web.Params(c.Req)[":uid"], c.GetOrgID())
|
ds, err := hs.getRawDataSourceByUID(c.Req.Context(), web.Params(c.Req)[":uid"], c.GetOrgID())
|
||||||
@@ -240,7 +240,7 @@ func (hs *HTTPServer) GetDataSourceByUID(c *contextmodel.ReqContext) response.Re
|
|||||||
func (hs *HTTPServer) DeleteDataSourceByUID(c *contextmodel.ReqContext) response.Response {
|
func (hs *HTTPServer) DeleteDataSourceByUID(c *contextmodel.ReqContext) response.Response {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
defer func() {
|
defer func() {
|
||||||
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "DeleteDataSourceByUID"), time.Since(start).Seconds())
|
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("DeleteDataSourceByUID"), time.Since(start).Seconds())
|
||||||
}()
|
}()
|
||||||
|
|
||||||
uid := web.Params(c.Req)[":uid"]
|
uid := web.Params(c.Req)[":uid"]
|
||||||
@@ -375,7 +375,7 @@ func validateJSONData(jsonData *simplejson.Json, cfg *setting.Cfg) error {
|
|||||||
func (hs *HTTPServer) AddDataSource(c *contextmodel.ReqContext) response.Response {
|
func (hs *HTTPServer) AddDataSource(c *contextmodel.ReqContext) response.Response {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
defer func() {
|
defer func() {
|
||||||
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "AddDataSource"), time.Since(start).Seconds())
|
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("AddDataSource"), time.Since(start).Seconds())
|
||||||
}()
|
}()
|
||||||
|
|
||||||
cmd := datasources.AddDataSourceCommand{}
|
cmd := datasources.AddDataSourceCommand{}
|
||||||
@@ -497,7 +497,7 @@ func (hs *HTTPServer) UpdateDataSourceByID(c *contextmodel.ReqContext) response.
|
|||||||
func (hs *HTTPServer) UpdateDataSourceByUID(c *contextmodel.ReqContext) response.Response {
|
func (hs *HTTPServer) UpdateDataSourceByUID(c *contextmodel.ReqContext) response.Response {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
defer func() {
|
defer func() {
|
||||||
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("legacy", "UpdateDataSourceByUID"), time.Since(start).Seconds())
|
metricutil.ObserveWithExemplar(c.Req.Context(), hs.dsConfigHandlerRequestsDuration.WithLabelValues("UpdateDataSourceByUID"), time.Since(start).Seconds())
|
||||||
}()
|
}()
|
||||||
cmd := datasources.UpdateDataSourceCommand{}
|
cmd := datasources.UpdateDataSourceCommand{}
|
||||||
if err := web.Bind(c.Req, &cmd); err != nil {
|
if err := web.Bind(c.Req, &cmd); err != nil {
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ func setupDsConfigHandlerMetrics() (prometheus.Registerer, *prometheus.Histogram
|
|||||||
Namespace: "grafana",
|
Namespace: "grafana",
|
||||||
Name: "ds_config_handler_requests_duration_seconds",
|
Name: "ds_config_handler_requests_duration_seconds",
|
||||||
Help: "Duration of requests handled by datasource configuration handlers",
|
Help: "Duration of requests handled by datasource configuration handlers",
|
||||||
}, []string{"code_path", "handler"})
|
}, []string{"handler"})
|
||||||
promRegister.MustRegister(dsConfigHandlerRequestsDuration)
|
promRegister.MustRegister(dsConfigHandlerRequestsDuration)
|
||||||
return promRegister, dsConfigHandlerRequestsDuration
|
return promRegister, dsConfigHandlerRequestsDuration
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -387,7 +387,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
|
|||||||
Namespace: "grafana",
|
Namespace: "grafana",
|
||||||
Name: "ds_config_handler_requests_duration_seconds",
|
Name: "ds_config_handler_requests_duration_seconds",
|
||||||
Help: "Duration of requests handled by datasource configuration handlers",
|
Help: "Duration of requests handled by datasource configuration handlers",
|
||||||
}, []string{"code_path", "handler"}),
|
}, []string{"handler"}),
|
||||||
}
|
}
|
||||||
|
|
||||||
promRegister.MustRegister(hs.htmlHandlerRequestsDuration)
|
promRegister.MustRegister(hs.htmlHandlerRequestsDuration)
|
||||||
|
|||||||
@@ -928,9 +928,10 @@ func getDatasourceProxiedRequest(t *testing.T, ctx *contextmodel.ReqContext, cfg
|
|||||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||||
features := featuremgmt.WithFeatures()
|
features := featuremgmt.WithFeatures()
|
||||||
quotaService := quotatest.New(false, nil)
|
quotaService := quotatest.New(false, nil)
|
||||||
|
dsRetriever := datasourceservice.ProvideDataSourceRetriever(sqlStore, features)
|
||||||
dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, features, acimpl.ProvideAccessControl(features),
|
dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, features, acimpl.ProvideAccessControl(features),
|
||||||
&actest.FakePermissionsService{}, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{},
|
&actest.FakePermissionsService{}, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{},
|
||||||
plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()))
|
plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()), dsRetriever)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
proxy, err := NewDataSourceProxy(ds, routes, ctx, "", cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, features)
|
proxy, err := NewDataSourceProxy(ds, routes, ctx, "", cfg, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, features)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -1050,9 +1051,11 @@ func runDatasourceAuthTest(t *testing.T, secretsService secrets.Service, secrets
|
|||||||
var routes []*plugins.Route
|
var routes []*plugins.Route
|
||||||
features := featuremgmt.WithFeatures()
|
features := featuremgmt.WithFeatures()
|
||||||
quotaService := quotatest.New(false, nil)
|
quotaService := quotatest.New(false, nil)
|
||||||
dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, features, acimpl.ProvideAccessControl(features),
|
var sqlStore db.DB = nil
|
||||||
|
dsRetriever := datasourceservice.ProvideDataSourceRetriever(sqlStore, features)
|
||||||
|
dsService, err := datasourceservice.ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acimpl.ProvideAccessControl(features),
|
||||||
&actest.FakePermissionsService{}, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{},
|
&actest.FakePermissionsService{}, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{},
|
||||||
plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()))
|
plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()), dsRetriever)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
proxy, err := NewDataSourceProxy(test.datasource, routes, ctx, "", &setting.Cfg{}, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, features)
|
proxy, err := NewDataSourceProxy(test.datasource, routes, ctx, "", &setting.Cfg{}, httpclient.NewProvider(), &oauthtoken.Service{}, dsService, tracer, features)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -1106,9 +1109,11 @@ func setupDSProxyTest(t *testing.T, ctx *contextmodel.ReqContext, ds *datasource
|
|||||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||||
secretsStore := secretskvs.NewSQLSecretsKVStore(dbtest.NewFakeDB(), secretsService, log.NewNopLogger())
|
secretsStore := secretskvs.NewSQLSecretsKVStore(dbtest.NewFakeDB(), secretsService, log.NewNopLogger())
|
||||||
features := featuremgmt.WithFeatures()
|
features := featuremgmt.WithFeatures()
|
||||||
dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, features, acimpl.ProvideAccessControl(features),
|
var sqlStore db.DB = nil
|
||||||
|
dsRetriever := datasourceservice.ProvideDataSourceRetriever(sqlStore, features)
|
||||||
|
dsService, err := datasourceservice.ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acimpl.ProvideAccessControl(features),
|
||||||
&actest.FakePermissionsService{}, quotatest.New(false, nil), &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{},
|
&actest.FakePermissionsService{}, quotatest.New(false, nil), &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{},
|
||||||
plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()))
|
plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()), dsRetriever)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
tracer := tracing.InitializeTracerForTest()
|
tracer := tracing.InitializeTracerForTest()
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import (
|
|||||||
_ "github.com/Azure/azure-sdk-for-go/services/keyvault/v7.1/keyvault"
|
_ "github.com/Azure/azure-sdk-for-go/services/keyvault/v7.1/keyvault"
|
||||||
_ "github.com/Azure/go-autorest/autorest"
|
_ "github.com/Azure/go-autorest/autorest"
|
||||||
_ "github.com/Azure/go-autorest/autorest/adal"
|
_ "github.com/Azure/go-autorest/autorest/adal"
|
||||||
|
_ "github.com/aws/aws-sdk-go-v2/credentials"
|
||||||
|
_ "github.com/aws/aws-sdk-go-v2/service/secretsmanager"
|
||||||
|
_ "github.com/aws/aws-sdk-go-v2/service/sts"
|
||||||
_ "github.com/beevik/etree"
|
_ "github.com/beevik/etree"
|
||||||
_ "github.com/blugelabs/bluge"
|
_ "github.com/blugelabs/bluge"
|
||||||
_ "github.com/blugelabs/bluge_segment_api"
|
_ "github.com/blugelabs/bluge_segment_api"
|
||||||
@@ -46,7 +49,6 @@ import (
|
|||||||
_ "sigs.k8s.io/randfill"
|
_ "sigs.k8s.io/randfill"
|
||||||
_ "xorm.io/builder"
|
_ "xorm.io/builder"
|
||||||
|
|
||||||
_ "github.com/aws/aws-sdk-go-v2/service/secretsmanager"
|
|
||||||
_ "github.com/grafana/authlib/authn"
|
_ "github.com/grafana/authlib/authn"
|
||||||
_ "github.com/grafana/authlib/authz"
|
_ "github.com/grafana/authlib/authz"
|
||||||
_ "github.com/grafana/authlib/cache"
|
_ "github.com/grafana/authlib/cache"
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ func (ots *TracingService) initSampler() (tracesdk.Sampler, error) {
|
|||||||
case "rateLimiting":
|
case "rateLimiting":
|
||||||
return newRateLimiter(ots.cfg.SamplerParam), nil
|
return newRateLimiter(ots.cfg.SamplerParam), nil
|
||||||
case "remote":
|
case "remote":
|
||||||
return jaegerremote.New("grafana",
|
return jaegerremote.New(ots.cfg.ServiceName,
|
||||||
jaegerremote.WithSamplingServerURL(ots.cfg.SamplerRemoteURL),
|
jaegerremote.WithSamplingServerURL(ots.cfg.SamplerRemoteURL),
|
||||||
jaegerremote.WithInitialSampler(tracesdk.TraceIDRatioBased(ots.cfg.SamplerParam)),
|
jaegerremote.WithInitialSampler(tracesdk.TraceIDRatioBased(ots.cfg.SamplerParam)),
|
||||||
), nil
|
), nil
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ func (s *legacyStorage) ConvertToTable(ctx context.Context, object runtime.Objec
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
|
func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
|
||||||
|
if s.dsConfigHandlerRequestsDuration != nil {
|
||||||
|
start := time.Now()
|
||||||
|
defer func() {
|
||||||
|
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.List"), time.Since(start).Seconds())
|
||||||
|
}()
|
||||||
|
}
|
||||||
return s.datasources.ListDataSources(ctx)
|
return s.datasources.ListDataSources(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +70,7 @@ func (s *legacyStorage) Get(ctx context.Context, name string, options *metav1.Ge
|
|||||||
if s.dsConfigHandlerRequestsDuration != nil {
|
if s.dsConfigHandlerRequestsDuration != nil {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
defer func() {
|
defer func() {
|
||||||
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Get"), time.Since(start).Seconds())
|
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.Get"), time.Since(start).Seconds())
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +82,7 @@ func (s *legacyStorage) Create(ctx context.Context, obj runtime.Object, createVa
|
|||||||
if s.dsConfigHandlerRequestsDuration != nil {
|
if s.dsConfigHandlerRequestsDuration != nil {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
defer func() {
|
defer func() {
|
||||||
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds())
|
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.Create"), time.Since(start).Seconds())
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +98,7 @@ func (s *legacyStorage) Update(ctx context.Context, name string, objInfo rest.Up
|
|||||||
if s.dsConfigHandlerRequestsDuration != nil {
|
if s.dsConfigHandlerRequestsDuration != nil {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
defer func() {
|
defer func() {
|
||||||
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds())
|
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.Update"), time.Since(start).Seconds())
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +141,7 @@ func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidatio
|
|||||||
if s.dsConfigHandlerRequestsDuration != nil {
|
if s.dsConfigHandlerRequestsDuration != nil {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
defer func() {
|
defer func() {
|
||||||
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("new", "Create"), time.Since(start).Seconds())
|
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.Delete"), time.Since(start).Seconds())
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +151,13 @@ func (s *legacyStorage) Delete(ctx context.Context, name string, deleteValidatio
|
|||||||
|
|
||||||
// DeleteCollection implements rest.CollectionDeleter.
|
// DeleteCollection implements rest.CollectionDeleter.
|
||||||
func (s *legacyStorage) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) {
|
func (s *legacyStorage) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) {
|
||||||
|
if s.dsConfigHandlerRequestsDuration != nil {
|
||||||
|
start := time.Now()
|
||||||
|
defer func() {
|
||||||
|
metricutil.ObserveWithExemplar(ctx, s.dsConfigHandlerRequestsDuration.WithLabelValues("legacyStorage.DeleteCollection"), time.Since(start).Seconds())
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
dss, err := s.datasources.ListDataSources(ctx)
|
dss, err := s.datasources.ListDataSources(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
datasourceV0 "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1"
|
datasourceV0 "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1"
|
||||||
queryV0 "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
|
queryV0 "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
|
||||||
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
|
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
|
||||||
|
"github.com/grafana/grafana/pkg/infra/metrics"
|
||||||
"github.com/grafana/grafana/pkg/infra/metrics/metricutil"
|
"github.com/grafana/grafana/pkg/infra/metrics/metricutil"
|
||||||
"github.com/grafana/grafana/pkg/plugins"
|
"github.com/grafana/grafana/pkg/plugins"
|
||||||
"github.com/grafana/grafana/pkg/plugins/manager/sources"
|
"github.com/grafana/grafana/pkg/plugins/manager/sources"
|
||||||
@@ -69,10 +70,10 @@ func RegisterAPIService(
|
|||||||
|
|
||||||
dataSourceCRUDMetric := metricutil.NewHistogramVec(prometheus.HistogramOpts{
|
dataSourceCRUDMetric := metricutil.NewHistogramVec(prometheus.HistogramOpts{
|
||||||
Namespace: "grafana",
|
Namespace: "grafana",
|
||||||
Name: "ds_config_handler_requests_duration_seconds",
|
Name: "ds_config_handler_apis_requests_duration_seconds",
|
||||||
Help: "Duration of requests handled by datasource configuration handlers",
|
Help: "Duration of requests handled by new k8s style APIs datasource configuration handlers",
|
||||||
}, []string{"code_path", "handler"})
|
}, []string{"handler"})
|
||||||
regErr := reg.Register(dataSourceCRUDMetric)
|
regErr := metrics.ProvideRegisterer().Register(dataSourceCRUDMetric)
|
||||||
if regErr != nil && !errors.As(regErr, &prometheus.AlreadyRegisteredError{}) {
|
if regErr != nil && !errors.As(regErr, &prometheus.AlreadyRegisteredError{}) {
|
||||||
return nil, regErr
|
return nil, regErr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/services/apiserver"
|
"github.com/grafana/grafana/pkg/services/apiserver"
|
||||||
"github.com/grafana/grafana/pkg/services/apiserver/appinstaller"
|
"github.com/grafana/grafana/pkg/services/apiserver/appinstaller"
|
||||||
grafanaauthorizer "github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer"
|
grafanaauthorizer "github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer"
|
||||||
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginassets"
|
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginassets"
|
||||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
|
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
|
||||||
)
|
)
|
||||||
@@ -36,9 +37,13 @@ func ProvideAppInstaller(
|
|||||||
pluginStore pluginstore.Store,
|
pluginStore pluginstore.Store,
|
||||||
pluginAssetsService *pluginassets.Service,
|
pluginAssetsService *pluginassets.Service,
|
||||||
accessControlService accesscontrol.Service, accessClient authlib.AccessClient,
|
accessControlService accesscontrol.Service, accessClient authlib.AccessClient,
|
||||||
|
features featuremgmt.FeatureToggles,
|
||||||
) (*AppInstaller, error) {
|
) (*AppInstaller, error) {
|
||||||
if err := registerAccessControlRoles(accessControlService); err != nil {
|
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||||
return nil, fmt.Errorf("registering access control roles: %w", err)
|
if features.IsEnabledGlobally(featuremgmt.FlagPluginStoreServiceLoading) {
|
||||||
|
if err := registerAccessControlRoles(accessControlService); err != nil {
|
||||||
|
return nil, fmt.Errorf("registering access control roles: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
localProvider := meta.NewLocalProvider(pluginStore, pluginAssetsService)
|
localProvider := meta.NewLocalProvider(pluginStore, pluginAssetsService)
|
||||||
|
|||||||
@@ -330,6 +330,7 @@ var wireBasicSet = wire.NewSet(
|
|||||||
dashsnapstore.ProvideStore,
|
dashsnapstore.ProvideStore,
|
||||||
wire.Bind(new(dashboardsnapshots.Service), new(*dashsnapsvc.ServiceImpl)),
|
wire.Bind(new(dashboardsnapshots.Service), new(*dashsnapsvc.ServiceImpl)),
|
||||||
dashsnapsvc.ProvideService,
|
dashsnapsvc.ProvideService,
|
||||||
|
datasourceservice.ProvideDataSourceRetriever,
|
||||||
datasourceservice.ProvideService,
|
datasourceservice.ProvideService,
|
||||||
wire.Bind(new(datasources.DataSourceService), new(*datasourceservice.Service)),
|
wire.Bind(new(datasources.DataSourceService), new(*datasourceservice.Service)),
|
||||||
datasourceservice.ProvideLegacyDataSourceLookup,
|
datasourceservice.ProvideLegacyDataSourceLookup,
|
||||||
|
|||||||
12
pkg/server/wire_gen.go
generated
12
pkg/server/wire_gen.go
generated
File diff suppressed because one or more lines are too long
@@ -3,7 +3,6 @@ package authorizer
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
|
||||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||||
k8suser "k8s.io/apiserver/pkg/authentication/user"
|
k8suser "k8s.io/apiserver/pkg/authentication/user"
|
||||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||||
@@ -29,9 +28,9 @@ type GrafanaAuthorizer struct {
|
|||||||
// 4. We check authorizer that is configured speficially for an api.
|
// 4. We check authorizer that is configured speficially for an api.
|
||||||
// 5. As a last fallback we check Role, this will only happen if an api have not configured
|
// 5. As a last fallback we check Role, this will only happen if an api have not configured
|
||||||
// an authorizer or return authorizer.DecisionNoOpinion
|
// an authorizer or return authorizer.DecisionNoOpinion
|
||||||
func NewGrafanaBuiltInSTAuthorizer(cfg *setting.Cfg) *GrafanaAuthorizer {
|
func NewGrafanaBuiltInSTAuthorizer() *GrafanaAuthorizer {
|
||||||
authorizers := []authorizer.Authorizer{
|
authorizers := []authorizer.Authorizer{
|
||||||
newImpersonationAuthorizer(),
|
NewImpersonationAuthorizer(),
|
||||||
authorizerfactory.NewPrivilegedGroups(k8suser.SystemPrivilegedGroup),
|
authorizerfactory.NewPrivilegedGroups(k8suser.SystemPrivilegedGroup),
|
||||||
newNamespaceAuthorizer(),
|
newNamespaceAuthorizer(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
|
|
||||||
var _ authorizer.Authorizer = (*impersonationAuthorizer)(nil)
|
var _ authorizer.Authorizer = (*impersonationAuthorizer)(nil)
|
||||||
|
|
||||||
func newImpersonationAuthorizer() *impersonationAuthorizer {
|
func NewImpersonationAuthorizer() *impersonationAuthorizer {
|
||||||
return &impersonationAuthorizer{}
|
return &impersonationAuthorizer{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,19 +76,7 @@ var PathRewriters = []filters.PathRewriter{
|
|||||||
|
|
||||||
func GetDefaultBuildHandlerChainFunc(builders []APIGroupBuilder, reg prometheus.Registerer) BuildHandlerChainFunc {
|
func GetDefaultBuildHandlerChainFunc(builders []APIGroupBuilder, reg prometheus.Registerer) BuildHandlerChainFunc {
|
||||||
return func(delegateHandler http.Handler, c *genericapiserver.Config) http.Handler {
|
return func(delegateHandler http.Handler, c *genericapiserver.Config) http.Handler {
|
||||||
requestHandler, err := GetCustomRoutesHandler(
|
handler := filters.WithTracingHTTPLoggingAttributes(delegateHandler)
|
||||||
delegateHandler,
|
|
||||||
c.LoopbackClientConfig,
|
|
||||||
builders,
|
|
||||||
reg,
|
|
||||||
c.MergedResourceConfig,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
panic(fmt.Sprintf("could not build the request handler for specified API builders: %s", err.Error()))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Needs to run last in request chain to function as expected, hence we register it first.
|
|
||||||
handler := filters.WithTracingHTTPLoggingAttributes(requestHandler)
|
|
||||||
|
|
||||||
// filters.WithRequester needs to be after the K8s chain because it depends on the K8s user in context
|
// filters.WithRequester needs to be after the K8s chain because it depends on the K8s user in context
|
||||||
handler = filters.WithRequester(handler)
|
handler = filters.WithRequester(handler)
|
||||||
|
|||||||
@@ -3,146 +3,306 @@ package builder
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/emicklei/go-restful/v3"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
serverstorage "k8s.io/apiserver/pkg/server/storage"
|
serverstorage "k8s.io/apiserver/pkg/server/storage"
|
||||||
restclient "k8s.io/client-go/rest"
|
|
||||||
klog "k8s.io/klog/v2"
|
klog "k8s.io/klog/v2"
|
||||||
"k8s.io/kube-openapi/pkg/spec3"
|
"k8s.io/kube-openapi/pkg/spec3"
|
||||||
)
|
)
|
||||||
|
|
||||||
type requestHandler struct {
|
// convertHandlerToRouteFunction converts an http.HandlerFunc to a restful.RouteFunction
|
||||||
router *mux.Router
|
// It extracts path parameters from restful.Request and populates them in the request context
|
||||||
|
// so that mux.Vars can read them (for backward compatibility with handlers that use mux.Vars)
|
||||||
|
func convertHandlerToRouteFunction(handler http.HandlerFunc) restful.RouteFunction {
|
||||||
|
return func(req *restful.Request, resp *restful.Response) {
|
||||||
|
// Extract path parameters from restful.Request and populate mux.Vars
|
||||||
|
// This is needed for backward compatibility with handlers that use mux.Vars(r)
|
||||||
|
vars := make(map[string]string)
|
||||||
|
|
||||||
|
// Get all path parameters from the restful.Request
|
||||||
|
// The restful.Request has PathParameters() method that returns a map
|
||||||
|
pathParams := req.PathParameters()
|
||||||
|
for key, value := range pathParams {
|
||||||
|
vars[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the vars in the request context using mux.SetURLVars
|
||||||
|
// This makes mux.Vars(r) work correctly
|
||||||
|
if len(vars) > 0 {
|
||||||
|
req.Request = mux.SetURLVars(req.Request, vars)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler(resp.ResponseWriter, req.Request)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetCustomRoutesHandler(delegateHandler http.Handler, restConfig *restclient.Config, builders []APIGroupBuilder, metricsRegistry prometheus.Registerer, apiResourceConfig *serverstorage.ResourceConfig) (http.Handler, error) {
|
// AugmentWebServicesWithCustomRoutes adds custom routes from builders to existing WebServices
|
||||||
useful := false // only true if any routes exist anywhere
|
// in the container.
|
||||||
router := mux.NewRouter()
|
func AugmentWebServicesWithCustomRoutes(
|
||||||
|
container *restful.Container,
|
||||||
|
builders []APIGroupBuilder,
|
||||||
|
metricsRegistry prometheus.Registerer,
|
||||||
|
apiResourceConfig *serverstorage.ResourceConfig,
|
||||||
|
) error {
|
||||||
|
if container == nil {
|
||||||
|
return fmt.Errorf("container cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
metrics := NewCustomRouteMetrics(metricsRegistry)
|
metrics := NewCustomRouteMetrics(metricsRegistry)
|
||||||
|
|
||||||
for _, builder := range builders {
|
// Build a map of existing WebServices by root path
|
||||||
provider, ok := builder.(APIGroupRouteProvider)
|
existingWebServices := make(map[string]*restful.WebService)
|
||||||
|
for _, ws := range container.RegisteredWebServices() {
|
||||||
|
existingWebServices[ws.RootPath()] = ws
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, b := range builders {
|
||||||
|
provider, ok := b.(APIGroupRouteProvider)
|
||||||
if !ok || provider == nil {
|
if !ok || provider == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, gv := range GetGroupVersions(builder) {
|
for _, gv := range GetGroupVersions(b) {
|
||||||
// filter out api groups that are disabled in APIEnablementOptions
|
// Filter out disabled API groups
|
||||||
gvr := gv.WithResource("")
|
gvr := gv.WithResource("")
|
||||||
if apiResourceConfig != nil && !apiResourceConfig.ResourceEnabled(gvr) {
|
if apiResourceConfig != nil && !apiResourceConfig.ResourceEnabled(gvr) {
|
||||||
klog.InfoS("Skipping custom route handler for disabled group version", "gv", gv.String())
|
klog.InfoS("Skipping custom routes for disabled group version", "gv", gv.String())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
routes := provider.GetAPIRoutes(gv)
|
routes := provider.GetAPIRoutes(gv)
|
||||||
if routes == nil {
|
if routes == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
prefix := "/apis/" + gv.String()
|
// Find or create WebService for this group version
|
||||||
|
rootPath := "/apis/" + gv.String()
|
||||||
// Root handlers
|
ws, exists := existingWebServices[rootPath]
|
||||||
var sub *mux.Router
|
if !exists {
|
||||||
for _, route := range routes.Root {
|
// Create a new WebService if one doesn't exist
|
||||||
if sub == nil {
|
ws = new(restful.WebService)
|
||||||
sub = router.PathPrefix(prefix).Subrouter()
|
ws.Path(rootPath)
|
||||||
sub.MethodNotAllowedHandler = &methodNotAllowedHandler{}
|
container.Add(ws)
|
||||||
}
|
existingWebServices[rootPath] = ws
|
||||||
|
|
||||||
useful = true
|
|
||||||
methods, err := methodsFromSpec(route.Path, route.Spec)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
instrumentedHandler := metrics.InstrumentHandler(
|
|
||||||
gv.Group,
|
|
||||||
gv.Version,
|
|
||||||
route.Path, // Use path as resource identifier
|
|
||||||
route.Handler,
|
|
||||||
)
|
|
||||||
|
|
||||||
sub.HandleFunc("/"+route.Path, instrumentedHandler).
|
|
||||||
Methods(methods...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Namespace handlers
|
// Add root handlers using OpenAPI specs
|
||||||
sub = nil
|
for _, route := range routes.Root {
|
||||||
prefix += "/namespaces/{namespace}"
|
|
||||||
for _, route := range routes.Namespace {
|
|
||||||
if sub == nil {
|
|
||||||
sub = router.PathPrefix(prefix).Subrouter()
|
|
||||||
sub.MethodNotAllowedHandler = &methodNotAllowedHandler{}
|
|
||||||
}
|
|
||||||
|
|
||||||
useful = true
|
|
||||||
methods, err := methodsFromSpec(route.Path, route.Spec)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
instrumentedHandler := metrics.InstrumentHandler(
|
instrumentedHandler := metrics.InstrumentHandler(
|
||||||
gv.Group,
|
gv.Group,
|
||||||
gv.Version,
|
gv.Version,
|
||||||
route.Path, // Use path as resource identifier
|
route.Path,
|
||||||
route.Handler,
|
route.Handler,
|
||||||
)
|
)
|
||||||
|
routeFunction := convertHandlerToRouteFunction(instrumentedHandler)
|
||||||
|
|
||||||
sub.HandleFunc("/"+route.Path, instrumentedHandler).
|
// Use OpenAPI spec to configure routes properly
|
||||||
Methods(methods...)
|
if err := addRouteFromSpec(ws, route.Path, route.Spec, routeFunction, false); err != nil {
|
||||||
|
return fmt.Errorf("failed to add root route %s: %w", route.Path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add namespace handlers using OpenAPI specs
|
||||||
|
for _, route := range routes.Namespace {
|
||||||
|
instrumentedHandler := metrics.InstrumentHandler(
|
||||||
|
gv.Group,
|
||||||
|
gv.Version,
|
||||||
|
route.Path,
|
||||||
|
route.Handler,
|
||||||
|
)
|
||||||
|
routeFunction := convertHandlerToRouteFunction(instrumentedHandler)
|
||||||
|
|
||||||
|
// Use OpenAPI spec to configure routes properly
|
||||||
|
if err := addRouteFromSpec(ws, route.Path, route.Spec, routeFunction, true); err != nil {
|
||||||
|
return fmt.Errorf("failed to add namespace route %s: %w", route.Path, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !useful {
|
return nil
|
||||||
return delegateHandler, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per Gorilla Mux issue here: https://github.com/gorilla/mux/issues/616#issuecomment-798807509
|
|
||||||
// default handler must come last
|
|
||||||
router.PathPrefix("/").Handler(delegateHandler)
|
|
||||||
|
|
||||||
return &requestHandler{
|
|
||||||
router: router,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *requestHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
// addRouteFromSpec adds routes to a WebService using OpenAPI specs
|
||||||
h.router.ServeHTTP(w, req)
|
func addRouteFromSpec(ws *restful.WebService, routePath string, pathProps *spec3.PathProps, handler restful.RouteFunction, isNamespaced bool) error {
|
||||||
|
if pathProps == nil {
|
||||||
|
return fmt.Errorf("pathProps cannot be nil for route %s", routePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the full path (relative to WebService root)
|
||||||
|
var fullPath string
|
||||||
|
if isNamespaced {
|
||||||
|
fullPath = "/namespaces/{namespace}/" + routePath
|
||||||
|
} else {
|
||||||
|
fullPath = "/" + routePath
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add routes for each HTTP method defined in the OpenAPI spec
|
||||||
|
operations := map[string]*spec3.Operation{
|
||||||
|
"GET": pathProps.Get,
|
||||||
|
"POST": pathProps.Post,
|
||||||
|
"PUT": pathProps.Put,
|
||||||
|
"PATCH": pathProps.Patch,
|
||||||
|
"DELETE": pathProps.Delete,
|
||||||
|
}
|
||||||
|
|
||||||
|
for method, operation := range operations {
|
||||||
|
if operation == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create route builder for this method
|
||||||
|
var routeBuilder *restful.RouteBuilder
|
||||||
|
switch method {
|
||||||
|
case "GET":
|
||||||
|
routeBuilder = ws.GET(fullPath)
|
||||||
|
case "POST":
|
||||||
|
routeBuilder = ws.POST(fullPath)
|
||||||
|
case "PUT":
|
||||||
|
routeBuilder = ws.PUT(fullPath)
|
||||||
|
case "PATCH":
|
||||||
|
routeBuilder = ws.PATCH(fullPath)
|
||||||
|
case "DELETE":
|
||||||
|
routeBuilder = ws.DELETE(fullPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set operation ID from OpenAPI spec (with K8s verb prefix if needed)
|
||||||
|
operationID := operation.OperationId
|
||||||
|
if operationID == "" {
|
||||||
|
// Generate from path if not specified
|
||||||
|
operationID = generateOperationNameFromPath(routePath)
|
||||||
|
}
|
||||||
|
operationID = prefixRouteIDWithK8sVerbIfNotPresent(operationID, method)
|
||||||
|
routeBuilder = routeBuilder.Operation(operationID)
|
||||||
|
|
||||||
|
// Add description from OpenAPI spec
|
||||||
|
if operation.Description != "" {
|
||||||
|
routeBuilder = routeBuilder.Doc(operation.Description)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if namespace parameter is already in the OpenAPI spec
|
||||||
|
hasNamespaceParam := false
|
||||||
|
if operation.Parameters != nil {
|
||||||
|
for _, param := range operation.Parameters {
|
||||||
|
if param.Name == "namespace" && param.In == "path" {
|
||||||
|
hasNamespaceParam = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add namespace parameter for namespaced routes if not already in spec
|
||||||
|
if isNamespaced && !hasNamespaceParam {
|
||||||
|
routeBuilder = routeBuilder.Param(restful.PathParameter("namespace", "object name and auth scope, such as for teams and projects"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add parameters from OpenAPI spec
|
||||||
|
if operation.Parameters != nil {
|
||||||
|
for _, param := range operation.Parameters {
|
||||||
|
switch param.In {
|
||||||
|
case "path":
|
||||||
|
routeBuilder = routeBuilder.Param(restful.PathParameter(param.Name, param.Description))
|
||||||
|
case "query":
|
||||||
|
routeBuilder = routeBuilder.Param(restful.QueryParameter(param.Name, param.Description))
|
||||||
|
case "header":
|
||||||
|
routeBuilder = routeBuilder.Param(restful.HeaderParameter(param.Name, param.Description))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: Request/response schemas are already defined in the OpenAPI spec from builders
|
||||||
|
// and will be added to the OpenAPI document via addBuilderRoutes in openapi.go.
|
||||||
|
// We don't duplicate that information here since restful uses the route metadata
|
||||||
|
// for OpenAPI generation, which is handled separately in this codebase.
|
||||||
|
|
||||||
|
// Register the route with handler
|
||||||
|
ws.Route(routeBuilder.To(handler))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func methodsFromSpec(slug string, props *spec3.PathProps) ([]string, error) {
|
func prefixRouteIDWithK8sVerbIfNotPresent(operationID string, method string) string {
|
||||||
if props == nil {
|
for _, verb := range allowedK8sVerbs {
|
||||||
return []string{"GET", "POST", "PUT", "PATCH", "DELETE"}, nil
|
if len(operationID) > len(verb) && operationID[:len(verb)] == verb {
|
||||||
|
return operationID
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return fmt.Sprintf("%s%s", httpMethodToK8sVerb[strings.ToUpper(method)], operationID)
|
||||||
methods := make([]string, 0)
|
|
||||||
if props.Get != nil {
|
|
||||||
methods = append(methods, "GET")
|
|
||||||
}
|
|
||||||
if props.Post != nil {
|
|
||||||
methods = append(methods, "POST")
|
|
||||||
}
|
|
||||||
if props.Put != nil {
|
|
||||||
methods = append(methods, "PUT")
|
|
||||||
}
|
|
||||||
if props.Patch != nil {
|
|
||||||
methods = append(methods, "PATCH")
|
|
||||||
}
|
|
||||||
if props.Delete != nil {
|
|
||||||
methods = append(methods, "DELETE")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(methods) == 0 {
|
|
||||||
return nil, fmt.Errorf("invalid OpenAPI Spec for slug=%s without any methods in PathProps", slug)
|
|
||||||
}
|
|
||||||
|
|
||||||
return methods, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type methodNotAllowedHandler struct{}
|
var allowedK8sVerbs = []string{
|
||||||
|
"get", "log", "read", "replace", "patch", "delete", "deletecollection", "watch", "connect", "proxy", "list", "create", "patch",
|
||||||
func (h *methodNotAllowedHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
}
|
||||||
w.WriteHeader(405) // method not allowed
|
|
||||||
|
var httpMethodToK8sVerb = map[string]string{
|
||||||
|
http.MethodGet: "get",
|
||||||
|
http.MethodPost: "create",
|
||||||
|
http.MethodPut: "replace",
|
||||||
|
http.MethodPatch: "patch",
|
||||||
|
http.MethodDelete: "delete",
|
||||||
|
http.MethodConnect: "connect",
|
||||||
|
http.MethodOptions: "connect", // No real equivalent to options and head
|
||||||
|
http.MethodHead: "connect",
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateOperationNameFromPath creates an operation name from a route path.
|
||||||
|
// The operation name is used by the OpenAPI generator and should be descriptive.
|
||||||
|
// It uses meaningful path segments to create readable yet unique operation names.
|
||||||
|
// Examples:
|
||||||
|
// - "/search" -> "Search"
|
||||||
|
// - "/snapshots/create" -> "SnapshotsCreate"
|
||||||
|
// - "ofrep/v1/evaluate/flags" -> "OfrepEvaluateFlags"
|
||||||
|
// - "ofrep/v1/evaluate/flags/{flagKey}" -> "OfrepEvaluateFlagsFlagKey"
|
||||||
|
func generateOperationNameFromPath(routePath string) string {
|
||||||
|
// Remove leading slash and split by path segments
|
||||||
|
parts := strings.Split(strings.TrimPrefix(routePath, "/"), "/")
|
||||||
|
|
||||||
|
// Filter to keep meaningful segments and path parameters
|
||||||
|
var nameParts []string
|
||||||
|
skipPrefixes := map[string]bool{
|
||||||
|
"namespaces": true,
|
||||||
|
"apis": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, part := range parts {
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract parameter name from {paramName} format
|
||||||
|
if strings.HasPrefix(part, "{") && strings.HasSuffix(part, "}") {
|
||||||
|
paramName := part[1 : len(part)-1]
|
||||||
|
// Skip generic parameters like {namespace}, but keep specific ones like {flagKey}
|
||||||
|
if paramName != "namespace" && paramName != "name" {
|
||||||
|
nameParts = append(nameParts, strings.ToUpper(paramName[:1])+paramName[1:])
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip common prefixes
|
||||||
|
if skipPrefixes[strings.ToLower(part)] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip version segments like v1, v0alpha1, v2beta1, etc.
|
||||||
|
if strings.HasPrefix(strings.ToLower(part), "v") &&
|
||||||
|
(len(part) <= 3 || strings.Contains(strings.ToLower(part), "alpha") || strings.Contains(strings.ToLower(part), "beta")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capitalize first letter and add to parts
|
||||||
|
if len(part) > 0 {
|
||||||
|
nameParts = append(nameParts, strings.ToUpper(part[:1])+part[1:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(nameParts) == 0 {
|
||||||
|
return "Route"
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(nameParts, "")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/services/apiserver/options"
|
"github.com/grafana/grafana/pkg/services/apiserver/options"
|
||||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||||
@@ -41,15 +40,6 @@ func applyGrafanaConfig(cfg *setting.Cfg, features featuremgmt.FeatureToggles, o
|
|||||||
apiserverCfg := cfg.SectionWithEnvOverrides("grafana-apiserver")
|
apiserverCfg := cfg.SectionWithEnvOverrides("grafana-apiserver")
|
||||||
|
|
||||||
runtimeConfig := apiserverCfg.Key("runtime_config").String()
|
runtimeConfig := apiserverCfg.Key("runtime_config").String()
|
||||||
runtimeConfigSplit := strings.Split(runtimeConfig, ",")
|
|
||||||
|
|
||||||
// TODO: temporary fix to allow disabling local features service and still being able to use its authz handler
|
|
||||||
if !cfg.OpenFeature.APIEnabled {
|
|
||||||
runtimeConfigSplit = append(runtimeConfigSplit, "features.grafana.app/v0alpha1=false")
|
|
||||||
}
|
|
||||||
|
|
||||||
runtimeConfig = strings.Join(runtimeConfigSplit, ",")
|
|
||||||
|
|
||||||
if runtimeConfig != "" {
|
if runtimeConfig != "" {
|
||||||
if err := o.APIEnablementOptions.RuntimeConfig.Set(runtimeConfig); err != nil {
|
if err := o.APIEnablementOptions.RuntimeConfig.Set(runtimeConfig); err != nil {
|
||||||
return fmt.Errorf("failed to set runtime config: %w", err)
|
return fmt.Errorf("failed to set runtime config: %w", err)
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ func ProvideService(
|
|||||||
features: features,
|
features: features,
|
||||||
rr: rr,
|
rr: rr,
|
||||||
builders: []builder.APIGroupBuilder{},
|
builders: []builder.APIGroupBuilder{},
|
||||||
authorizer: authorizer.NewGrafanaBuiltInSTAuthorizer(cfg),
|
authorizer: authorizer.NewGrafanaBuiltInSTAuthorizer(),
|
||||||
tracing: tracing,
|
tracing: tracing,
|
||||||
db: db, // For Unified storage
|
db: db, // For Unified storage
|
||||||
metrics: reg,
|
metrics: reg,
|
||||||
@@ -443,6 +443,19 @@ func (s *service) start(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Augment existing WebServices with custom routes from builders
|
||||||
|
// This directly adds routes to existing WebServices using the OpenAPI specs from builders
|
||||||
|
if server.Handler != nil && server.Handler.GoRestfulContainer != nil {
|
||||||
|
if err := builder.AugmentWebServicesWithCustomRoutes(
|
||||||
|
server.Handler.GoRestfulContainer,
|
||||||
|
builders,
|
||||||
|
s.metrics,
|
||||||
|
serverConfig.MergedResourceConfig,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("failed to augment web services with custom routes: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// stash the options for later use
|
// stash the options for later use
|
||||||
s.options = o
|
s.options = o
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ type Service struct {
|
|||||||
pluginStore pluginstore.Store
|
pluginStore pluginstore.Store
|
||||||
pluginClient plugins.Client
|
pluginClient plugins.Client
|
||||||
basePluginContextProvider plugincontext.BasePluginContextProvider
|
basePluginContextProvider plugincontext.BasePluginContextProvider
|
||||||
|
retriever DataSourceRetriever
|
||||||
|
|
||||||
ptc proxyTransportCache
|
ptc proxyTransportCache
|
||||||
}
|
}
|
||||||
@@ -70,6 +71,7 @@ func ProvideService(
|
|||||||
features featuremgmt.FeatureToggles, ac accesscontrol.AccessControl, datasourcePermissionsService accesscontrol.DatasourcePermissionsService,
|
features featuremgmt.FeatureToggles, ac accesscontrol.AccessControl, datasourcePermissionsService accesscontrol.DatasourcePermissionsService,
|
||||||
quotaService quota.Service, pluginStore pluginstore.Store, pluginClient plugins.Client,
|
quotaService quota.Service, pluginStore pluginstore.Store, pluginClient plugins.Client,
|
||||||
basePluginContextProvider plugincontext.BasePluginContextProvider,
|
basePluginContextProvider plugincontext.BasePluginContextProvider,
|
||||||
|
retriever DataSourceRetriever,
|
||||||
) (*Service, error) {
|
) (*Service, error) {
|
||||||
dslogger := log.New("datasources")
|
dslogger := log.New("datasources")
|
||||||
store := &SqlStore{db: db, logger: dslogger, features: features}
|
store := &SqlStore{db: db, logger: dslogger, features: features}
|
||||||
@@ -89,6 +91,7 @@ func ProvideService(
|
|||||||
pluginStore: pluginStore,
|
pluginStore: pluginStore,
|
||||||
pluginClient: pluginClient,
|
pluginClient: pluginClient,
|
||||||
basePluginContextProvider: basePluginContextProvider,
|
basePluginContextProvider: basePluginContextProvider,
|
||||||
|
retriever: retriever,
|
||||||
}
|
}
|
||||||
|
|
||||||
ac.RegisterScopeAttributeResolver(NewNameScopeResolver(store))
|
ac.RegisterScopeAttributeResolver(NewNameScopeResolver(store))
|
||||||
@@ -175,11 +178,11 @@ func NewIDScopeResolver(db DataSourceRetriever) (string, accesscontrol.ScopeAttr
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) GetDataSource(ctx context.Context, query *datasources.GetDataSourceQuery) (*datasources.DataSource, error) {
|
func (s *Service) GetDataSource(ctx context.Context, query *datasources.GetDataSourceQuery) (*datasources.DataSource, error) {
|
||||||
return s.SQLStore.GetDataSource(ctx, query)
|
return s.retriever.GetDataSource(ctx, query)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) GetDataSourceInNamespace(ctx context.Context, namespace, name, group string) (*datasources.DataSource, error) {
|
func (s *Service) GetDataSourceInNamespace(ctx context.Context, namespace, name, group string) (*datasources.DataSource, error) {
|
||||||
return s.SQLStore.GetDataSourceInNamespace(ctx, namespace, name, group)
|
return s.retriever.GetDataSourceInNamespace(ctx, namespace, name, group)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) GetDataSources(ctx context.Context, query *datasources.GetDataSourcesQuery) ([]*datasources.DataSource, error) {
|
func (s *Service) GetDataSources(ctx context.Context, query *datasources.GetDataSourcesQuery) ([]*datasources.DataSource, error) {
|
||||||
|
|||||||
@@ -832,8 +832,9 @@ func TestIntegrationService_DeleteDataSource(t *testing.T) {
|
|||||||
quotaService := quotatest.New(false, nil)
|
quotaService := quotatest.New(false, nil)
|
||||||
permissionSvc := acmock.NewMockedPermissionsService()
|
permissionSvc := acmock.NewMockedPermissionsService()
|
||||||
permissionSvc.On("DeleteResourcePermissions", mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe()
|
permissionSvc.On("DeleteResourcePermissions", mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe()
|
||||||
|
features := featuremgmt.WithFeatures()
|
||||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, &setting.Cfg{}, featuremgmt.WithFeatures(), acmock.New(), permissionSvc, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
||||||
|
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, &setting.Cfg{}, features, acmock.New(), permissionSvc, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
cmd := &datasources.DeleteDataSourceCommand{
|
cmd := &datasources.DeleteDataSourceCommand{
|
||||||
@@ -857,7 +858,9 @@ func TestIntegrationService_DeleteDataSource(t *testing.T) {
|
|||||||
permissionSvc.On("DeleteResourcePermissions", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
|
permissionSvc.On("DeleteResourcePermissions", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
|
||||||
cfg := &setting.Cfg{}
|
cfg := &setting.Cfg{}
|
||||||
enableRBACManagedPermissions(t, cfg)
|
enableRBACManagedPermissions(t, cfg)
|
||||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), permissionSvc, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
features := featuremgmt.WithFeatures()
|
||||||
|
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
||||||
|
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), permissionSvc, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// First add the datasource
|
// First add the datasource
|
||||||
@@ -1124,7 +1127,9 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) {
|
|||||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||||
quotaService := quotatest.New(false, nil)
|
quotaService := quotatest.New(false, nil)
|
||||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
features := featuremgmt.WithFeatures()
|
||||||
|
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
||||||
|
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
rt1, err := dsService.GetHTTPTransport(context.Background(), &ds, provider)
|
rt1, err := dsService.GetHTTPTransport(context.Background(), &ds, provider)
|
||||||
@@ -1161,7 +1166,9 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) {
|
|||||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||||
quotaService := quotatest.New(false, nil)
|
quotaService := quotatest.New(false, nil)
|
||||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
features := featuremgmt.WithFeatures()
|
||||||
|
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
||||||
|
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
ds := datasources.DataSource{
|
ds := datasources.DataSource{
|
||||||
@@ -1212,7 +1219,9 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) {
|
|||||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||||
quotaService := quotatest.New(false, nil)
|
quotaService := quotatest.New(false, nil)
|
||||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
features := featuremgmt.WithFeatures()
|
||||||
|
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
||||||
|
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
ds := datasources.DataSource{
|
ds := datasources.DataSource{
|
||||||
@@ -1260,7 +1269,9 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) {
|
|||||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||||
quotaService := quotatest.New(false, nil)
|
quotaService := quotatest.New(false, nil)
|
||||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
features := featuremgmt.WithFeatures()
|
||||||
|
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
||||||
|
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
ds := datasources.DataSource{
|
ds := datasources.DataSource{
|
||||||
@@ -1316,7 +1327,9 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) {
|
|||||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||||
quotaService := quotatest.New(false, nil)
|
quotaService := quotatest.New(false, nil)
|
||||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
features := featuremgmt.WithFeatures()
|
||||||
|
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
||||||
|
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
ds := datasources.DataSource{
|
ds := datasources.DataSource{
|
||||||
@@ -1351,7 +1364,9 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) {
|
|||||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||||
quotaService := quotatest.New(false, nil)
|
quotaService := quotatest.New(false, nil)
|
||||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
features := featuremgmt.WithFeatures()
|
||||||
|
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
||||||
|
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
ds := datasources.DataSource{
|
ds := datasources.DataSource{
|
||||||
@@ -1420,7 +1435,9 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) {
|
|||||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||||
quotaService := quotatest.New(false, nil)
|
quotaService := quotatest.New(false, nil)
|
||||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
features := featuremgmt.WithFeatures()
|
||||||
|
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
||||||
|
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
ds := datasources.DataSource{
|
ds := datasources.DataSource{
|
||||||
@@ -1499,7 +1516,9 @@ func TestIntegrationService_GetHttpTransport(t *testing.T) {
|
|||||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||||
quotaService := quotatest.New(false, nil)
|
quotaService := quotatest.New(false, nil)
|
||||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
features := featuremgmt.WithFeatures()
|
||||||
|
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
||||||
|
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
ds := datasources.DataSource{
|
ds := datasources.DataSource{
|
||||||
@@ -1522,7 +1541,9 @@ func TestIntegrationService_getProxySettings(t *testing.T) {
|
|||||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||||
quotaService := quotatest.New(false, nil)
|
quotaService := quotatest.New(false, nil)
|
||||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, &setting.Cfg{}, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
features := featuremgmt.WithFeatures()
|
||||||
|
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
||||||
|
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, &setting.Cfg{}, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
t.Run("Should default to disabled", func(t *testing.T) {
|
t.Run("Should default to disabled", func(t *testing.T) {
|
||||||
@@ -1620,7 +1641,9 @@ func TestIntegrationService_getTimeout(t *testing.T) {
|
|||||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||||
quotaService := quotatest.New(false, nil)
|
quotaService := quotatest.New(false, nil)
|
||||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
features := featuremgmt.WithFeatures()
|
||||||
|
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
||||||
|
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
@@ -1645,7 +1668,9 @@ func TestIntegrationService_GetDecryptedValues(t *testing.T) {
|
|||||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||||
quotaService := quotatest.New(false, nil)
|
quotaService := quotatest.New(false, nil)
|
||||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
features := featuremgmt.WithFeatures()
|
||||||
|
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
||||||
|
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
jsonData := map[string]string{
|
jsonData := map[string]string{
|
||||||
@@ -1673,7 +1698,9 @@ func TestIntegrationService_GetDecryptedValues(t *testing.T) {
|
|||||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||||
quotaService := quotatest.New(false, nil)
|
quotaService := quotatest.New(false, nil)
|
||||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
features := featuremgmt.WithFeatures()
|
||||||
|
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
||||||
|
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
jsonData := map[string]string{
|
jsonData := map[string]string{
|
||||||
@@ -1699,7 +1726,9 @@ func TestIntegrationDataSource_CustomHeaders(t *testing.T) {
|
|||||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||||
quotaService := quotatest.New(false, nil)
|
quotaService := quotatest.New(false, nil)
|
||||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
|
features := featuremgmt.WithFeatures()
|
||||||
|
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
||||||
|
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, nil, features, acmock.New(), acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil, dsRetriever)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
dsService.cfg = setting.NewCfg()
|
dsService.cfg = setting.NewCfg()
|
||||||
@@ -1788,7 +1817,9 @@ func initDSService(t *testing.T) *Service {
|
|||||||
quotaService := quotatest.New(false, nil)
|
quotaService := quotatest.New(false, nil)
|
||||||
mockPermission := acmock.NewMockedPermissionsService()
|
mockPermission := acmock.NewMockedPermissionsService()
|
||||||
mockPermission.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil)
|
mockPermission.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil)
|
||||||
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), actest.FakeAccessControl{}, mockPermission, quotaService, &pluginstore.FakePluginStore{
|
features := featuremgmt.WithFeatures()
|
||||||
|
dsRetriever := ProvideDataSourceRetriever(sqlStore, features)
|
||||||
|
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, features, actest.FakeAccessControl{}, mockPermission, quotaService, &pluginstore.FakePluginStore{
|
||||||
PluginList: []pluginstore.Plugin{{
|
PluginList: []pluginstore.Plugin{{
|
||||||
JSONData: plugins.JSONData{
|
JSONData: plugins.JSONData{
|
||||||
ID: "test",
|
ID: "test",
|
||||||
@@ -1808,7 +1839,7 @@ func initDSService(t *testing.T) *Service {
|
|||||||
ObjectBytes: req.ObjectBytes,
|
ObjectBytes: req.ObjectBytes,
|
||||||
}, nil
|
}, nil
|
||||||
},
|
},
|
||||||
}, plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()))
|
}, plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()), dsRetriever)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
return dsService
|
return dsService
|
||||||
|
|||||||
34
pkg/services/datasources/service/datasourceretriever.go
Normal file
34
pkg/services/datasources/service/datasourceretriever.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/infra/db"
|
||||||
|
"github.com/grafana/grafana/pkg/infra/log"
|
||||||
|
"github.com/grafana/grafana/pkg/services/datasources"
|
||||||
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DataSourceRetrieverImpl implements DataSourceRetriever by delegating to a Store.
|
||||||
|
type DataSourceRetrieverImpl struct {
|
||||||
|
store Store
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ DataSourceRetriever = (*DataSourceRetrieverImpl)(nil)
|
||||||
|
|
||||||
|
// ProvideDataSourceRetriever creates a DataSourceRetriever for wire injection.
|
||||||
|
func ProvideDataSourceRetriever(db db.DB, features featuremgmt.FeatureToggles) DataSourceRetriever {
|
||||||
|
dslogger := log.New("datasources-retriever")
|
||||||
|
store := &SqlStore{db: db, logger: dslogger, features: features}
|
||||||
|
return &DataSourceRetrieverImpl{store: store}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDataSource gets a datasource.
|
||||||
|
func (r *DataSourceRetrieverImpl) GetDataSource(ctx context.Context, query *datasources.GetDataSourceQuery) (*datasources.DataSource, error) {
|
||||||
|
return r.store.GetDataSource(ctx, query)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDataSourceInNamespace gets a datasource by namespace, name (datasource uid), and group (datasource type).
|
||||||
|
func (r *DataSourceRetrieverImpl) GetDataSourceInNamespace(ctx context.Context, namespace, name, group string) (*datasources.DataSource, error) {
|
||||||
|
return r.store.GetDataSourceInNamespace(ctx, namespace, name, group)
|
||||||
|
}
|
||||||
@@ -907,6 +907,14 @@ var (
|
|||||||
Owner: grafanaAlertingSquad,
|
Owner: grafanaAlertingSquad,
|
||||||
FrontendOnly: false, // changes navtree from backend
|
FrontendOnly: false, // changes navtree from backend
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "alertingNavigationV2",
|
||||||
|
Description: "Enable new grouped navigation structure for Alerting",
|
||||||
|
Stage: FeatureStageExperimental,
|
||||||
|
Owner: grafanaAlertingSquad,
|
||||||
|
FrontendOnly: false, // changes navtree from backend
|
||||||
|
Expression: "false", // Off by default
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "pluginProxyPreserveTrailingSlash",
|
Name: "pluginProxyPreserveTrailingSlash",
|
||||||
Description: "Preserve plugin proxy trailing slash.",
|
Description: "Preserve plugin proxy trailing slash.",
|
||||||
|
|||||||
1
pkg/services/featuremgmt/toggles_gen.csv
generated
1
pkg/services/featuremgmt/toggles_gen.csv
generated
@@ -125,6 +125,7 @@ alertingSavedSearches,experimental,@grafana/alerting-squad,false,false,true
|
|||||||
alertingDisableSendAlertsExternal,experimental,@grafana/alerting-squad,false,false,false
|
alertingDisableSendAlertsExternal,experimental,@grafana/alerting-squad,false,false,false
|
||||||
preserveDashboardStateWhenNavigating,experimental,@grafana/dashboards-squad,false,false,false
|
preserveDashboardStateWhenNavigating,experimental,@grafana/dashboards-squad,false,false,false
|
||||||
alertingCentralAlertHistory,experimental,@grafana/alerting-squad,false,false,false
|
alertingCentralAlertHistory,experimental,@grafana/alerting-squad,false,false,false
|
||||||
|
alertingNavigationV2,experimental,@grafana/alerting-squad,false,false,false
|
||||||
pluginProxyPreserveTrailingSlash,GA,@grafana/plugins-platform-backend,false,false,false
|
pluginProxyPreserveTrailingSlash,GA,@grafana/plugins-platform-backend,false,false,false
|
||||||
azureMonitorPrometheusExemplars,GA,@grafana/partner-datasources,false,false,false
|
azureMonitorPrometheusExemplars,GA,@grafana/partner-datasources,false,false,false
|
||||||
authZGRPCServer,experimental,@grafana/identity-access-team,false,false,false
|
authZGRPCServer,experimental,@grafana/identity-access-team,false,false,false
|
||||||
|
|||||||
|
4
pkg/services/featuremgmt/toggles_gen.go
generated
4
pkg/services/featuremgmt/toggles_gen.go
generated
@@ -379,6 +379,10 @@ const (
|
|||||||
// Enables the new central alert history.
|
// Enables the new central alert history.
|
||||||
FlagAlertingCentralAlertHistory = "alertingCentralAlertHistory"
|
FlagAlertingCentralAlertHistory = "alertingCentralAlertHistory"
|
||||||
|
|
||||||
|
// FlagAlertingNavigationV2
|
||||||
|
// Enable new grouped navigation structure for Alerting
|
||||||
|
FlagAlertingNavigationV2 = "alertingNavigationV2"
|
||||||
|
|
||||||
// FlagPluginProxyPreserveTrailingSlash
|
// FlagPluginProxyPreserveTrailingSlash
|
||||||
// Preserve plugin proxy trailing slash.
|
// Preserve plugin proxy trailing slash.
|
||||||
FlagPluginProxyPreserveTrailingSlash = "pluginProxyPreserveTrailingSlash"
|
FlagPluginProxyPreserveTrailingSlash = "pluginProxyPreserveTrailingSlash"
|
||||||
|
|||||||
13
pkg/services/featuremgmt/toggles_gen.json
generated
13
pkg/services/featuremgmt/toggles_gen.json
generated
@@ -348,6 +348,19 @@
|
|||||||
"expression": "true"
|
"expression": "true"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"metadata": {
|
||||||
|
"name": "alertingNavigationV2",
|
||||||
|
"resourceVersion": "1767827323622",
|
||||||
|
"creationTimestamp": "2026-01-07T23:08:43Z"
|
||||||
|
},
|
||||||
|
"spec": {
|
||||||
|
"description": "Enable new grouped navigation structure for Alerting",
|
||||||
|
"stage": "experimental",
|
||||||
|
"codeowner": "@grafana/alerting-squad",
|
||||||
|
"expression": "false"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"name": "alertingNotificationHistory",
|
"name": "alertingNotificationHistory",
|
||||||
|
|||||||
@@ -433,6 +433,214 @@ func (s *ServiceImpl) buildDashboardNavLinks(c *contextmodel.ReqContext) []*navt
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *ServiceImpl) buildAlertNavLinks(c *contextmodel.ReqContext) *navtree.NavLink {
|
func (s *ServiceImpl) buildAlertNavLinks(c *contextmodel.ReqContext) *navtree.NavLink {
|
||||||
|
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||||
|
if !s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingNavigationV2) {
|
||||||
|
return s.buildAlertNavLinksLegacy(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// V2 Navigation - New grouped structure
|
||||||
|
hasAccess := ac.HasAccess(s.accessControl, c)
|
||||||
|
var alertChildNavs []*navtree.NavLink
|
||||||
|
|
||||||
|
// 1. Alert activity (parent with tabs: Alerts, Active notifications)
|
||||||
|
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||||
|
var alertActivityChildren []*navtree.NavLink
|
||||||
|
if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingTriage) {
|
||||||
|
// Alerts tab
|
||||||
|
if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleRead), ac.EvalPermission(ac.ActionAlertingRuleExternalRead))) {
|
||||||
|
alertActivityChildren = append(alertActivityChildren, &navtree.NavLink{
|
||||||
|
Text: "Alerts", SubTitle: "Visualize active and pending alerts", Id: "alert-activity-alerts", Url: s.cfg.AppSubURL + "/alerting/alerts", Icon: "bell",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Active notifications tab
|
||||||
|
if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingInstanceRead), ac.EvalPermission(ac.ActionAlertingInstancesExternalRead))) {
|
||||||
|
alertActivityChildren = append(alertActivityChildren, &navtree.NavLink{
|
||||||
|
Text: "Active notifications", SubTitle: "See grouped alerts with active notifications", Id: "alert-activity-groups", Url: s.cfg.AppSubURL + "/alerting/groups", Icon: "layer-group",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(alertActivityChildren) > 0 {
|
||||||
|
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
|
||||||
|
Text: "Alert activity",
|
||||||
|
SubTitle: "Visualize active and pending alerts",
|
||||||
|
Id: "alert-activity",
|
||||||
|
Url: s.cfg.AppSubURL + "/alerting/alerts",
|
||||||
|
Icon: "bell",
|
||||||
|
IsNew: true,
|
||||||
|
Children: alertActivityChildren,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Alert rules (parent with tabs: Alert rules, Recently deleted)
|
||||||
|
var alertRulesChildren []*navtree.NavLink
|
||||||
|
if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleRead), ac.EvalPermission(ac.ActionAlertingRuleExternalRead))) {
|
||||||
|
alertRulesChildren = append(alertRulesChildren, &navtree.NavLink{
|
||||||
|
Text: "Alert rules", SubTitle: "Rules that determine whether an alert will fire", Id: "alert-rules-list", Url: s.cfg.AppSubURL + "/alerting/list", Icon: "list-ul",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
//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) {
|
||||||
|
alertRulesChildren = append(alertRulesChildren, &navtree.NavLink{
|
||||||
|
Text: "Recently deleted",
|
||||||
|
SubTitle: "Any items listed here for more than 30 days will be automatically deleted.",
|
||||||
|
Id: "alert-rules-recently-deleted",
|
||||||
|
Url: s.cfg.AppSubURL + "/alerting/recently-deleted",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(alertRulesChildren) > 0 {
|
||||||
|
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
|
||||||
|
Text: "Alert rules",
|
||||||
|
SubTitle: "Manage alert and recording rules",
|
||||||
|
Id: "alert-rules",
|
||||||
|
Url: s.cfg.AppSubURL + "/alerting/list",
|
||||||
|
Icon: "list-ul",
|
||||||
|
Children: alertRulesChildren,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Notification configuration (parent with tabs: Contact points, Notification policies, Templates, Time intervals)
|
||||||
|
var notificationConfigChildren []*navtree.NavLink
|
||||||
|
|
||||||
|
contactPointsPerms := []ac.Evaluator{
|
||||||
|
ac.EvalPermission(ac.ActionAlertingNotificationsRead),
|
||||||
|
ac.EvalPermission(ac.ActionAlertingNotificationsExternalRead),
|
||||||
|
ac.EvalPermission(ac.ActionAlertingReceiversRead),
|
||||||
|
ac.EvalPermission(ac.ActionAlertingReceiversReadSecrets),
|
||||||
|
ac.EvalPermission(ac.ActionAlertingReceiversCreate),
|
||||||
|
ac.EvalPermission(ac.ActionAlertingNotificationsTemplatesRead),
|
||||||
|
ac.EvalPermission(ac.ActionAlertingNotificationsTemplatesWrite),
|
||||||
|
ac.EvalPermission(ac.ActionAlertingNotificationsTemplatesDelete),
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasAccess(ac.EvalAny(contactPointsPerms...)) {
|
||||||
|
notificationConfigChildren = append(notificationConfigChildren, &navtree.NavLink{
|
||||||
|
Text: "Contact points", SubTitle: "Choose how to notify your contact points when an alert instance fires", Id: "notification-config-contact-points", Url: s.cfg.AppSubURL + "/alerting/notifications", Icon: "comment-alt-share",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasAccess(ac.EvalAny(
|
||||||
|
ac.EvalPermission(ac.ActionAlertingNotificationsRead),
|
||||||
|
ac.EvalPermission(ac.ActionAlertingNotificationsExternalRead),
|
||||||
|
ac.EvalPermission(ac.ActionAlertingRoutesRead),
|
||||||
|
ac.EvalPermission(ac.ActionAlertingRoutesWrite),
|
||||||
|
ac.EvalPermission(ac.ActionAlertingNotificationsTimeIntervalsRead),
|
||||||
|
ac.EvalPermission(ac.ActionAlertingNotificationsTimeIntervalsWrite),
|
||||||
|
)) {
|
||||||
|
notificationConfigChildren = append(notificationConfigChildren, &navtree.NavLink{
|
||||||
|
Text: "Notification policies", SubTitle: "Determine how alerts are routed to contact points", Id: "notification-config-policies", Url: s.cfg.AppSubURL + "/alerting/routes", Icon: "sitemap",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Templates
|
||||||
|
if hasAccess(ac.EvalAny(contactPointsPerms...)) {
|
||||||
|
notificationConfigChildren = append(notificationConfigChildren, &navtree.NavLink{
|
||||||
|
Text: "Notification templates", SubTitle: "Manage notification templates", Id: "notification-config-templates", Url: s.cfg.AppSubURL + "/alerting/notifications/templates", Icon: "file-alt",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time intervals
|
||||||
|
if hasAccess(ac.EvalAny(
|
||||||
|
ac.EvalPermission(ac.ActionAlertingNotificationsRead),
|
||||||
|
ac.EvalPermission(ac.ActionAlertingNotificationsExternalRead),
|
||||||
|
ac.EvalPermission(ac.ActionAlertingRoutesRead),
|
||||||
|
ac.EvalPermission(ac.ActionAlertingRoutesWrite),
|
||||||
|
ac.EvalPermission(ac.ActionAlertingNotificationsTimeIntervalsRead),
|
||||||
|
ac.EvalPermission(ac.ActionAlertingNotificationsTimeIntervalsWrite),
|
||||||
|
)) {
|
||||||
|
notificationConfigChildren = append(notificationConfigChildren, &navtree.NavLink{
|
||||||
|
Text: "Time intervals", SubTitle: "Configure time intervals for notification policies", Id: "notification-config-time-intervals", Url: s.cfg.AppSubURL + "/alerting/routes?tab=time_intervals", Icon: "clock-nine",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(notificationConfigChildren) > 0 {
|
||||||
|
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
|
||||||
|
Text: "Notification configuration",
|
||||||
|
SubTitle: "Configure how alerts are notified",
|
||||||
|
Id: "notification-config",
|
||||||
|
Url: s.cfg.AppSubURL + "/alerting/notifications",
|
||||||
|
Icon: "cog",
|
||||||
|
Children: notificationConfigChildren,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Insights (parent with tabs: System Insights, Alert state history)
|
||||||
|
var insightsChildren []*navtree.NavLink
|
||||||
|
|
||||||
|
// System Insights
|
||||||
|
if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleRead), ac.EvalPermission(ac.ActionAlertingRuleExternalRead))) {
|
||||||
|
insightsChildren = append(insightsChildren, &navtree.NavLink{
|
||||||
|
Text: "System Insights", SubTitle: "View system insights and analytics", Id: "insights-system", Url: s.cfg.AppSubURL + "/alerting/insights", Icon: "chart-line",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alert state history
|
||||||
|
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||||
|
if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingCentralAlertHistory) {
|
||||||
|
if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleRead))) {
|
||||||
|
insightsChildren = append(insightsChildren, &navtree.NavLink{
|
||||||
|
Text: "Alert state history",
|
||||||
|
SubTitle: "View a history of all alert events generated by your Grafana-managed alert rules. All alert events are displayed regardless of whether silences or mute timings are set.",
|
||||||
|
Id: "insights-history",
|
||||||
|
Url: s.cfg.AppSubURL + "/alerting/history",
|
||||||
|
Icon: "history",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(insightsChildren) > 0 {
|
||||||
|
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
|
||||||
|
Text: "Insights",
|
||||||
|
SubTitle: "Analytics and history for alerting",
|
||||||
|
Id: "insights",
|
||||||
|
Url: s.cfg.AppSubURL + "/alerting/insights",
|
||||||
|
Icon: "chart-line",
|
||||||
|
Children: insightsChildren,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Settings (parent with tab: Settings)
|
||||||
|
if c.GetOrgRole() == org.RoleAdmin {
|
||||||
|
settingsChildren := []*navtree.NavLink{
|
||||||
|
{
|
||||||
|
Text: "Settings", Id: "alerting-admin", Url: s.cfg.AppSubURL + "/alerting/admin", Icon: "cog",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
|
||||||
|
Text: "Settings",
|
||||||
|
SubTitle: "Alerting configuration and administration",
|
||||||
|
Id: "alerting-settings",
|
||||||
|
Url: s.cfg.AppSubURL + "/alerting/admin",
|
||||||
|
Icon: "cog",
|
||||||
|
Children: settingsChildren,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create alert rule (hidden from tabs)
|
||||||
|
if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleCreate), ac.EvalPermission(ac.ActionAlertingRuleExternalWrite))) {
|
||||||
|
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
|
||||||
|
Text: "Create alert rule", SubTitle: "Create an alert rule", Id: "alert",
|
||||||
|
Icon: "plus", Url: s.cfg.AppSubURL + "/alerting/new", HideFromTabs: true, IsCreateAction: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(alertChildNavs) > 0 {
|
||||||
|
var alertNav = navtree.NavLink{
|
||||||
|
Text: "Alerting",
|
||||||
|
SubTitle: "Learn about problems in your systems moments after they occur",
|
||||||
|
Id: navtree.NavIDAlerting,
|
||||||
|
Icon: "bell",
|
||||||
|
Children: alertChildNavs,
|
||||||
|
SortWeight: navtree.WeightAlerting,
|
||||||
|
Url: s.cfg.AppSubURL + "/alerting",
|
||||||
|
}
|
||||||
|
|
||||||
|
return &alertNav
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ServiceImpl) buildAlertNavLinksLegacy(c *contextmodel.ReqContext) *navtree.NavLink {
|
||||||
hasAccess := ac.HasAccess(s.accessControl, c)
|
hasAccess := ac.HasAccess(s.accessControl, c)
|
||||||
var alertChildNavs []*navtree.NavLink
|
var alertChildNavs []*navtree.NavLink
|
||||||
|
|
||||||
@@ -440,7 +648,7 @@ func (s *ServiceImpl) buildAlertNavLinks(c *contextmodel.ReqContext) *navtree.Na
|
|||||||
if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingTriage) {
|
if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingTriage) {
|
||||||
if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleRead), ac.EvalPermission(ac.ActionAlertingRuleExternalRead))) {
|
if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleRead), ac.EvalPermission(ac.ActionAlertingRuleExternalRead))) {
|
||||||
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
|
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
|
||||||
Text: "Alerts", SubTitle: "Visualize active and pending alerts", Id: "alert-alerts", Url: s.cfg.AppSubURL + "/alerting/alerts", Icon: "bell", IsNew: true,
|
Text: "Alert activity", SubTitle: "Visualize active and pending alerts", Id: "alert-alerts", Url: s.cfg.AppSubURL + "/alerting/alerts", Icon: "bell", IsNew: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
234
pkg/services/navtree/navtreeimpl/navtree_alerting_test.go
Normal file
234
pkg/services/navtree/navtreeimpl/navtree_alerting_test.go
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
package navtreeimpl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/infra/log"
|
||||||
|
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
|
accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
|
||||||
|
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||||
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||||
|
"github.com/grafana/grafana/pkg/services/navtree"
|
||||||
|
"github.com/grafana/grafana/pkg/services/org"
|
||||||
|
"github.com/grafana/grafana/pkg/services/user"
|
||||||
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
|
"github.com/grafana/grafana/pkg/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Test fixtures
|
||||||
|
func setupTestContext() *contextmodel.ReqContext {
|
||||||
|
httpReq, _ := http.NewRequest(http.MethodGet, "", nil)
|
||||||
|
return &contextmodel.ReqContext{
|
||||||
|
SignedInUser: &user.SignedInUser{
|
||||||
|
UserID: 1,
|
||||||
|
OrgID: 1,
|
||||||
|
OrgRole: org.RoleAdmin,
|
||||||
|
},
|
||||||
|
Context: &web.Context{Req: httpReq},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupTestService(permissions []ac.Permission, featureFlags ...string) ServiceImpl {
|
||||||
|
// Convert string slice to []any for WithFeatures
|
||||||
|
flags := make([]any, len(featureFlags))
|
||||||
|
for i, flag := range featureFlags {
|
||||||
|
flags[i] = flag
|
||||||
|
}
|
||||||
|
return ServiceImpl{
|
||||||
|
log: log.New("navtree"),
|
||||||
|
cfg: setting.NewCfg(),
|
||||||
|
accessControl: accesscontrolmock.New().WithPermissions(permissions),
|
||||||
|
features: featuremgmt.WithFeatures(flags...),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fullPermissions() []ac.Permission {
|
||||||
|
return []ac.Permission{
|
||||||
|
{Action: ac.ActionAlertingRuleRead, Scope: "*"},
|
||||||
|
{Action: ac.ActionAlertingNotificationsRead, Scope: "*"},
|
||||||
|
{Action: ac.ActionAlertingRoutesRead, Scope: "*"},
|
||||||
|
{Action: ac.ActionAlertingInstanceRead, Scope: "*"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to find a nav link by ID
|
||||||
|
func findNavLink(navLink *navtree.NavLink, id string) *navtree.NavLink {
|
||||||
|
if navLink == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if navLink.Id == id {
|
||||||
|
return navLink
|
||||||
|
}
|
||||||
|
for _, child := range navLink.Children {
|
||||||
|
if found := findNavLink(child, id); found != nil {
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to check if a nav link has a child with given ID
|
||||||
|
func hasChildWithId(parent *navtree.NavLink, childId string) bool {
|
||||||
|
if parent == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, child := range parent.Children {
|
||||||
|
if child.Id == childId {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAlertNavLinks_FeatureToggle(t *testing.T) {
|
||||||
|
reqCtx := setupTestContext()
|
||||||
|
permissions := fullPermissions()
|
||||||
|
|
||||||
|
t.Run("Should use legacy navigation when flag is off", func(t *testing.T) {
|
||||||
|
service := setupTestService(permissions) // No feature flags
|
||||||
|
|
||||||
|
navLink := service.buildAlertNavLinks(reqCtx)
|
||||||
|
require.NotNil(t, navLink)
|
||||||
|
require.Equal(t, "Alerting", navLink.Text)
|
||||||
|
require.Equal(t, navtree.NavIDAlerting, navLink.Id)
|
||||||
|
|
||||||
|
// Legacy structure: flat children without nested items
|
||||||
|
require.NotEmpty(t, navLink.Children)
|
||||||
|
alertList := findNavLink(navLink, "alert-list")
|
||||||
|
receivers := findNavLink(navLink, "receivers")
|
||||||
|
|
||||||
|
require.NotNil(t, alertList, "Should have alert-list in legacy navigation")
|
||||||
|
require.NotNil(t, receivers, "Should have receivers in legacy navigation")
|
||||||
|
require.Empty(t, alertList.Children, "Legacy items should not have nested children")
|
||||||
|
require.Empty(t, receivers.Children, "Legacy items should not have nested children")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Should use V2 navigation when flag is on", func(t *testing.T) {
|
||||||
|
service := setupTestService(permissions, "alertingNavigationV2")
|
||||||
|
|
||||||
|
navLink := service.buildAlertNavLinks(reqCtx)
|
||||||
|
require.NotNil(t, navLink)
|
||||||
|
require.Equal(t, "Alerting", navLink.Text)
|
||||||
|
require.Equal(t, navtree.NavIDAlerting, navLink.Id)
|
||||||
|
|
||||||
|
// V2 structure: grouped parents with nested children
|
||||||
|
require.NotEmpty(t, navLink.Children)
|
||||||
|
|
||||||
|
// Verify all expected parent items exist with children
|
||||||
|
expectedParents := []string{"alert-rules", "notification-config", "insights", "alerting-settings"}
|
||||||
|
for _, parentId := range expectedParents {
|
||||||
|
parent := findNavLink(navLink, parentId)
|
||||||
|
require.NotNil(t, parent, "Should have %s parent in V2 navigation", parentId)
|
||||||
|
require.NotEmpty(t, parent.Children, "V2 parent %s should have children", parentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify alert-rules has expected tab
|
||||||
|
alertRules := findNavLink(navLink, "alert-rules")
|
||||||
|
require.True(t, hasChildWithId(alertRules, "alert-rules-list"), "Should have alert-rules-list tab")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAlertNavLinks_Legacy(t *testing.T) {
|
||||||
|
reqCtx := setupTestContext()
|
||||||
|
|
||||||
|
t.Run("Should include all expected items in legacy navigation", func(t *testing.T) {
|
||||||
|
service := setupTestService(fullPermissions())
|
||||||
|
navLink := service.buildAlertNavLinksLegacy(reqCtx)
|
||||||
|
require.NotNil(t, navLink)
|
||||||
|
|
||||||
|
expectedIds := []string{"alert-list", "receivers", "am-routes", "alerting-admin"}
|
||||||
|
for _, expectedId := range expectedIds {
|
||||||
|
require.NotNil(t, findNavLink(navLink, expectedId), "Should have %s in legacy navigation", expectedId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Should respect permissions in legacy navigation", func(t *testing.T) {
|
||||||
|
limitedPermissions := []ac.Permission{
|
||||||
|
{Action: ac.ActionAlertingRuleRead, Scope: "*"},
|
||||||
|
}
|
||||||
|
limitedService := setupTestService(limitedPermissions)
|
||||||
|
|
||||||
|
navLink := limitedService.buildAlertNavLinksLegacy(reqCtx)
|
||||||
|
require.NotNil(t, navLink)
|
||||||
|
|
||||||
|
require.NotNil(t, findNavLink(navLink, "alert-list"), "Should have alert rules with read permission")
|
||||||
|
require.Nil(t, findNavLink(navLink, "receivers"), "Should not have contact points without notification permissions")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAlertNavLinks_V2(t *testing.T) {
|
||||||
|
reqCtx := setupTestContext()
|
||||||
|
allFeatureFlags := []string{"alertingNavigationV2", "alertingTriage", "alertingCentralAlertHistory", "alertRuleRestore", "alertingRuleRecoverDeleted"}
|
||||||
|
service := setupTestService(fullPermissions(), allFeatureFlags...)
|
||||||
|
|
||||||
|
t.Run("Should have correct parent structure in V2 navigation", func(t *testing.T) {
|
||||||
|
navLink := service.buildAlertNavLinks(reqCtx)
|
||||||
|
require.NotNil(t, navLink)
|
||||||
|
require.NotEmpty(t, navLink.Children)
|
||||||
|
|
||||||
|
// Verify all parent items exist with children
|
||||||
|
parentIds := []string{"alert-rules", "notification-config", "insights", "alerting-settings"}
|
||||||
|
for _, parentId := range parentIds {
|
||||||
|
parent := findNavLink(navLink, parentId)
|
||||||
|
require.NotNil(t, parent, "Should have parent %s in V2 navigation", parentId)
|
||||||
|
require.NotEmpty(t, parent.Children, "Parent %s should have children", parentId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Should have correct tabs under each parent", func(t *testing.T) {
|
||||||
|
navLink := service.buildAlertNavLinks(reqCtx)
|
||||||
|
require.NotNil(t, navLink)
|
||||||
|
|
||||||
|
// Table-driven test for tab verification
|
||||||
|
tests := []struct {
|
||||||
|
parentId string
|
||||||
|
expectedTabs []string
|
||||||
|
}{
|
||||||
|
{"alert-rules", []string{"alert-rules-list", "alert-rules-recently-deleted"}},
|
||||||
|
{"notification-config", []string{"notification-config-contact-points", "notification-config-policies", "notification-config-templates", "notification-config-time-intervals"}},
|
||||||
|
{"insights", []string{"insights-system", "insights-history"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
parent := findNavLink(navLink, tt.parentId)
|
||||||
|
require.NotNil(t, parent, "Should have %s parent", tt.parentId)
|
||||||
|
|
||||||
|
for _, expectedTab := range tt.expectedTabs {
|
||||||
|
require.True(t, hasChildWithId(parent, expectedTab), "Parent %s should have tab %s", tt.parentId, expectedTab)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Should respect permissions in V2 navigation", func(t *testing.T) {
|
||||||
|
limitedPermissions := []ac.Permission{
|
||||||
|
{Action: ac.ActionAlertingRuleRead, Scope: "*"},
|
||||||
|
}
|
||||||
|
limitedService := setupTestService(limitedPermissions, "alertingNavigationV2")
|
||||||
|
|
||||||
|
navLink := limitedService.buildAlertNavLinks(reqCtx)
|
||||||
|
require.NotNil(t, navLink)
|
||||||
|
|
||||||
|
// Should not have notification-config without notification permissions
|
||||||
|
require.Nil(t, findNavLink(navLink, "notification-config"), "Should not have notification-config without permissions")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Should exclude future items from V2 navigation", func(t *testing.T) {
|
||||||
|
navLink := service.buildAlertNavLinks(reqCtx)
|
||||||
|
require.NotNil(t, navLink)
|
||||||
|
|
||||||
|
// Verify future items are not present
|
||||||
|
futureIds := []string{
|
||||||
|
"alert-rules-recording-rules",
|
||||||
|
"alert-rules-evaluation-chains",
|
||||||
|
"insights-alert-optimizer",
|
||||||
|
"insights-notification-history",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, futureId := range futureIds {
|
||||||
|
require.Nil(t, findNavLink(navLink, futureId), "Should not have future item %s", futureId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -542,9 +542,10 @@ func setupEnv(t *testing.T, sqlStore db.DB, cfg *setting.Cfg, b bus.Bus, quotaSe
|
|||||||
dashService.RegisterDashboardPermissions(acmock.NewMockedPermissionsService())
|
dashService.RegisterDashboardPermissions(acmock.NewMockedPermissionsService())
|
||||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||||
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
|
||||||
|
dsRetriever := dsservice.ProvideDataSourceRetriever(sqlStore, featuremgmt.WithFeatures())
|
||||||
_, err = dsservice.ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(),
|
_, err = dsservice.ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), acmock.NewMockedPermissionsService(),
|
||||||
quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, plugincontext.
|
quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, plugincontext.
|
||||||
ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()))
|
ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()), dsRetriever)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
m := metrics.NewNGAlert(prometheus.NewRegistry())
|
m := metrics.NewNGAlert(prometheus.NewRegistry())
|
||||||
|
|
||||||
|
|||||||
@@ -37,9 +37,10 @@ func SetupTestDataSourceSecretMigrationService(t *testing.T, sqlStore db.DB, kvS
|
|||||||
features := featuremgmt.WithFeatures()
|
features := featuremgmt.WithFeatures()
|
||||||
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||||
quotaService := quotatest.New(false, nil)
|
quotaService := quotatest.New(false, nil)
|
||||||
|
dsRetriever := dsservice.ProvideDataSourceRetriever(sqlStore, features)
|
||||||
dsService, err := dsservice.ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(),
|
dsService, err := dsservice.ProvideService(sqlStore, secretsService, secretsStore, cfg, features, acmock.New(),
|
||||||
acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{},
|
acmock.NewMockedPermissionsService(), quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{},
|
||||||
plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()))
|
plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()), dsRetriever)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
migService := ProvideDataSourceMigrationService(dsService, kvStore, features)
|
migService := ProvideDataSourceMigrationService(dsService, kvStore, features)
|
||||||
return migService
|
return migService
|
||||||
|
|||||||
@@ -293,15 +293,15 @@ overrides_path = overrides.yaml
|
|||||||
overrides_reload_period = 5s
|
overrides_reload_period = 5s
|
||||||
```
|
```
|
||||||
|
|
||||||
To overrides the default quota for a tenant, add the following to the overrides.yaml file:
|
To override the default quota for a tenant, add the following to the `overrides.yaml` file:
|
||||||
```yaml
|
```yaml
|
||||||
overrides:
|
overrides:
|
||||||
<NAMESPACE>:
|
<NAMESPACE>:
|
||||||
quotas:
|
quotas:
|
||||||
<GROUP>.<RESOURCE>:
|
<GROUP>/<RESOURCE>:
|
||||||
limit: 10
|
limit: 10
|
||||||
```
|
```
|
||||||
Unless otherwise set, the NAMESPACE when running locally is `default`.
|
Unless otherwise set, the `NAMESPACE` when running locally is `default`.
|
||||||
|
|
||||||
To access quotas, use the following API endpoint:
|
To access quotas, use the following API endpoint:
|
||||||
```
|
```
|
||||||
@@ -806,8 +806,10 @@ flowchart TD
|
|||||||
|
|
||||||
#### Setting Dual Writer Mode
|
#### Setting Dual Writer Mode
|
||||||
```ini
|
```ini
|
||||||
[unified_storage.{resource}.{kind}.{group}]
|
; [unified_storage.{resource}.{group}]
|
||||||
dualWriterMode = {0-5}
|
[unified_storage.dashboards.dashboard.grafana.app]
|
||||||
|
; modes {0-5}
|
||||||
|
dualWriterMode = 0
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Background Sync Configuration
|
#### Background Sync Configuration
|
||||||
@@ -1376,4 +1378,3 @@ disable_data_migrations = false
|
|||||||
### Documentation
|
### Documentation
|
||||||
|
|
||||||
For detailed information about migration architecture, validators, and troubleshooting, refer to [migrations/README.md](./migrations/README.md).
|
For detailed information about migration architecture, validators, and troubleshooting, refer to [migrations/README.md](./migrations/README.md).
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ INSERT INTO {{ .Ident "resource" }}
|
|||||||
{{ .Ident "previous_resource_version" }}
|
{{ .Ident "previous_resource_version" }}
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
COALESCE({{ .Arg .Value }}, ""),
|
(SELECT {{ .Ident "value" }} FROM {{ .Ident "resource_history" }} WHERE {{ .Ident "guid" }} = {{ .Arg .GUID }}),
|
||||||
{{ .Arg .GUID }},
|
{{ .Arg .GUID }},
|
||||||
{{ .Arg .Group }},
|
{{ .Arg .Group }},
|
||||||
{{ .Arg .Resource }},
|
{{ .Arg .Resource }},
|
||||||
@@ -19,13 +19,5 @@ VALUES (
|
|||||||
{{ .Arg .Name }},
|
{{ .Arg .Name }},
|
||||||
{{ .Arg .Action }},
|
{{ .Arg .Action }},
|
||||||
{{ .Arg .Folder }},
|
{{ .Arg .Folder }},
|
||||||
CASE WHEN {{ .Arg .Action }} = 1 THEN 0 ELSE (
|
{{ .Arg .PreviousRV }}
|
||||||
SELECT {{ .Ident "resource_version" }}
|
|
||||||
FROM {{ .Ident "resource" }}
|
|
||||||
WHERE {{ .Ident "group" }} = {{ .Arg .Group }}
|
|
||||||
AND {{ .Ident "resource" }} = {{ .Arg .Resource }}
|
|
||||||
AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }}
|
|
||||||
AND {{ .Ident "name" }} = {{ .Arg .Name }}
|
|
||||||
ORDER BY {{ .Ident "resource_version" }} DESC LIMIT 1
|
|
||||||
) END
|
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,9 +7,7 @@ INSERT INTO {{ .Ident "resource_history" }}
|
|||||||
{{ .Ident "namespace" }},
|
{{ .Ident "namespace" }},
|
||||||
{{ .Ident "name" }},
|
{{ .Ident "name" }},
|
||||||
{{ .Ident "action" }},
|
{{ .Ident "action" }},
|
||||||
{{ .Ident "folder" }},
|
{{ .Ident "folder" }}
|
||||||
{{ .Ident "previous_resource_version" }},
|
|
||||||
{{ .Ident "generation" }}
|
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
COALESCE({{ .Arg .Value }}, ""),
|
COALESCE({{ .Arg .Value }}, ""),
|
||||||
@@ -19,26 +17,5 @@ VALUES (
|
|||||||
{{ .Arg .Namespace }},
|
{{ .Arg .Namespace }},
|
||||||
{{ .Arg .Name }},
|
{{ .Arg .Name }},
|
||||||
{{ .Arg .Action }},
|
{{ .Arg .Action }},
|
||||||
{{ .Arg .Folder }},
|
{{ .Arg .Folder }}
|
||||||
CASE WHEN {{ .Arg .Action }} = 1 THEN 0 ELSE (
|
|
||||||
SELECT {{ .Ident "resource_version" }}
|
|
||||||
FROM {{ .Ident "resource_history" }}
|
|
||||||
WHERE {{ .Ident "group" }} = {{ .Arg .Group }}
|
|
||||||
AND {{ .Ident "resource" }} = {{ .Arg .Resource }}
|
|
||||||
AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }}
|
|
||||||
AND {{ .Ident "name" }} = {{ .Arg .Name }}
|
|
||||||
ORDER BY {{ .Ident "resource_version" }} DESC LIMIT 1
|
|
||||||
) END,
|
|
||||||
CASE
|
|
||||||
WHEN {{ .Arg .Action }} = 1 THEN 1
|
|
||||||
WHEN {{ .Arg .Action }} = 3 THEN 0
|
|
||||||
ELSE 1 + (
|
|
||||||
SELECT COUNT(1)
|
|
||||||
FROM {{ .Ident "resource_history" }}
|
|
||||||
WHERE {{ .Ident "group" }} = {{ .Arg .Group }}
|
|
||||||
AND {{ .Ident "resource" }} = {{ .Arg .Resource }}
|
|
||||||
AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }}
|
|
||||||
AND {{ .Ident "name" }} = {{ .Arg .Name }}
|
|
||||||
)
|
|
||||||
END
|
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
UPDATE {{ .Ident "resource" }}
|
UPDATE {{ .Ident "resource" }}
|
||||||
SET
|
SET
|
||||||
{{ .Ident "value" }} = {{ .Arg .Value }},
|
{{ .Ident "guid" }} = {{ .Arg .GUID }},
|
||||||
|
{{ .Ident "value" }} = (SELECT {{ .Ident "value" }} FROM {{ .Ident "resource_history" }} WHERE {{ .Ident "guid" }} = {{ .Arg .GUID }}),
|
||||||
{{ .Ident "action" }} = {{ .Arg .Action }},
|
{{ .Ident "action" }} = {{ .Arg .Action }},
|
||||||
{{ .Ident "folder" }} = {{ .Arg .Folder }}
|
{{ .Ident "folder" }} = {{ .Arg .Folder }},
|
||||||
|
{{ .Ident "previous_resource_version" }} = {{ .Arg .PreviousRV }}
|
||||||
WHERE {{ .Ident "group" }} = {{ .Arg .Group }}
|
WHERE {{ .Ident "group" }} = {{ .Arg .Group }}
|
||||||
AND {{ .Ident "resource" }} = {{ .Arg .Resource }}
|
AND {{ .Ident "resource" }} = {{ .Arg .Resource }}
|
||||||
AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }}
|
AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
UPDATE {{ .Ident "resource_history" }}
|
||||||
|
SET
|
||||||
|
{{ .Ident "previous_resource_version" }} = {{ .Arg .PreviousRV }},
|
||||||
|
{{ .Ident "generation" }} = {{ .Arg .Generation }}
|
||||||
|
WHERE {{ .Ident "guid" }} = {{ .Arg .GUID }};
|
||||||
@@ -12,6 +12,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/apimachinery/validation"
|
"github.com/grafana/grafana/pkg/apimachinery/validation"
|
||||||
|
"github.com/grafana/grafana/pkg/storage/unified/sql/db"
|
||||||
|
"github.com/grafana/grafana/pkg/storage/unified/sql/dbutil"
|
||||||
|
"github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate"
|
||||||
gocache "github.com/patrickmn/go-cache"
|
gocache "github.com/patrickmn/go-cache"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -306,10 +309,6 @@ func (d *dataStore) GetResourceKeyAtRevision(ctx context.Context, key GetRequest
|
|||||||
return DataKey{}, fmt.Errorf("invalid get request key: %w", err)
|
return DataKey{}, fmt.Errorf("invalid get request key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if rv == 0 {
|
|
||||||
rv = math.MaxInt64
|
|
||||||
}
|
|
||||||
|
|
||||||
listKey := ListRequestKey(key)
|
listKey := ListRequestKey(key)
|
||||||
|
|
||||||
iter := d.ListResourceKeysAtRevision(ctx, ListRequestOptions{Key: listKey, ResourceVersion: rv})
|
iter := d.ListResourceKeysAtRevision(ctx, ListRequestOptions{Key: listKey, ResourceVersion: rv})
|
||||||
@@ -598,7 +597,7 @@ func ParseKey(key string) (DataKey, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Temporary while we need to support unified/sql/backend compatibility
|
// Temporary while we need to support unified/sql/backend compatibility.
|
||||||
// Remove once we stop using RvManager in storage_backend.go
|
// Remove once we stop using RvManager in storage_backend.go
|
||||||
func ParseKeyWithGUID(key string) (DataKey, error) {
|
func ParseKeyWithGUID(key string) (DataKey, error) {
|
||||||
parts := strings.Split(key, "/")
|
parts := strings.Split(key, "/")
|
||||||
@@ -815,3 +814,121 @@ func (d *dataStore) getGroupResources(ctx context.Context) ([]GroupResource, err
|
|||||||
|
|
||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: remove when backwards compatibility is no longer needed.
|
||||||
|
var (
|
||||||
|
sqlKVUpdateLegacyResourceHistory = mustTemplate("sqlkv_update_legacy_resource_history.sql")
|
||||||
|
sqlKVInsertLegacyResource = mustTemplate("sqlkv_insert_legacy_resource.sql")
|
||||||
|
sqlKVUpdateLegacyResource = mustTemplate("sqlkv_update_legacy_resource.sql")
|
||||||
|
)
|
||||||
|
|
||||||
|
// TODO: remove when backwards compatibility is no longer needed.
|
||||||
|
type sqlKVLegacySaveRequest struct {
|
||||||
|
sqltemplate.SQLTemplate
|
||||||
|
GUID string
|
||||||
|
Group string
|
||||||
|
Resource string
|
||||||
|
Namespace string
|
||||||
|
Name string
|
||||||
|
Action int64
|
||||||
|
Folder string
|
||||||
|
PreviousRV int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (req sqlKVLegacySaveRequest) Validate() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: remove when backwards compatibility is no longer needed.
|
||||||
|
type sqlKVLegacyUpdateHistoryRequest struct {
|
||||||
|
sqltemplate.SQLTemplate
|
||||||
|
GUID string
|
||||||
|
PreviousRV int64
|
||||||
|
Generation int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (req sqlKVLegacyUpdateHistoryRequest) Validate() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyBackwardsCompatibleChanges updates the `resource` and `resource_history` tables
|
||||||
|
// to make sure the sqlkv implementation is backwards-compatible with the existing sql backend.
|
||||||
|
// Specifically, it will update the `resource_history` table to include the previous resource version
|
||||||
|
// and generation, which come from the `WriteEvent`, and also make the corresponding change on the
|
||||||
|
// `resource` table, no longer used in the storage backend.
|
||||||
|
//
|
||||||
|
// TODO: remove when backwards compatibility is no longer needed.
|
||||||
|
func (d *dataStore) applyBackwardsCompatibleChanges(ctx context.Context, tx db.Tx, event WriteEvent, key DataKey) error {
|
||||||
|
kv, isSQLKV := d.kv.(*sqlKV)
|
||||||
|
if !isSQLKV {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := dbutil.Exec(ctx, tx, sqlKVUpdateLegacyResourceHistory, sqlKVLegacyUpdateHistoryRequest{
|
||||||
|
SQLTemplate: sqltemplate.New(kv.dialect),
|
||||||
|
GUID: key.GUID,
|
||||||
|
PreviousRV: event.PreviousRV,
|
||||||
|
Generation: event.Object.GetGeneration(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("compatibility layer: failed to insert to resource: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var action int64
|
||||||
|
switch key.Action {
|
||||||
|
case DataActionCreated:
|
||||||
|
action = 1
|
||||||
|
case DataActionUpdated:
|
||||||
|
action = 2
|
||||||
|
case DataActionDeleted:
|
||||||
|
action = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
switch key.Action {
|
||||||
|
case DataActionCreated:
|
||||||
|
_, err := dbutil.Exec(ctx, tx, sqlKVInsertLegacyResource, sqlKVLegacySaveRequest{
|
||||||
|
SQLTemplate: sqltemplate.New(kv.dialect),
|
||||||
|
GUID: key.GUID,
|
||||||
|
Group: key.Group,
|
||||||
|
Resource: key.Resource,
|
||||||
|
Namespace: key.Namespace,
|
||||||
|
Name: key.Name,
|
||||||
|
Action: action,
|
||||||
|
Folder: key.Folder,
|
||||||
|
PreviousRV: event.PreviousRV,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("compatibility layer: failed to insert to resource: %w", err)
|
||||||
|
}
|
||||||
|
case DataActionUpdated:
|
||||||
|
_, err := dbutil.Exec(ctx, tx, sqlKVUpdateLegacyResource, sqlKVLegacySaveRequest{
|
||||||
|
SQLTemplate: sqltemplate.New(kv.dialect),
|
||||||
|
GUID: key.GUID,
|
||||||
|
Group: key.Group,
|
||||||
|
Resource: key.Resource,
|
||||||
|
Namespace: key.Namespace,
|
||||||
|
Name: key.Name,
|
||||||
|
Folder: key.Folder,
|
||||||
|
PreviousRV: event.PreviousRV,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("compatibility layer: failed to update resource: %w", err)
|
||||||
|
}
|
||||||
|
case DataActionDeleted:
|
||||||
|
_, err := dbutil.Exec(ctx, tx, sqlKVDeleteLegacyResource, sqlKVLegacySaveRequest{
|
||||||
|
SQLTemplate: sqltemplate.New(kv.dialect),
|
||||||
|
Resource: key.Resource,
|
||||||
|
Namespace: key.Namespace,
|
||||||
|
Name: key.Name,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("compatibility layer: failed to delete from resource: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,8 +44,6 @@ var (
|
|||||||
sqlKVInsertData = mustTemplate("sqlkv_insert_datastore.sql")
|
sqlKVInsertData = mustTemplate("sqlkv_insert_datastore.sql")
|
||||||
sqlKVUpdateData = mustTemplate("sqlkv_update_datastore.sql")
|
sqlKVUpdateData = mustTemplate("sqlkv_update_datastore.sql")
|
||||||
sqlKVInsertLegacyResourceHistory = mustTemplate("sqlkv_insert_legacy_resource_history.sql")
|
sqlKVInsertLegacyResourceHistory = mustTemplate("sqlkv_insert_legacy_resource_history.sql")
|
||||||
sqlKVInsertLegacyResource = mustTemplate("sqlkv_insert_legacy_resource.sql")
|
|
||||||
sqlKVUpdateLegacyResource = mustTemplate("sqlkv_update_legacy_resource.sql")
|
|
||||||
sqlKVDeleteLegacyResource = mustTemplate("sqlkv_delete_legacy_resource.sql")
|
sqlKVDeleteLegacyResource = mustTemplate("sqlkv_delete_legacy_resource.sql")
|
||||||
sqlKVDelete = mustTemplate("sqlkv_delete.sql")
|
sqlKVDelete = mustTemplate("sqlkv_delete.sql")
|
||||||
sqlKVBatchDelete = mustTemplate("sqlkv_batch_delete.sql")
|
sqlKVBatchDelete = mustTemplate("sqlkv_batch_delete.sql")
|
||||||
@@ -157,26 +155,6 @@ func (req sqlKVSaveRequest) Validate() error {
|
|||||||
return req.sqlKVSectionKey.Validate()
|
return req.sqlKVSectionKey.Validate()
|
||||||
}
|
}
|
||||||
|
|
||||||
type sqlKVLegacySaveRequest struct {
|
|
||||||
sqltemplate.SQLTemplate
|
|
||||||
Value []byte
|
|
||||||
GUID string
|
|
||||||
Group string
|
|
||||||
Resource string
|
|
||||||
Namespace string
|
|
||||||
Name string
|
|
||||||
Action int64
|
|
||||||
Folder string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (req sqlKVLegacySaveRequest) Validate() error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (req sqlKVLegacySaveRequest) Results() ([]byte, error) {
|
|
||||||
return req.Value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type sqlKVKeysRequest struct {
|
type sqlKVKeysRequest struct {
|
||||||
sqltemplate.SQLTemplate
|
sqltemplate.SQLTemplate
|
||||||
sqlKVSection
|
sqlKVSection
|
||||||
@@ -392,7 +370,7 @@ func (w *sqlWriteCloser) Close() error {
|
|||||||
// used to keep backwards compatibility between sql-based kvstore and unified/sql/backend
|
// used to keep backwards compatibility between sql-based kvstore and unified/sql/backend
|
||||||
tx, ok := rvmanager.TxFromCtx(w.ctx)
|
tx, ok := rvmanager.TxFromCtx(w.ctx)
|
||||||
if !ok {
|
if !ok {
|
||||||
// temporary save for dataStore without rvmanager
|
// temporary save for dataStore without rvmanager (non backwards-compatible)
|
||||||
// we can use the same template as the event one after we:
|
// we can use the same template as the event one after we:
|
||||||
// - move PK from GUID to key_path
|
// - move PK from GUID to key_path
|
||||||
// - remove all unnecessary columns (or at least their NOT NULL constraints)
|
// - remove all unnecessary columns (or at least their NOT NULL constraints)
|
||||||
@@ -429,11 +407,12 @@ func (w *sqlWriteCloser) Close() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// special, temporary save that includes all the fields in resource_history that are not relevant for the kvstore,
|
// special, temporary backwards-compatible save that includes all the fields in resource_history that are not relevant
|
||||||
// as well as the resource table. This is only called if an RvManager was passed to storage_backend, as that
|
// for the kvstore, as well as the resource table. This is only called if an RvManager was passed to storage_backend, as that
|
||||||
// component will be responsible for populating the resource_version and key_path columns
|
// component will be responsible for populating the resource_version and key_path columns.
|
||||||
// note that we are not touching resource_version table, neither the resource_version columns or the key_path column
|
// For full backwards-compatibility, the `Save` function needs to be called within a callback that updates the resource_history
|
||||||
// as the RvManager will be responsible for this
|
// table with `previous_resource_version` and `generation` and updates the `resource` table accordingly. See the
|
||||||
|
// storage_backend for the full implementation.
|
||||||
dataKey, err := ParseKeyWithGUID(w.sectionKey.Key)
|
dataKey, err := ParseKeyWithGUID(w.sectionKey.Key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to parse key: %w", err)
|
return fmt.Errorf("failed to parse key: %w", err)
|
||||||
@@ -448,7 +427,7 @@ func (w *sqlWriteCloser) Close() error {
|
|||||||
case DataActionDeleted:
|
case DataActionDeleted:
|
||||||
action = 3
|
action = 3
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("failed to parse key: %w", err)
|
return fmt.Errorf("failed to parse key: invalid action")
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = dbutil.Exec(w.ctx, tx, sqlKVInsertLegacyResourceHistory, sqlKVSaveRequest{
|
_, err = dbutil.Exec(w.ctx, tx, sqlKVInsertLegacyResourceHistory, sqlKVSaveRequest{
|
||||||
@@ -468,52 +447,6 @@ func (w *sqlWriteCloser) Close() error {
|
|||||||
return fmt.Errorf("failed to save to resource_history: %w", err)
|
return fmt.Errorf("failed to save to resource_history: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
switch dataKey.Action {
|
|
||||||
case DataActionCreated:
|
|
||||||
_, err = dbutil.Exec(w.ctx, tx, sqlKVInsertLegacyResource, sqlKVLegacySaveRequest{
|
|
||||||
SQLTemplate: sqltemplate.New(w.kv.dialect),
|
|
||||||
Value: w.buf.Bytes(),
|
|
||||||
GUID: dataKey.GUID,
|
|
||||||
Group: dataKey.Group,
|
|
||||||
Resource: dataKey.Resource,
|
|
||||||
Namespace: dataKey.Namespace,
|
|
||||||
Name: dataKey.Name,
|
|
||||||
Action: action,
|
|
||||||
Folder: dataKey.Folder,
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to insert to resource: %w", err)
|
|
||||||
}
|
|
||||||
case DataActionUpdated:
|
|
||||||
_, err = dbutil.Exec(w.ctx, tx, sqlKVUpdateLegacyResource, sqlKVLegacySaveRequest{
|
|
||||||
SQLTemplate: sqltemplate.New(w.kv.dialect),
|
|
||||||
Value: w.buf.Bytes(),
|
|
||||||
Group: dataKey.Group,
|
|
||||||
Resource: dataKey.Resource,
|
|
||||||
Namespace: dataKey.Namespace,
|
|
||||||
Name: dataKey.Name,
|
|
||||||
Action: action,
|
|
||||||
Folder: dataKey.Folder,
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to update resource: %w", err)
|
|
||||||
}
|
|
||||||
case DataActionDeleted:
|
|
||||||
_, err = dbutil.Exec(w.ctx, tx, sqlKVDeleteLegacyResource, sqlKVLegacySaveRequest{
|
|
||||||
SQLTemplate: sqltemplate.New(w.kv.dialect),
|
|
||||||
Group: dataKey.Group,
|
|
||||||
Resource: dataKey.Resource,
|
|
||||||
Namespace: dataKey.Namespace,
|
|
||||||
Name: dataKey.Name,
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to delete from resource: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -332,11 +332,14 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in
|
|||||||
dataKey.GUID = uuid.New().String()
|
dataKey.GUID = uuid.New().String()
|
||||||
var err error
|
var err error
|
||||||
rv, err = k.rvManager.ExecWithRV(ctx, event.Key, func(tx db.Tx) (string, error) {
|
rv, err = k.rvManager.ExecWithRV(ctx, event.Key, func(tx db.Tx) (string, error) {
|
||||||
err := k.dataStore.Save(rvmanager.ContextWithTx(ctx, tx), dataKey, bytes.NewReader(event.Value))
|
if err := k.dataStore.Save(rvmanager.ContextWithTx(ctx, tx), dataKey, bytes.NewReader(event.Value)); err != nil {
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to write data: %w", err)
|
return "", fmt.Errorf("failed to write data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := k.dataStore.applyBackwardsCompatibleChanges(ctx, tx, event, dataKey); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to apply backwards compatible updates: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return dataKey.GUID, nil
|
return dataKey.GUID, nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
package apis
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/tests/testinfra"
|
|
||||||
"github.com/grafana/grafana/pkg/util/testutil"
|
|
||||||
)
|
|
||||||
|
|
||||||
const pluginsDiscoveryJSON = `[
|
|
||||||
{
|
|
||||||
"version": "v0alpha1",
|
|
||||||
"freshness": "Current",
|
|
||||||
"resources": [
|
|
||||||
{
|
|
||||||
"resource": "metas",
|
|
||||||
"responseKind": {
|
|
||||||
"group": "",
|
|
||||||
"kind": "Meta",
|
|
||||||
"version": ""
|
|
||||||
},
|
|
||||||
"scope": "Namespaced",
|
|
||||||
"singularResource": "meta",
|
|
||||||
"subresources": [
|
|
||||||
{
|
|
||||||
"responseKind": {
|
|
||||||
"group": "",
|
|
||||||
"kind": "Meta",
|
|
||||||
"version": ""
|
|
||||||
},
|
|
||||||
"subresource": "status",
|
|
||||||
"verbs": [
|
|
||||||
"get",
|
|
||||||
"patch",
|
|
||||||
"update"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"verbs": [
|
|
||||||
"get",
|
|
||||||
"list"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"resource": "plugins",
|
|
||||||
"responseKind": {
|
|
||||||
"group": "",
|
|
||||||
"kind": "Plugin",
|
|
||||||
"version": ""
|
|
||||||
},
|
|
||||||
"scope": "Namespaced",
|
|
||||||
"singularResource": "plugin",
|
|
||||||
"subresources": [
|
|
||||||
{
|
|
||||||
"responseKind": {
|
|
||||||
"group": "",
|
|
||||||
"kind": "Plugin",
|
|
||||||
"version": ""
|
|
||||||
},
|
|
||||||
"subresource": "status",
|
|
||||||
"verbs": [
|
|
||||||
"get",
|
|
||||||
"patch",
|
|
||||||
"update"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"verbs": [
|
|
||||||
"create",
|
|
||||||
"delete",
|
|
||||||
"deletecollection",
|
|
||||||
"get",
|
|
||||||
"list",
|
|
||||||
"patch",
|
|
||||||
"update",
|
|
||||||
"watch"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]`
|
|
||||||
|
|
||||||
func setupHelper(t *testing.T, openFeatureAPIEnabled bool) *K8sTestHelper {
|
|
||||||
t.Helper()
|
|
||||||
helper := NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
|
||||||
AppModeProduction: true,
|
|
||||||
DisableAnonymous: true,
|
|
||||||
APIServerRuntimeConfig: "plugins.grafana.app/v0alpha1=true",
|
|
||||||
OpenFeatureAPIEnabled: openFeatureAPIEnabled,
|
|
||||||
})
|
|
||||||
t.Cleanup(func() { helper.Shutdown() })
|
|
||||||
return helper
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIntegrationAPIServerRuntimeConfig(t *testing.T) {
|
|
||||||
testutil.SkipIntegrationTestInShortMode(t)
|
|
||||||
|
|
||||||
t.Run("discovery with openfeature api enabled", func(t *testing.T) {
|
|
||||||
helper := setupHelper(t, true)
|
|
||||||
disco, err := helper.GetGroupVersionInfoJSON("features.grafana.app")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.JSONEq(t, `[
|
|
||||||
{
|
|
||||||
"freshness": "Current",
|
|
||||||
"resources": [
|
|
||||||
{
|
|
||||||
"resource": "noop",
|
|
||||||
"responseKind": {
|
|
||||||
"group": "",
|
|
||||||
"kind": "Status",
|
|
||||||
"version": ""
|
|
||||||
},
|
|
||||||
"scope": "Namespaced",
|
|
||||||
"singularResource": "noop",
|
|
||||||
"verbs": [
|
|
||||||
"get"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"version": "v0alpha1"
|
|
||||||
}
|
|
||||||
]`, disco)
|
|
||||||
|
|
||||||
// plugins should still be discoverable
|
|
||||||
disco, err = helper.GetGroupVersionInfoJSON("plugins.grafana.app")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.JSONEq(t, pluginsDiscoveryJSON, disco)
|
|
||||||
require.NoError(t, err)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("discovery with openfeature api false", func(t *testing.T) {
|
|
||||||
helper := setupHelper(t, false)
|
|
||||||
_, err := helper.GetGroupVersionInfoJSON("features.grafana.app")
|
|
||||||
require.Error(t, err, "expected error when openfeature api is disabled")
|
|
||||||
|
|
||||||
// plugins should still be discoverable
|
|
||||||
disco, err := helper.GetGroupVersionInfoJSON("plugins.grafana.app")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.JSONEq(t, pluginsDiscoveryJSON, disco)
|
|
||||||
require.NoError(t, err)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||||
"github.com/grafana/grafana/pkg/tests/apis"
|
"github.com/grafana/grafana/pkg/tests/apis"
|
||||||
"github.com/grafana/grafana/pkg/tests/testinfra"
|
"github.com/grafana/grafana/pkg/tests/testinfra"
|
||||||
"github.com/grafana/grafana/pkg/tests/testsuite"
|
"github.com/grafana/grafana/pkg/tests/testsuite"
|
||||||
@@ -177,6 +178,9 @@ func setupHelper(t *testing.T) *apis.K8sTestHelper {
|
|||||||
AppModeProduction: true,
|
AppModeProduction: true,
|
||||||
DisableAnonymous: true,
|
DisableAnonymous: true,
|
||||||
APIServerRuntimeConfig: "plugins.grafana.app/v0alpha1=true",
|
APIServerRuntimeConfig: "plugins.grafana.app/v0alpha1=true",
|
||||||
|
EnableFeatureToggles: []string{
|
||||||
|
featuremgmt.FlagPluginStoreServiceLoading,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
t.Cleanup(func() { helper.Shutdown() })
|
t.Cleanup(func() { helper.Shutdown() })
|
||||||
return helper
|
return helper
|
||||||
|
|||||||
@@ -320,8 +320,9 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
_, err = openFeatureSect.NewKey("enable_api", strconv.FormatBool(opts.OpenFeatureAPIEnabled))
|
_, err = openFeatureSect.NewKey("enable_api", strconv.FormatBool(opts.OpenFeatureAPIEnabled))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
if !opts.OpenFeatureAPIEnabled {
|
|
||||||
_, err = openFeatureSect.NewKey("provider", "static") // in practice, APIEnabled being false goes with features-service type, but trying to make tests work
|
if opts.OpenFeatureAPIEnabled {
|
||||||
|
_, err = openFeatureSect.NewKey("provider", "static")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
_, err = openFeatureSect.NewKey("targetingKey", "grafana")
|
_, err = openFeatureSect.NewKey("targetingKey", "grafana")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { reportInteraction } from '@grafana/runtime';
|
|||||||
import { ScrollContainer, useStyles2 } from '@grafana/ui';
|
import { ScrollContainer, useStyles2 } from '@grafana/ui';
|
||||||
import { useGrafana } from 'app/core/context/GrafanaContext';
|
import { useGrafana } from 'app/core/context/GrafanaContext';
|
||||||
import { setBookmark } from 'app/core/reducers/navBarTree';
|
import { setBookmark } from 'app/core/reducers/navBarTree';
|
||||||
|
import { shouldUseAlertingNavigationV2 } from 'app/features/alerting/unified/featureToggles';
|
||||||
import { useDispatch, useSelector } from 'app/types/store';
|
import { useDispatch, useSelector } from 'app/types/store';
|
||||||
|
|
||||||
import { MegaMenuExtensionPoint } from './MegaMenuExtensionPoint';
|
import { MegaMenuExtensionPoint } from './MegaMenuExtensionPoint';
|
||||||
@@ -37,9 +38,25 @@ export const MegaMenu = memo(
|
|||||||
const pinnedItems = usePinnedItems();
|
const pinnedItems = usePinnedItems();
|
||||||
|
|
||||||
// Remove profile + help from tree
|
// Remove profile + help from tree
|
||||||
|
// For Alerting V2 navigation, flatten the sidebar to show only top-level items (hide nested children/tabs)
|
||||||
|
const useV2Nav = shouldUseAlertingNavigationV2();
|
||||||
const navItems = navTree
|
const navItems = navTree
|
||||||
.filter((item) => item.id !== 'profile' && item.id !== 'help')
|
.filter((item) => item.id !== 'profile' && item.id !== 'help')
|
||||||
.map((item) => enrichWithInteractionTracking(item, state.megaMenuDocked));
|
.map((item) => {
|
||||||
|
const enriched = enrichWithInteractionTracking(item, state.megaMenuDocked);
|
||||||
|
// If this is Alerting section and V2 navigation is enabled, flatten children for sidebar display
|
||||||
|
// Children are still available in navIndex for breadcrumbs and page navigation
|
||||||
|
if (useV2Nav && item.id === 'alerting' && enriched.children) {
|
||||||
|
return {
|
||||||
|
...enriched,
|
||||||
|
children: enriched.children.map((child) => ({
|
||||||
|
...child,
|
||||||
|
children: undefined, // Remove nested children from sidebar, but keep them for page navigation
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return enriched;
|
||||||
|
});
|
||||||
|
|
||||||
const bookmarksItem = navItems.find((item) => item.id === 'bookmarks');
|
const bookmarksItem = navItems.find((item) => item.id === 'bookmarks');
|
||||||
if (bookmarksItem) {
|
if (bookmarksItem) {
|
||||||
|
|||||||
@@ -35,11 +35,18 @@ export function buildBreadcrumbs(sectionNav: NavModelItem, pageNav?: NavModelIte
|
|||||||
|
|
||||||
if (shouldAddCrumb) {
|
if (shouldAddCrumb) {
|
||||||
const activeChildIndex = node.children?.findIndex((child) => child.active) ?? -1;
|
const activeChildIndex = node.children?.findIndex((child) => child.active) ?? -1;
|
||||||
// Add tab to breadcrumbs if it's not the first active child
|
// Add active tab to breadcrumbs if it exists and its URL is different from the node's URL
|
||||||
if (activeChildIndex > 0) {
|
// This ensures tabs show in breadcrumbs (including the first tab) while preventing duplication
|
||||||
|
if (activeChildIndex >= 0) {
|
||||||
const activeChild = node.children?.[activeChildIndex];
|
const activeChild = node.children?.[activeChildIndex];
|
||||||
if (activeChild) {
|
if (activeChild) {
|
||||||
crumbs.unshift({ text: activeChild.text, href: activeChild.url ?? '' });
|
// Only add the active child if its URL doesn't match the node's URL
|
||||||
|
// This prevents duplication when the pageNav is the active tab
|
||||||
|
const nodeUrl = node.url?.split('?')[0] ?? '';
|
||||||
|
const childUrl = activeChild.url?.split('?')[0] ?? '';
|
||||||
|
if (nodeUrl !== childUrl) {
|
||||||
|
crumbs.unshift({ text: activeChild.text, href: activeChild.url ?? '' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
crumbs.unshift({ text: node.text, href: node.url ?? '' });
|
crumbs.unshift({ text: node.text, href: node.url ?? '' });
|
||||||
|
|||||||
@@ -56,6 +56,17 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] {
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/alerting/time-intervals',
|
||||||
|
roles: evaluateAccess([
|
||||||
|
AccessControlAction.AlertingNotificationsRead,
|
||||||
|
AccessControlAction.AlertingNotificationsExternalRead,
|
||||||
|
...PERMISSIONS_TIME_INTERVALS_READ,
|
||||||
|
]),
|
||||||
|
component: importAlertingComponent(
|
||||||
|
() => import(/* webpackChunkName: "TimeIntervalsPage" */ 'app/features/alerting/unified/TimeIntervalsPage')
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/alerting/routes/mute-timing/new',
|
path: '/alerting/routes/mute-timing/new',
|
||||||
roles: evaluateAccess([
|
roles: evaluateAccess([
|
||||||
@@ -212,6 +223,13 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] {
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/alerting/insights',
|
||||||
|
roles: evaluateAccess([AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleExternalRead]),
|
||||||
|
component: importAlertingComponent(
|
||||||
|
() => import(/* webpackChunkName: "InsightsPage" */ 'app/features/alerting/unified/insights/InsightsPage')
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/alerting/recently-deleted/',
|
path: '/alerting/recently-deleted/',
|
||||||
roles: () => ['Admin'],
|
roles: () => ['Admin'],
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { AlertGroupFilter } from './components/alert-groups/AlertGroupFilter';
|
|||||||
import { useFilteredAmGroups } from './hooks/useFilteredAmGroups';
|
import { useFilteredAmGroups } from './hooks/useFilteredAmGroups';
|
||||||
import { useGroupedAlerts } from './hooks/useGroupedAlerts';
|
import { useGroupedAlerts } from './hooks/useGroupedAlerts';
|
||||||
import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector';
|
import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector';
|
||||||
|
import { useAlertActivityNav } from './navigation/useAlertActivityNav';
|
||||||
import { useAlertmanager } from './state/AlertmanagerContext';
|
import { useAlertmanager } from './state/AlertmanagerContext';
|
||||||
import { fetchAlertGroupsAction } from './state/actions';
|
import { fetchAlertGroupsAction } from './state/actions';
|
||||||
import { NOTIFICATIONS_POLL_INTERVAL_MS } from './utils/constants';
|
import { NOTIFICATIONS_POLL_INTERVAL_MS } from './utils/constants';
|
||||||
@@ -113,8 +114,9 @@ const AlertGroups = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function AlertGroupsPage() {
|
function AlertGroupsPage() {
|
||||||
|
const { navId, pageNav } = useAlertActivityNav();
|
||||||
return (
|
return (
|
||||||
<AlertmanagerPageWrapper navId="groups" accessType="instance">
|
<AlertmanagerPageWrapper navId={navId || 'groups'} pageNav={pageNav} accessType="instance">
|
||||||
<AlertGroups />
|
<AlertGroups />
|
||||||
</AlertmanagerPageWrapper>
|
</AlertmanagerPageWrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { produce } from 'immer';
|
import { produce } from 'immer';
|
||||||
import { clickSelectOption } from 'test/helpers/selectOptionInTest';
|
import { clickSelectOption } from 'test/helpers/selectOptionInTest';
|
||||||
import { render, screen, userEvent, within } from 'test/test-utils';
|
import { render, screen, testWithFeatureToggles, userEvent, within } from 'test/test-utils';
|
||||||
import { byLabelText, byRole, byTestId } from 'testing-library-selector';
|
import { byLabelText, byRole, byTestId } from 'testing-library-selector';
|
||||||
|
|
||||||
import { AppNotificationList } from 'app/core/components/AppNotifications/AppNotificationList';
|
import { AppNotificationList } from 'app/core/components/AppNotifications/AppNotificationList';
|
||||||
@@ -140,6 +140,39 @@ const getRootRoute = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('NotificationPolicies', () => {
|
describe('NotificationPolicies', () => {
|
||||||
|
describe('V2 Navigation Mode', () => {
|
||||||
|
testWithFeatureToggles({ enable: ['alertingNavigationV2'] });
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setupDataSources(dataSources.am);
|
||||||
|
grantUserPermissions([
|
||||||
|
AccessControlAction.AlertingNotificationsRead,
|
||||||
|
AccessControlAction.AlertingNotificationsWrite,
|
||||||
|
...PERMISSIONS_NOTIFICATION_POLICIES,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows only notification policies without internal tabs', async () => {
|
||||||
|
renderNotificationPolicies();
|
||||||
|
|
||||||
|
// Should show notification policies directly
|
||||||
|
expect(await ui.rootRouteContainer.find()).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Should not have tabs
|
||||||
|
expect(screen.queryByRole('tab')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show time intervals tab in V2 mode', async () => {
|
||||||
|
renderNotificationPolicies();
|
||||||
|
|
||||||
|
// Should show notification policies
|
||||||
|
expect(await ui.rootRouteContainer.find()).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Should not show time intervals tab
|
||||||
|
expect(screen.queryByText(/time intervals/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// combobox hack :/
|
// combobox hack :/
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
const mockGetBoundingClientRect = jest.fn(() => ({
|
const mockGetBoundingClientRect = jest.fn(() => ({
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { AlertmanagerAction, useAlertmanagerAbility } from 'app/features/alertin
|
|||||||
import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper';
|
import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper';
|
||||||
import { GrafanaAlertmanagerWarning } from './components/GrafanaAlertmanagerWarning';
|
import { GrafanaAlertmanagerWarning } from './components/GrafanaAlertmanagerWarning';
|
||||||
import { TimeIntervalsTable } from './components/mute-timings/MuteTimingsTable';
|
import { TimeIntervalsTable } from './components/mute-timings/MuteTimingsTable';
|
||||||
|
import { shouldUseAlertingNavigationV2 } from './featureToggles';
|
||||||
|
import { useNotificationConfigNav } from './navigation/useNotificationConfigNav';
|
||||||
import { useAlertmanager } from './state/AlertmanagerContext';
|
import { useAlertmanager } from './state/AlertmanagerContext';
|
||||||
import { withPageErrorBoundary } from './withPageErrorBoundary';
|
import { withPageErrorBoundary } from './withPageErrorBoundary';
|
||||||
|
|
||||||
@@ -106,9 +108,32 @@ function getActiveTabFromUrl(queryParams: UrlQueryMap, defaultTab: ActiveTab): Q
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function NotificationPoliciesPage() {
|
const NotificationPoliciesContent = () => {
|
||||||
|
const { selectedAlertmanager = '' } = useAlertmanager();
|
||||||
return (
|
return (
|
||||||
<AlertmanagerPageWrapper navId="am-routes" accessType="notification">
|
<>
|
||||||
|
<GrafanaAlertmanagerWarning currentAlertmanager={selectedAlertmanager} />
|
||||||
|
<NotificationPoliciesList />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function NotificationPoliciesPage() {
|
||||||
|
const useV2Nav = shouldUseAlertingNavigationV2();
|
||||||
|
const { navId, pageNav } = useNotificationConfigNav();
|
||||||
|
|
||||||
|
// In V2 mode, show only notification policies (no internal tabs)
|
||||||
|
if (useV2Nav) {
|
||||||
|
return (
|
||||||
|
<AlertmanagerPageWrapper navId={navId || 'am-routes'} pageNav={pageNav} accessType="notification">
|
||||||
|
<NotificationPoliciesContent />
|
||||||
|
</AlertmanagerPageWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy mode: Show internal tabs (backward compatible)
|
||||||
|
return (
|
||||||
|
<AlertmanagerPageWrapper navId={navId || 'am-routes'} pageNav={pageNav} accessType="notification">
|
||||||
<NotificationPoliciesTabs />
|
<NotificationPoliciesTabs />
|
||||||
</AlertmanagerPageWrapper>
|
</AlertmanagerPageWrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,56 @@
|
|||||||
import { Route, Routes } from 'react-router-dom-v5-compat';
|
import { Route, Routes } from 'react-router-dom-v5-compat';
|
||||||
|
|
||||||
|
import { Trans } from '@grafana/i18n';
|
||||||
|
import { LinkButton, Stack, Text } from '@grafana/ui';
|
||||||
|
|
||||||
|
import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper';
|
||||||
import DuplicateMessageTemplate from './components/contact-points/DuplicateMessageTemplate';
|
import DuplicateMessageTemplate from './components/contact-points/DuplicateMessageTemplate';
|
||||||
import EditMessageTemplate from './components/contact-points/EditMessageTemplate';
|
import EditMessageTemplate from './components/contact-points/EditMessageTemplate';
|
||||||
import NewMessageTemplate from './components/contact-points/NewMessageTemplate';
|
import NewMessageTemplate from './components/contact-points/NewMessageTemplate';
|
||||||
|
import { NotificationTemplates } from './components/contact-points/NotificationTemplates';
|
||||||
|
import { shouldUseAlertingNavigationV2 } from './featureToggles';
|
||||||
|
import { AlertmanagerAction, useAlertmanagerAbility } from './hooks/useAbilities';
|
||||||
|
import { useNotificationConfigNav } from './navigation/useNotificationConfigNav';
|
||||||
import { withPageErrorBoundary } from './withPageErrorBoundary';
|
import { withPageErrorBoundary } from './withPageErrorBoundary';
|
||||||
|
|
||||||
function NotificationTemplates() {
|
const TemplatesList = () => {
|
||||||
|
const [createTemplateSupported, createTemplateAllowed] = useAlertmanagerAbility(
|
||||||
|
AlertmanagerAction.CreateNotificationTemplate
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||||
|
<Text variant="body" color="secondary">
|
||||||
|
<Trans i18nKey="alerting.notification-templates-tab.create-notification-templates-customize-notifications">
|
||||||
|
Create notification templates to customize your notifications.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
{createTemplateSupported && (
|
||||||
|
<LinkButton
|
||||||
|
icon="plus"
|
||||||
|
variant="primary"
|
||||||
|
href="/alerting/notifications/templates/new"
|
||||||
|
disabled={!createTemplateAllowed}
|
||||||
|
>
|
||||||
|
<Trans i18nKey="alerting.notification-templates-tab.add-notification-template-group">
|
||||||
|
Add notification template group
|
||||||
|
</Trans>
|
||||||
|
</LinkButton>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
<NotificationTemplates />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function NotificationTemplatesRoutes() {
|
||||||
|
const useV2Nav = shouldUseAlertingNavigationV2();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
|
{/* In V2 mode, show templates list on base route */}
|
||||||
|
{useV2Nav && <Route path="" element={<TemplatesList />} />}
|
||||||
<Route path="new" element={<NewMessageTemplate />} />
|
<Route path="new" element={<NewMessageTemplate />} />
|
||||||
<Route path=":name/edit" element={<EditMessageTemplate />} />
|
<Route path=":name/edit" element={<EditMessageTemplate />} />
|
||||||
<Route path=":name/duplicate" element={<DuplicateMessageTemplate />} />
|
<Route path=":name/duplicate" element={<DuplicateMessageTemplate />} />
|
||||||
@@ -15,4 +58,21 @@ function NotificationTemplates() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withPageErrorBoundary(NotificationTemplates);
|
function NotificationTemplatesPage() {
|
||||||
|
const useV2Nav = shouldUseAlertingNavigationV2();
|
||||||
|
const { navId, pageNav } = useNotificationConfigNav();
|
||||||
|
|
||||||
|
// In V2 mode, wrap with page wrapper for proper navigation
|
||||||
|
if (useV2Nav) {
|
||||||
|
return (
|
||||||
|
<AlertmanagerPageWrapper navId={navId || 'receivers'} pageNav={pageNav} accessType="notification">
|
||||||
|
<NotificationTemplatesRoutes />
|
||||||
|
</AlertmanagerPageWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// In legacy mode, just render routes (templates are accessed via ContactPoints page tabs)
|
||||||
|
return <NotificationTemplatesRoutes />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withPageErrorBoundary(NotificationTemplatesPage);
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { render, screen, testWithFeatureToggles } from 'test/test-utils';
|
||||||
|
|
||||||
|
import { configureStore } from 'app/store/configureStore';
|
||||||
|
import { AccessControlAction } from 'app/types/accessControl';
|
||||||
|
|
||||||
|
import TimeIntervalsPage from './TimeIntervalsPage';
|
||||||
|
import { defaultConfig } from './components/mute-timings/mocks';
|
||||||
|
import { setupMswServer } from './mockApi';
|
||||||
|
import { grantUserPermissions, mockDataSource } from './mocks';
|
||||||
|
import { setTimeIntervalsListEmpty } from './mocks/server/configure';
|
||||||
|
import { setAlertmanagerConfig } from './mocks/server/entities/alertmanagers';
|
||||||
|
import { setupDataSources } from './testSetup/datasources';
|
||||||
|
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
|
||||||
|
|
||||||
|
setupMswServer();
|
||||||
|
|
||||||
|
const alertManager = mockDataSource({
|
||||||
|
name: 'Alertmanager',
|
||||||
|
type: DataSourceType.Alertmanager,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TimeIntervalsPage', () => {
|
||||||
|
describe('V2 Navigation Mode', () => {
|
||||||
|
testWithFeatureToggles({ enable: ['alertingNavigationV2'] });
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setupDataSources(alertManager);
|
||||||
|
setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, defaultConfig);
|
||||||
|
setTimeIntervalsListEmpty(); // Mock empty time intervals list so component renders
|
||||||
|
grantUserPermissions([
|
||||||
|
AccessControlAction.AlertingNotificationsRead,
|
||||||
|
AccessControlAction.AlertingTimeIntervalsRead,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders time intervals table', async () => {
|
||||||
|
const mockNavIndex = {
|
||||||
|
'notification-config': {
|
||||||
|
id: 'notification-config',
|
||||||
|
text: 'Notification configuration',
|
||||||
|
url: '/alerting/notifications',
|
||||||
|
},
|
||||||
|
'notification-config-time-intervals': {
|
||||||
|
id: 'notification-config-time-intervals',
|
||||||
|
text: 'Time intervals',
|
||||||
|
url: '/alerting/time-intervals',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const store = configureStore({
|
||||||
|
navIndex: mockNavIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<TimeIntervalsPage />, {
|
||||||
|
store,
|
||||||
|
historyOptions: {
|
||||||
|
initialEntries: ['/alerting/time-intervals'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should show time intervals content
|
||||||
|
// When empty, it shows "You haven't created any time intervals yet"
|
||||||
|
// When loading, it shows "Loading time intervals..."
|
||||||
|
// When error, it shows "Error loading time intervals"
|
||||||
|
// All contain "time intervals" - use getAllByText since there are multiple matches (tab, description, empty state)
|
||||||
|
const timeIntervalsTexts = await screen.findAllByText(/time intervals/i, {}, { timeout: 5000 });
|
||||||
|
expect(timeIntervalsTexts.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null in legacy mode', () => {
|
||||||
|
// This test verifies that the component returns null when V2 is disabled
|
||||||
|
// The feature toggle is controlled by testWithFeatureToggles, so we test it separately
|
||||||
|
const { container } = render(<TimeIntervalsPage />, {
|
||||||
|
historyOptions: {
|
||||||
|
initialEntries: ['/alerting/time-intervals'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// In V2 mode (enabled by testWithFeatureToggles), it should render content
|
||||||
|
expect(container).not.toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
40
public/app/features/alerting/unified/TimeIntervalsPage.tsx
Normal file
40
public/app/features/alerting/unified/TimeIntervalsPage.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper';
|
||||||
|
import { GrafanaAlertmanagerWarning } from './components/GrafanaAlertmanagerWarning';
|
||||||
|
import { TimeIntervalsTable } from './components/mute-timings/MuteTimingsTable';
|
||||||
|
import { shouldUseAlertingNavigationV2 } from './featureToggles';
|
||||||
|
import { useNotificationConfigNav } from './navigation/useNotificationConfigNav';
|
||||||
|
import { useAlertmanager } from './state/AlertmanagerContext';
|
||||||
|
import { withPageErrorBoundary } from './withPageErrorBoundary';
|
||||||
|
|
||||||
|
// Content component that uses AlertmanagerContext
|
||||||
|
// This must be rendered within AlertmanagerPageWrapper
|
||||||
|
function TimeIntervalsPageContent() {
|
||||||
|
const { selectedAlertmanager } = useAlertmanager();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<GrafanaAlertmanagerWarning currentAlertmanager={selectedAlertmanager!} />
|
||||||
|
<TimeIntervalsTable />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimeIntervalsPage() {
|
||||||
|
const useV2Nav = shouldUseAlertingNavigationV2();
|
||||||
|
const { navId, pageNav } = useNotificationConfigNav();
|
||||||
|
|
||||||
|
// In V2 mode, wrap with page wrapper for proper navigation
|
||||||
|
// AlertmanagerPageWrapper provides AlertmanagerContext, so TimeIntervalsPageContent can use useAlertmanager
|
||||||
|
if (useV2Nav) {
|
||||||
|
return (
|
||||||
|
<AlertmanagerPageWrapper navId={navId || 'am-routes'} pageNav={pageNav} accessType="notification">
|
||||||
|
<TimeIntervalsPageContent />
|
||||||
|
</AlertmanagerPageWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy mode: not used (handled by NotificationPoliciesPage)
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withPageErrorBoundary(TimeIntervalsPage);
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
import { MemoryHistoryBuildOptions } from 'history';
|
import { MemoryHistoryBuildOptions } from 'history';
|
||||||
import { ComponentProps, ReactNode } from 'react';
|
import { ComponentProps, ReactNode } from 'react';
|
||||||
import { render, screen, userEvent, waitFor, waitForElementToBeRemoved, within } from 'test/test-utils';
|
import {
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
testWithFeatureToggles,
|
||||||
|
userEvent,
|
||||||
|
waitFor,
|
||||||
|
waitForElementToBeRemoved,
|
||||||
|
within,
|
||||||
|
} from 'test/test-utils';
|
||||||
|
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
import { MIMIR_DATASOURCE_UID } from 'app/features/alerting/unified/mocks/server/constants';
|
import { MIMIR_DATASOURCE_UID } from 'app/features/alerting/unified/mocks/server/constants';
|
||||||
@@ -170,6 +178,30 @@ describe('contact points', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('V2 Navigation Mode', () => {
|
||||||
|
testWithFeatureToggles({ enable: ['alertingNavigationV2'] });
|
||||||
|
|
||||||
|
test('shows only contact points without internal tabs', async () => {
|
||||||
|
renderWithProvider(<ContactPointsPageContents />);
|
||||||
|
|
||||||
|
// Should show contact points directly
|
||||||
|
expect(await screen.findByText(/create contact point/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Should not have tabs
|
||||||
|
expect(screen.queryByRole('tab')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not show templates tab in V2 mode', async () => {
|
||||||
|
renderWithProvider(<ContactPointsPageContents />);
|
||||||
|
|
||||||
|
// Should show contact points
|
||||||
|
expect(await screen.findByText(/create contact point/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Should not show templates tab
|
||||||
|
expect(screen.queryByText(/notification templates/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('templates tab', () => {
|
describe('templates tab', () => {
|
||||||
it('does not show a warning for a "misconfigured" template', async () => {
|
it('does not show a warning for a "misconfigured" template', async () => {
|
||||||
renderWithProvider(
|
renderWithProvider(
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { Trans, t } from '@grafana/i18n';
|
import { Trans, t } from '@grafana/i18n';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -13,15 +15,18 @@ import {
|
|||||||
TabContent,
|
TabContent,
|
||||||
TabsBar,
|
TabsBar,
|
||||||
Text,
|
Text,
|
||||||
|
useStyles2,
|
||||||
} from '@grafana/ui';
|
} from '@grafana/ui';
|
||||||
import { contextSrv } from 'app/core/services/context_srv';
|
import { contextSrv } from 'app/core/services/context_srv';
|
||||||
import { shouldUseK8sApi } from 'app/features/alerting/unified/utils/k8s/utils';
|
import { shouldUseK8sApi } from 'app/features/alerting/unified/utils/k8s/utils';
|
||||||
import { makeAMLink, stringifyErrorLike } from 'app/features/alerting/unified/utils/misc';
|
import { makeAMLink, stringifyErrorLike } from 'app/features/alerting/unified/utils/misc';
|
||||||
import { AccessControlAction } from 'app/types/accessControl';
|
import { AccessControlAction } from 'app/types/accessControl';
|
||||||
|
|
||||||
|
import { shouldUseAlertingNavigationV2 } from '../../featureToggles';
|
||||||
import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities';
|
import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities';
|
||||||
import { usePagination } from '../../hooks/usePagination';
|
import { usePagination } from '../../hooks/usePagination';
|
||||||
import { useURLSearchParams } from '../../hooks/useURLSearchParams';
|
import { useURLSearchParams } from '../../hooks/useURLSearchParams';
|
||||||
|
import { useNotificationConfigNav } from '../../navigation/useNotificationConfigNav';
|
||||||
import { useAlertmanager } from '../../state/AlertmanagerContext';
|
import { useAlertmanager } from '../../state/AlertmanagerContext';
|
||||||
import { isExtraConfig } from '../../utils/alertmanager/extraConfigs';
|
import { isExtraConfig } from '../../utils/alertmanager/extraConfigs';
|
||||||
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
|
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
|
||||||
@@ -99,7 +104,7 @@ const ContactPointsTab = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Stack direction="column" gap={1}>
|
||||||
{/* TODO we can add some additional info here with a ToggleTip */}
|
{/* TODO we can add some additional info here with a ToggleTip */}
|
||||||
<Stack direction="row" alignItems="end" justifyContent="space-between">
|
<Stack direction="row" alignItems="end" justifyContent="space-between">
|
||||||
<ContactPointsFilter />
|
<ContactPointsFilter />
|
||||||
@@ -148,7 +153,7 @@ const ContactPointsTab = () => {
|
|||||||
<GlobalConfigAlert alertManagerName={selectedAlertmanager!} />
|
<GlobalConfigAlert alertManagerName={selectedAlertmanager!} />
|
||||||
)}
|
)}
|
||||||
{ExportDrawer}
|
{ExportDrawer}
|
||||||
</>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -158,7 +163,7 @@ const NotificationTemplatesTab = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Stack direction="column" gap={1}>
|
||||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||||
<Text variant="body" color="secondary">
|
<Text variant="body" color="secondary">
|
||||||
<Trans i18nKey="alerting.notification-templates-tab.create-notification-templates-customize-notifications">
|
<Trans i18nKey="alerting.notification-templates-tab.create-notification-templates-customize-notifications">
|
||||||
@@ -179,7 +184,7 @@ const NotificationTemplatesTab = () => {
|
|||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
<NotificationTemplates />
|
<NotificationTemplates />
|
||||||
</>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -201,6 +206,10 @@ const useTabQueryParam = (defaultTab: ActiveTab) => {
|
|||||||
|
|
||||||
export const ContactPointsPageContents = () => {
|
export const ContactPointsPageContents = () => {
|
||||||
const { selectedAlertmanager } = useAlertmanager();
|
const { selectedAlertmanager } = useAlertmanager();
|
||||||
|
const useV2Nav = shouldUseAlertingNavigationV2();
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
|
// All hooks must be called unconditionally before any early returns
|
||||||
const [, canViewContactPoints] = useAlertmanagerAbility(AlertmanagerAction.ViewContactPoint);
|
const [, canViewContactPoints] = useAlertmanagerAbility(AlertmanagerAction.ViewContactPoint);
|
||||||
const [, canCreateContactPoints] = useAlertmanagerAbility(AlertmanagerAction.CreateContactPoint);
|
const [, canCreateContactPoints] = useAlertmanagerAbility(AlertmanagerAction.CreateContactPoint);
|
||||||
const [, showTemplatesTab] = useAlertmanagerAbility(AlertmanagerAction.ViewNotificationTemplate);
|
const [, showTemplatesTab] = useAlertmanagerAbility(AlertmanagerAction.ViewNotificationTemplate);
|
||||||
@@ -220,6 +229,19 @@ export const ContactPointsPageContents = () => {
|
|||||||
alertmanager: selectedAlertmanager!,
|
alertmanager: selectedAlertmanager!,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// In V2 navigation mode, show only contact points (no internal tabs)
|
||||||
|
// Templates are accessible via the sidebar navigation
|
||||||
|
if (useV2Nav) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<GrafanaAlertmanagerWarning currentAlertmanager={selectedAlertmanager!} />
|
||||||
|
<ContactPointsTab />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy mode: Show internal tabs (backward compatible)
|
||||||
|
|
||||||
const showingContactPoints = activeTab === ActiveTab.ContactPoints;
|
const showingContactPoints = activeTab === ActiveTab.ContactPoints;
|
||||||
const showNotificationTemplates = activeTab === ActiveTab.NotificationTemplates;
|
const showNotificationTemplates = activeTab === ActiveTab.NotificationTemplates;
|
||||||
|
|
||||||
@@ -244,7 +266,7 @@ export const ContactPointsPageContents = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TabsBar>
|
</TabsBar>
|
||||||
<TabContent>
|
<TabContent className={styles.tabContent}>
|
||||||
<Stack direction="column">
|
<Stack direction="column">
|
||||||
{showingContactPoints && <ContactPointsTab />}
|
{showingContactPoints && <ContactPointsTab />}
|
||||||
{showNotificationTemplates && <NotificationTemplatesTab />}
|
{showNotificationTemplates && <NotificationTemplatesTab />}
|
||||||
@@ -281,9 +303,16 @@ const ContactPointsList = ({ contactPoints, search, pageSize = DEFAULT_PAGE_SIZE
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
|
tabContent: css({
|
||||||
|
marginTop: theme.spacing(2),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
function ContactPointsPage() {
|
function ContactPointsPage() {
|
||||||
|
const { navId, pageNav } = useNotificationConfigNav();
|
||||||
return (
|
return (
|
||||||
<AlertmanagerPageWrapper navId="receivers" accessType="notification">
|
<AlertmanagerPageWrapper navId={navId || 'receivers'} pageNav={pageNav} accessType="notification">
|
||||||
<ContactPointsPageContents />
|
<ContactPointsPageContents />
|
||||||
</AlertmanagerPageWrapper>
|
</AlertmanagerPageWrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
|
import { useInsightsNav } from '../../../navigation/useInsightsNav';
|
||||||
import { withPageErrorBoundary } from '../../../withPageErrorBoundary';
|
import { withPageErrorBoundary } from '../../../withPageErrorBoundary';
|
||||||
import { AlertingPageWrapper } from '../../AlertingPageWrapper';
|
import { AlertingPageWrapper } from '../../AlertingPageWrapper';
|
||||||
|
|
||||||
import { CentralAlertHistoryScene } from './CentralAlertHistoryScene';
|
import { CentralAlertHistoryScene } from './CentralAlertHistoryScene';
|
||||||
|
|
||||||
function HistoryPage() {
|
function HistoryPage() {
|
||||||
|
const { navId, pageNav } = useInsightsNav();
|
||||||
return (
|
return (
|
||||||
<AlertingPageWrapper navId="alerts-history" isLoading={false}>
|
<AlertingPageWrapper navId={navId || 'alerts-history'} pageNav={pageNav} isLoading={false}>
|
||||||
<CentralAlertHistoryScene />
|
<CentralAlertHistoryScene />
|
||||||
</AlertingPageWrapper>
|
</AlertingPageWrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Alert } from '@grafana/ui';
|
|||||||
|
|
||||||
import { alertRuleApi } from '../../../api/alertRuleApi';
|
import { alertRuleApi } from '../../../api/alertRuleApi';
|
||||||
import { GRAFANA_RULER_CONFIG } from '../../../api/featureDiscoveryApi';
|
import { GRAFANA_RULER_CONFIG } from '../../../api/featureDiscoveryApi';
|
||||||
|
import { useAlertRulesNav } from '../../../navigation/useAlertRulesNav';
|
||||||
import { stringifyErrorLike } from '../../../utils/misc';
|
import { stringifyErrorLike } from '../../../utils/misc';
|
||||||
import { withPageErrorBoundary } from '../../../withPageErrorBoundary';
|
import { withPageErrorBoundary } from '../../../withPageErrorBoundary';
|
||||||
import { AlertingPageWrapper } from '../../AlertingPageWrapper';
|
import { AlertingPageWrapper } from '../../AlertingPageWrapper';
|
||||||
@@ -18,9 +19,10 @@ function DeletedrulesPage() {
|
|||||||
rulerConfig: GRAFANA_RULER_CONFIG,
|
rulerConfig: GRAFANA_RULER_CONFIG,
|
||||||
filter: {}, // todo: add filters, and limit?????
|
filter: {}, // todo: add filters, and limit?????
|
||||||
});
|
});
|
||||||
|
const { navId, pageNav } = useAlertRulesNav();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertingPageWrapper navId="alerts/recently-deleted" isLoading={isLoading}>
|
<AlertingPageWrapper navId={navId || 'alerts/recently-deleted'} pageNav={pageNav} isLoading={isLoading}>
|
||||||
<>
|
<>
|
||||||
{error && (
|
{error && (
|
||||||
<Alert title={t('alerting.deleted-rules.errorloading', 'Failed to load alert deleted rules')}>
|
<Alert title={t('alerting.deleted-rules.errorloading', 'Failed to load alert deleted rules')}>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export const getFormFieldsForSilence = (silence: Silence): SilenceFormFields =>
|
|||||||
startsAt: interval.start.toISOString(),
|
startsAt: interval.start.toISOString(),
|
||||||
endsAt: interval.end.toISOString(),
|
endsAt: interval.end.toISOString(),
|
||||||
comment: silence.comment,
|
comment: silence.comment,
|
||||||
createdBy: silence.createdBy,
|
createdBy: isExpired ? contextSrv.user.name : silence.createdBy,
|
||||||
duration: intervalToAbbreviatedDurationString(interval),
|
duration: intervalToAbbreviatedDurationString(interval),
|
||||||
isRegex: false,
|
isRegex: false,
|
||||||
matchers: silence.matchers?.map(matcherToMatcherField) || [],
|
matchers: silence.matchers?.map(matcherToMatcherField) || [],
|
||||||
|
|||||||
@@ -31,3 +31,8 @@ export const shouldUseFullyCompatibleBackendFilters = () =>
|
|||||||
* Saved searches feature - allows users to save and apply search queries on the Alert Rules page.
|
* Saved searches feature - allows users to save and apply search queries on the Alert Rules page.
|
||||||
*/
|
*/
|
||||||
export const shouldUseSavedSearches = () => config.featureToggles.alertingSavedSearches ?? false;
|
export const shouldUseSavedSearches = () => config.featureToggles.alertingSavedSearches ?? false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* New grouped navigation structure for Alerting
|
||||||
|
*/
|
||||||
|
export const shouldUseAlertingNavigationV2 = () => config.featureToggles.alertingNavigationV2 ?? false;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { t } from '@grafana/i18n';
|
import { t } from '@grafana/i18n';
|
||||||
|
import { config } from '@grafana/runtime';
|
||||||
import { Box, Stack, Tab, TabContent, TabsBar } from '@grafana/ui';
|
import { Box, Stack, Tab, TabContent, TabsBar } from '@grafana/ui';
|
||||||
|
|
||||||
import { AlertingPageWrapper } from '../components/AlertingPageWrapper';
|
import { AlertingPageWrapper } from '../components/AlertingPageWrapper';
|
||||||
@@ -14,10 +15,13 @@ import { PluginIntegrations } from './PluginIntegrations';
|
|||||||
import SyntheticMonitoringCard from './SyntheticMonitoringCard';
|
import SyntheticMonitoringCard from './SyntheticMonitoringCard';
|
||||||
|
|
||||||
function Home() {
|
function Home() {
|
||||||
const insightsEnabled = insightsIsAvailable() || isLocalDevEnv();
|
// When V2 navigation is enabled, don't show Insights tab on Home page
|
||||||
|
// (Insights is available via the sidebar Insights menu instead)
|
||||||
|
const insightsEnabled = (insightsIsAvailable() || isLocalDevEnv()) && !config.featureToggles.alertingNavigationV2;
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<'insights' | 'overview'>(insightsEnabled ? 'insights' : 'overview');
|
const [activeTab, setActiveTab] = useState<'insights' | 'overview'>(insightsEnabled ? 'insights' : 'overview');
|
||||||
const insightsScene = getInsightsScenes();
|
// Memoize the scene so it's only created once and properly initialized
|
||||||
|
const insightsScene = useMemo(() => getInsightsScenes(), []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertingPageWrapper subTitle="Learn about problems in your systems moments after they occur" navId="alerting">
|
<AlertingPageWrapper subTitle="Learn about problems in your systems moments after they occur" navId="alerting">
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { Trans, t } from '@grafana/i18n';
|
||||||
|
|
||||||
|
import { AlertingPageWrapper } from '../components/AlertingPageWrapper';
|
||||||
|
import { getInsightsScenes, insightsIsAvailable } from '../home/Insights';
|
||||||
|
import { useInsightsNav } from '../navigation/useInsightsNav';
|
||||||
|
import { isLocalDevEnv } from '../utils/misc';
|
||||||
|
import { withPageErrorBoundary } from '../withPageErrorBoundary';
|
||||||
|
|
||||||
|
function InsightsPage() {
|
||||||
|
const insightsEnabled = insightsIsAvailable() || isLocalDevEnv();
|
||||||
|
const { navId, pageNav } = useInsightsNav();
|
||||||
|
// Memoize the scene so it's only created once and properly initialized
|
||||||
|
const insightsScene = useMemo(() => getInsightsScenes(), []);
|
||||||
|
|
||||||
|
if (!insightsEnabled) {
|
||||||
|
return (
|
||||||
|
<AlertingPageWrapper
|
||||||
|
navId={navId || 'insights'}
|
||||||
|
pageNav={pageNav}
|
||||||
|
subTitle={t('alerting.insights.subtitle', 'Analytics and history for alerting')}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Trans i18nKey="alerting.insights.not-available">
|
||||||
|
Insights are not available. Please configure the required data sources.
|
||||||
|
</Trans>
|
||||||
|
</div>
|
||||||
|
</AlertingPageWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertingPageWrapper
|
||||||
|
navId={navId || 'insights'}
|
||||||
|
pageNav={pageNav}
|
||||||
|
subTitle={t('alerting.insights.subtitle', 'Analytics and history for alerting')}
|
||||||
|
>
|
||||||
|
<insightsScene.Component model={insightsScene} />
|
||||||
|
</AlertingPageWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withPageErrorBoundary(InsightsPage);
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { renderHook } from '@testing-library/react';
|
||||||
|
import { getWrapper } from 'test/test-utils';
|
||||||
|
|
||||||
|
import { config } from '@grafana/runtime';
|
||||||
|
import { configureStore } from 'app/store/configureStore';
|
||||||
|
|
||||||
|
import { useAlertActivityNav } from './useAlertActivityNav';
|
||||||
|
|
||||||
|
describe('useAlertActivityNav', () => {
|
||||||
|
const mockNavIndex = {
|
||||||
|
'alert-activity': {
|
||||||
|
id: 'alert-activity',
|
||||||
|
text: 'Alert activity',
|
||||||
|
url: '/alerting/alerts',
|
||||||
|
},
|
||||||
|
'alert-activity-alerts': {
|
||||||
|
id: 'alert-activity-alerts',
|
||||||
|
text: 'Alerts',
|
||||||
|
url: '/alerting/alerts',
|
||||||
|
},
|
||||||
|
'alert-activity-groups': {
|
||||||
|
id: 'alert-activity-groups',
|
||||||
|
text: 'Active notifications',
|
||||||
|
url: '/alerting/groups',
|
||||||
|
},
|
||||||
|
groups: {
|
||||||
|
id: 'groups',
|
||||||
|
text: 'Alert groups',
|
||||||
|
url: '/alerting/groups',
|
||||||
|
},
|
||||||
|
'alert-alerts': {
|
||||||
|
id: 'alert-alerts',
|
||||||
|
text: 'Alerts',
|
||||||
|
url: '/alerting/alerts',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultPreloadedState = {
|
||||||
|
navIndex: mockNavIndex,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
config.featureToggles.alertingNavigationV2 = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return legacy navId when feature flag is off for /alerting/groups', () => {
|
||||||
|
const wrapper = getWrapper({
|
||||||
|
preloadedState: defaultPreloadedState,
|
||||||
|
renderWithRouter: true,
|
||||||
|
historyOptions: {
|
||||||
|
initialEntries: ['/alerting/groups'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAlertActivityNav(), { wrapper });
|
||||||
|
|
||||||
|
expect(result.current.navId).toBe('groups');
|
||||||
|
expect(result.current.pageNav).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return legacy navId when feature flag is off for /alerting/alerts', () => {
|
||||||
|
const wrapper = getWrapper({
|
||||||
|
preloadedState: defaultPreloadedState,
|
||||||
|
renderWithRouter: true,
|
||||||
|
historyOptions: {
|
||||||
|
initialEntries: ['/alerting/alerts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAlertActivityNav(), { wrapper });
|
||||||
|
|
||||||
|
expect(result.current.navId).toBe('alert-alerts');
|
||||||
|
expect(result.current.pageNav).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return V2 navigation when feature flag is on for Alerts tab', () => {
|
||||||
|
config.featureToggles.alertingNavigationV2 = true;
|
||||||
|
const store = configureStore(defaultPreloadedState);
|
||||||
|
const wrapper = getWrapper({
|
||||||
|
store,
|
||||||
|
renderWithRouter: true,
|
||||||
|
historyOptions: {
|
||||||
|
initialEntries: ['/alerting/alerts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAlertActivityNav(), { wrapper });
|
||||||
|
|
||||||
|
expect(result.current.navId).toBe('alert-activity');
|
||||||
|
expect(result.current.pageNav).toBeDefined();
|
||||||
|
// eslint-disable-next-line testing-library/no-node-access
|
||||||
|
expect(result.current.pageNav?.children).toBeDefined();
|
||||||
|
// The pageNav should represent Alert Activity (not the active tab) for consistent title
|
||||||
|
expect(result.current.pageNav?.text).toBe('Alert activity');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return V2 navigation when feature flag is on for Active notifications tab', () => {
|
||||||
|
config.featureToggles.alertingNavigationV2 = true;
|
||||||
|
const store = configureStore(defaultPreloadedState);
|
||||||
|
const wrapper = getWrapper({
|
||||||
|
store,
|
||||||
|
renderWithRouter: true,
|
||||||
|
historyOptions: {
|
||||||
|
initialEntries: ['/alerting/groups'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAlertActivityNav(), { wrapper });
|
||||||
|
|
||||||
|
expect(result.current.navId).toBe('alert-activity');
|
||||||
|
expect(result.current.pageNav).toBeDefined();
|
||||||
|
// eslint-disable-next-line testing-library/no-node-access
|
||||||
|
expect(result.current.pageNav?.children).toBeDefined();
|
||||||
|
// The pageNav should represent Alert Activity (not the active tab) for consistent title
|
||||||
|
expect(result.current.pageNav?.text).toBe('Alert activity');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set active tab based on current path', () => {
|
||||||
|
config.featureToggles.alertingNavigationV2 = true;
|
||||||
|
const store = configureStore(defaultPreloadedState);
|
||||||
|
const wrapper = getWrapper({
|
||||||
|
store,
|
||||||
|
renderWithRouter: true,
|
||||||
|
historyOptions: {
|
||||||
|
initialEntries: ['/alerting/groups'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAlertActivityNav(), { wrapper });
|
||||||
|
|
||||||
|
// eslint-disable-next-line testing-library/no-node-access
|
||||||
|
const activeNotificationsTab = result.current.pageNav?.children?.find((tab) => tab.id === 'alert-activity-groups');
|
||||||
|
expect(activeNotificationsTab?.active).toBe(true);
|
||||||
|
|
||||||
|
// eslint-disable-next-line testing-library/no-node-access
|
||||||
|
const alertsTab = result.current.pageNav?.children?.find((tab) => tab.id === 'alert-activity-alerts');
|
||||||
|
expect(alertsTab?.active).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter tabs based on permissions', () => {
|
||||||
|
config.featureToggles.alertingNavigationV2 = true;
|
||||||
|
const limitedNavIndex = {
|
||||||
|
'alert-activity': mockNavIndex['alert-activity'],
|
||||||
|
'alert-activity-alerts': mockNavIndex['alert-activity-alerts'],
|
||||||
|
// Missing 'alert-activity-groups' - user doesn't have permission
|
||||||
|
};
|
||||||
|
const store = configureStore({
|
||||||
|
navIndex: limitedNavIndex,
|
||||||
|
});
|
||||||
|
const wrapper = getWrapper({
|
||||||
|
store,
|
||||||
|
renderWithRouter: true,
|
||||||
|
historyOptions: {
|
||||||
|
initialEntries: ['/alerting/alerts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAlertActivityNav(), { wrapper });
|
||||||
|
|
||||||
|
// eslint-disable-next-line testing-library/no-node-access
|
||||||
|
expect(result.current.pageNav?.children?.length).toBe(1);
|
||||||
|
// eslint-disable-next-line testing-library/no-node-access
|
||||||
|
expect(result.current.pageNav?.children?.[0].id).toBe('alert-activity-alerts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fallback to legacy when alert-activity nav is missing', () => {
|
||||||
|
config.featureToggles.alertingNavigationV2 = true;
|
||||||
|
const store = configureStore({
|
||||||
|
navIndex: {
|
||||||
|
groups: mockNavIndex.groups,
|
||||||
|
'alert-alerts': mockNavIndex['alert-alerts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const wrapper = getWrapper({
|
||||||
|
store,
|
||||||
|
renderWithRouter: true,
|
||||||
|
historyOptions: {
|
||||||
|
initialEntries: ['/alerting/groups'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAlertActivityNav(), { wrapper });
|
||||||
|
|
||||||
|
expect(result.current.navId).toBe('groups');
|
||||||
|
expect(result.current.pageNav).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { useLocation } from 'react-router-dom-v5-compat';
|
||||||
|
|
||||||
|
import { NavModelItem } from '@grafana/data';
|
||||||
|
import { t } from '@grafana/i18n';
|
||||||
|
import { useSelector } from 'app/types/store';
|
||||||
|
|
||||||
|
import { shouldUseAlertingNavigationV2 } from '../featureToggles';
|
||||||
|
|
||||||
|
export function useAlertActivityNav() {
|
||||||
|
const location = useLocation();
|
||||||
|
const navIndex = useSelector((state) => state.navIndex);
|
||||||
|
const useV2Nav = shouldUseAlertingNavigationV2();
|
||||||
|
|
||||||
|
// If V2 navigation is not enabled, return legacy navId
|
||||||
|
if (!useV2Nav) {
|
||||||
|
if (location.pathname === '/alerting/groups') {
|
||||||
|
return {
|
||||||
|
navId: 'groups',
|
||||||
|
pageNav: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (location.pathname === '/alerting/alerts') {
|
||||||
|
return {
|
||||||
|
navId: 'alert-alerts',
|
||||||
|
pageNav: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
navId: undefined,
|
||||||
|
pageNav: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const alertActivityNav = navIndex['alert-activity'];
|
||||||
|
if (!alertActivityNav) {
|
||||||
|
// Fallback to legacy
|
||||||
|
if (location.pathname === '/alerting/groups') {
|
||||||
|
return {
|
||||||
|
navId: 'groups',
|
||||||
|
pageNav: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (location.pathname === '/alerting/alerts') {
|
||||||
|
return {
|
||||||
|
navId: 'alert-alerts',
|
||||||
|
pageNav: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
navId: undefined,
|
||||||
|
pageNav: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// All available tabs
|
||||||
|
const allTabs = [
|
||||||
|
{
|
||||||
|
id: 'alert-activity-alerts',
|
||||||
|
text: t('alerting.navigation.alerts', 'Alerts'),
|
||||||
|
url: '/alerting/alerts',
|
||||||
|
active: location.pathname === '/alerting/alerts',
|
||||||
|
icon: 'bell',
|
||||||
|
parentItem: alertActivityNav,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'alert-activity-groups',
|
||||||
|
text: t('alerting.navigation.active-notifications', 'Active notifications'),
|
||||||
|
url: '/alerting/groups',
|
||||||
|
active: location.pathname === '/alerting/groups',
|
||||||
|
icon: 'layer-group',
|
||||||
|
parentItem: alertActivityNav,
|
||||||
|
},
|
||||||
|
].filter((tab) => {
|
||||||
|
// Filter based on permissions - if nav item doesn't exist, user doesn't have permission
|
||||||
|
const navItem = navIndex[tab.id];
|
||||||
|
return navItem !== undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create pageNav structure following the same pattern as useNotificationConfigNav
|
||||||
|
// Keep "Alert Activity" as the pageNav (not the active tab) so the title and subtitle stay consistent
|
||||||
|
// The tabs are children, and the breadcrumb utility will add the active tab to breadcrumbs
|
||||||
|
// (including the first tab, after our fix to the breadcrumb utility)
|
||||||
|
const pageNav: NavModelItem = {
|
||||||
|
...alertActivityNav,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
children: allTabs as NavModelItem[],
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
navId: 'alert-activity',
|
||||||
|
pageNav,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { renderHook } from '@testing-library/react';
|
||||||
|
import { getWrapper } from 'test/test-utils';
|
||||||
|
|
||||||
|
import { config } from '@grafana/runtime';
|
||||||
|
import { configureStore } from 'app/store/configureStore';
|
||||||
|
|
||||||
|
import { useAlertRulesNav } from './useAlertRulesNav';
|
||||||
|
|
||||||
|
describe('useAlertRulesNav', () => {
|
||||||
|
const mockNavIndex = {
|
||||||
|
'alert-rules': {
|
||||||
|
id: 'alert-rules',
|
||||||
|
text: 'Alert rules',
|
||||||
|
url: '/alerting/list',
|
||||||
|
icon: 'list-ul',
|
||||||
|
},
|
||||||
|
'alert-rules-list': {
|
||||||
|
id: 'alert-rules-list',
|
||||||
|
text: 'Alert rules',
|
||||||
|
url: '/alerting/list',
|
||||||
|
},
|
||||||
|
'alert-rules-recently-deleted': {
|
||||||
|
id: 'alert-rules-recently-deleted',
|
||||||
|
text: 'Recently deleted',
|
||||||
|
url: '/alerting/recently-deleted',
|
||||||
|
},
|
||||||
|
'alert-list': {
|
||||||
|
id: 'alert-list',
|
||||||
|
text: 'Alert rules',
|
||||||
|
url: '/alerting/list',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultPreloadedState = {
|
||||||
|
navIndex: mockNavIndex,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
config.featureToggles.alertingNavigationV2 = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return legacy navId when feature flag is off', () => {
|
||||||
|
const wrapper = getWrapper({
|
||||||
|
preloadedState: defaultPreloadedState,
|
||||||
|
renderWithRouter: true,
|
||||||
|
historyOptions: {
|
||||||
|
initialEntries: ['/alerting/list'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAlertRulesNav(), { wrapper });
|
||||||
|
|
||||||
|
expect(result.current.navId).toBe('alert-list');
|
||||||
|
expect(result.current.pageNav).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return V2 navigation when feature flag is on', () => {
|
||||||
|
config.featureToggles.alertingNavigationV2 = true;
|
||||||
|
const store = configureStore(defaultPreloadedState);
|
||||||
|
const wrapper = getWrapper({
|
||||||
|
store,
|
||||||
|
renderWithRouter: true,
|
||||||
|
historyOptions: {
|
||||||
|
initialEntries: ['/alerting/list'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAlertRulesNav(), { wrapper });
|
||||||
|
|
||||||
|
expect(result.current.navId).toBe('alert-rules');
|
||||||
|
expect(result.current.pageNav).toBeDefined();
|
||||||
|
// eslint-disable-next-line testing-library/no-node-access
|
||||||
|
expect(result.current.pageNav?.children).toBeDefined();
|
||||||
|
// eslint-disable-next-line testing-library/no-node-access
|
||||||
|
expect(result.current.pageNav?.children?.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter tabs based on permissions', () => {
|
||||||
|
config.featureToggles.alertingNavigationV2 = true;
|
||||||
|
const limitedNavIndex = {
|
||||||
|
'alert-rules': mockNavIndex['alert-rules'],
|
||||||
|
'alert-rules-list': mockNavIndex['alert-rules-list'],
|
||||||
|
// Missing 'alert-rules-recently-deleted' - user doesn't have permission
|
||||||
|
};
|
||||||
|
const store = configureStore({
|
||||||
|
navIndex: limitedNavIndex,
|
||||||
|
});
|
||||||
|
const wrapper = getWrapper({
|
||||||
|
store,
|
||||||
|
renderWithRouter: true,
|
||||||
|
historyOptions: {
|
||||||
|
initialEntries: ['/alerting/list'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAlertRulesNav(), { wrapper });
|
||||||
|
|
||||||
|
// eslint-disable-next-line testing-library/no-node-access
|
||||||
|
expect(result.current.pageNav?.children?.length).toBe(1);
|
||||||
|
// eslint-disable-next-line testing-library/no-node-access
|
||||||
|
expect(result.current.pageNav?.children?.[0].id).toBe('alert-rules-list');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set active tab based on current path', () => {
|
||||||
|
config.featureToggles.alertingNavigationV2 = true;
|
||||||
|
const store = configureStore(defaultPreloadedState);
|
||||||
|
const wrapper = getWrapper({
|
||||||
|
store,
|
||||||
|
renderWithRouter: true,
|
||||||
|
historyOptions: {
|
||||||
|
initialEntries: ['/alerting/recently-deleted'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAlertRulesNav(), { wrapper });
|
||||||
|
|
||||||
|
// eslint-disable-next-line testing-library/no-node-access
|
||||||
|
const recentlyDeletedTab = result.current.pageNav?.children?.find(
|
||||||
|
(tab) => tab.id === 'alert-rules-recently-deleted'
|
||||||
|
);
|
||||||
|
expect(recentlyDeletedTab?.active).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { useLocation } from 'react-router-dom-v5-compat';
|
||||||
|
|
||||||
|
import { NavModelItem } from '@grafana/data';
|
||||||
|
import { t } from '@grafana/i18n';
|
||||||
|
import { useSelector } from 'app/types/store';
|
||||||
|
|
||||||
|
import { shouldUseAlertingNavigationV2 } from '../featureToggles';
|
||||||
|
|
||||||
|
export function useAlertRulesNav() {
|
||||||
|
const location = useLocation();
|
||||||
|
const navIndex = useSelector((state) => state.navIndex);
|
||||||
|
const useV2Nav = shouldUseAlertingNavigationV2();
|
||||||
|
|
||||||
|
// If V2 navigation is not enabled, return legacy navId
|
||||||
|
if (!useV2Nav) {
|
||||||
|
return {
|
||||||
|
navId: 'alert-list',
|
||||||
|
pageNav: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const alertRulesNav = navIndex['alert-rules'];
|
||||||
|
if (!alertRulesNav) {
|
||||||
|
// Fallback to legacy if V2 nav doesn't exist
|
||||||
|
return {
|
||||||
|
navId: 'alert-list',
|
||||||
|
pageNav: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// All available tabs
|
||||||
|
const allTabs = [
|
||||||
|
{
|
||||||
|
id: 'alert-rules-list',
|
||||||
|
text: t('alerting.navigation.alert-rules', 'Alert rules'),
|
||||||
|
url: '/alerting/list',
|
||||||
|
active: location.pathname === '/alerting/list',
|
||||||
|
icon: 'list-ul',
|
||||||
|
parentItem: alertRulesNav,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'alert-rules-recently-deleted',
|
||||||
|
text: t('alerting.navigation.recently-deleted', 'Recently deleted'),
|
||||||
|
url: '/alerting/recently-deleted',
|
||||||
|
active: location.pathname === '/alerting/recently-deleted',
|
||||||
|
icon: 'trash-alt',
|
||||||
|
parentItem: alertRulesNav,
|
||||||
|
},
|
||||||
|
].filter((tab) => {
|
||||||
|
// Filter based on permissions - if nav item doesn't exist, user doesn't have permission
|
||||||
|
const navItem = navIndex[tab.id];
|
||||||
|
return navItem !== undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create pageNav that represents the Alert rules page with tabs as children
|
||||||
|
const pageNav: NavModelItem = {
|
||||||
|
...alertRulesNav,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
children: allTabs as NavModelItem[],
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
navId: 'alert-rules',
|
||||||
|
pageNav,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { renderHook } from '@testing-library/react';
|
||||||
|
import { getWrapper } from 'test/test-utils';
|
||||||
|
|
||||||
|
import { config } from '@grafana/runtime';
|
||||||
|
import { configureStore } from 'app/store/configureStore';
|
||||||
|
|
||||||
|
import { useInsightsNav } from './useInsightsNav';
|
||||||
|
|
||||||
|
describe('useInsightsNav', () => {
|
||||||
|
const mockNavIndex = {
|
||||||
|
insights: {
|
||||||
|
id: 'insights',
|
||||||
|
text: 'Insights',
|
||||||
|
url: '/alerting/insights',
|
||||||
|
},
|
||||||
|
'insights-system': {
|
||||||
|
id: 'insights-system',
|
||||||
|
text: 'System Insights',
|
||||||
|
url: '/alerting/insights',
|
||||||
|
},
|
||||||
|
'insights-history': {
|
||||||
|
id: 'insights-history',
|
||||||
|
text: 'Alert state history',
|
||||||
|
url: '/alerting/history',
|
||||||
|
},
|
||||||
|
'alerts-history': {
|
||||||
|
id: 'alerts-history',
|
||||||
|
text: 'History',
|
||||||
|
url: '/alerting/history',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultPreloadedState = {
|
||||||
|
navIndex: mockNavIndex,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
config.featureToggles.alertingNavigationV2 = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return legacy navId when feature flag is off', () => {
|
||||||
|
const wrapper = getWrapper({
|
||||||
|
preloadedState: defaultPreloadedState,
|
||||||
|
renderWithRouter: true,
|
||||||
|
historyOptions: {
|
||||||
|
initialEntries: ['/alerting/history'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useInsightsNav(), { wrapper });
|
||||||
|
|
||||||
|
expect(result.current.navId).toBe('alerts-history');
|
||||||
|
expect(result.current.pageNav).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return V2 navigation when feature flag is on', () => {
|
||||||
|
config.featureToggles.alertingNavigationV2 = true;
|
||||||
|
const store = configureStore(defaultPreloadedState);
|
||||||
|
const wrapper = getWrapper({
|
||||||
|
store,
|
||||||
|
renderWithRouter: true,
|
||||||
|
historyOptions: {
|
||||||
|
initialEntries: ['/alerting/insights'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useInsightsNav(), { wrapper });
|
||||||
|
|
||||||
|
expect(result.current.navId).toBe('insights');
|
||||||
|
expect(result.current.pageNav).toBeDefined();
|
||||||
|
// eslint-disable-next-line testing-library/no-node-access
|
||||||
|
expect(result.current.pageNav?.children).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set active tab based on current path', () => {
|
||||||
|
config.featureToggles.alertingNavigationV2 = true;
|
||||||
|
const store = configureStore(defaultPreloadedState);
|
||||||
|
const wrapper = getWrapper({
|
||||||
|
store,
|
||||||
|
renderWithRouter: true,
|
||||||
|
historyOptions: {
|
||||||
|
initialEntries: ['/alerting/history'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useInsightsNav(), { wrapper });
|
||||||
|
|
||||||
|
// eslint-disable-next-line testing-library/no-node-access
|
||||||
|
const historyTab = result.current.pageNav?.children?.find((tab) => tab.id === 'insights-history');
|
||||||
|
expect(historyTab?.active).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter tabs based on permissions', () => {
|
||||||
|
config.featureToggles.alertingNavigationV2 = true;
|
||||||
|
const limitedNavIndex = {
|
||||||
|
insights: mockNavIndex.insights,
|
||||||
|
'insights-system': mockNavIndex['insights-system'],
|
||||||
|
// Missing 'insights-history' - user doesn't have permission
|
||||||
|
};
|
||||||
|
const store = configureStore({
|
||||||
|
navIndex: limitedNavIndex,
|
||||||
|
});
|
||||||
|
const wrapper = getWrapper({
|
||||||
|
store,
|
||||||
|
renderWithRouter: true,
|
||||||
|
historyOptions: {
|
||||||
|
initialEntries: ['/alerting/insights'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useInsightsNav(), { wrapper });
|
||||||
|
|
||||||
|
// eslint-disable-next-line testing-library/no-node-access
|
||||||
|
expect(result.current.pageNav?.children?.length).toBe(1);
|
||||||
|
// eslint-disable-next-line testing-library/no-node-access
|
||||||
|
expect(result.current.pageNav?.children?.[0].id).toBe('insights-system');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { useLocation } from 'react-router-dom-v5-compat';
|
||||||
|
|
||||||
|
import { NavModelItem } from '@grafana/data';
|
||||||
|
import { t } from '@grafana/i18n';
|
||||||
|
import { useSelector } from 'app/types/store';
|
||||||
|
|
||||||
|
import { shouldUseAlertingNavigationV2 } from '../featureToggles';
|
||||||
|
|
||||||
|
export function useInsightsNav() {
|
||||||
|
const location = useLocation();
|
||||||
|
const navIndex = useSelector((state) => state.navIndex);
|
||||||
|
const useV2Nav = shouldUseAlertingNavigationV2();
|
||||||
|
|
||||||
|
// If V2 navigation is not enabled, return legacy navId
|
||||||
|
if (!useV2Nav) {
|
||||||
|
if (location.pathname === '/alerting/history') {
|
||||||
|
return {
|
||||||
|
navId: 'alerts-history',
|
||||||
|
pageNav: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// For insights page, it doesn't exist in legacy, so return undefined
|
||||||
|
return {
|
||||||
|
navId: undefined,
|
||||||
|
pageNav: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const insightsNav = navIndex.insights;
|
||||||
|
if (!insightsNav) {
|
||||||
|
// Fallback to legacy
|
||||||
|
if (location.pathname === '/alerting/history') {
|
||||||
|
return {
|
||||||
|
navId: 'alerts-history',
|
||||||
|
pageNav: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
navId: undefined,
|
||||||
|
pageNav: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// All available tabs
|
||||||
|
const allTabs = [
|
||||||
|
{
|
||||||
|
id: 'insights-system',
|
||||||
|
text: t('alerting.navigation.system-insights', 'System Insights'),
|
||||||
|
url: '/alerting/insights',
|
||||||
|
active: location.pathname === '/alerting/insights',
|
||||||
|
icon: 'chart-line',
|
||||||
|
parentItem: insightsNav,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'insights-history',
|
||||||
|
text: t('alerting.navigation.alert-state-history', 'Alert state history'),
|
||||||
|
url: '/alerting/history',
|
||||||
|
active: location.pathname === '/alerting/history',
|
||||||
|
icon: 'history',
|
||||||
|
parentItem: insightsNav,
|
||||||
|
},
|
||||||
|
].filter((tab) => {
|
||||||
|
// Filter based on permissions - if nav item doesn't exist, user doesn't have permission
|
||||||
|
const navItem = navIndex[tab.id];
|
||||||
|
return navItem !== undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create pageNav that represents the Insights page with tabs as children
|
||||||
|
const pageNav: NavModelItem = {
|
||||||
|
...insightsNav,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
children: allTabs as NavModelItem[],
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
navId: 'insights',
|
||||||
|
pageNav,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { renderHook } from '@testing-library/react';
|
||||||
|
import { getWrapper } from 'test/test-utils';
|
||||||
|
|
||||||
|
import { config } from '@grafana/runtime';
|
||||||
|
import { configureStore } from 'app/store/configureStore';
|
||||||
|
|
||||||
|
import { useNotificationConfigNav } from './useNotificationConfigNav';
|
||||||
|
|
||||||
|
describe('useNotificationConfigNav', () => {
|
||||||
|
const mockNavIndex = {
|
||||||
|
'notification-config': {
|
||||||
|
id: 'notification-config',
|
||||||
|
text: 'Notification configuration',
|
||||||
|
url: '/alerting/notifications',
|
||||||
|
},
|
||||||
|
'notification-config-contact-points': {
|
||||||
|
id: 'notification-config-contact-points',
|
||||||
|
text: 'Contact points',
|
||||||
|
url: '/alerting/notifications',
|
||||||
|
},
|
||||||
|
'notification-config-policies': {
|
||||||
|
id: 'notification-config-policies',
|
||||||
|
text: 'Notification policies',
|
||||||
|
url: '/alerting/routes',
|
||||||
|
},
|
||||||
|
'notification-config-templates': {
|
||||||
|
id: 'notification-config-templates',
|
||||||
|
text: 'Notification templates',
|
||||||
|
url: '/alerting/notifications/templates',
|
||||||
|
},
|
||||||
|
'notification-config-time-intervals': {
|
||||||
|
id: 'notification-config-time-intervals',
|
||||||
|
text: 'Time intervals',
|
||||||
|
url: '/alerting/routes?tab=time_intervals',
|
||||||
|
},
|
||||||
|
receivers: {
|
||||||
|
id: 'receivers',
|
||||||
|
text: 'Contact points',
|
||||||
|
url: '/alerting/notifications',
|
||||||
|
},
|
||||||
|
'am-routes': {
|
||||||
|
id: 'am-routes',
|
||||||
|
text: 'Notification policies',
|
||||||
|
url: '/alerting/routes',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultPreloadedState = {
|
||||||
|
navIndex: mockNavIndex,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
config.featureToggles.alertingNavigationV2 = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return legacy navId when feature flag is off', () => {
|
||||||
|
const wrapper = getWrapper({
|
||||||
|
preloadedState: defaultPreloadedState,
|
||||||
|
renderWithRouter: true,
|
||||||
|
historyOptions: {
|
||||||
|
initialEntries: ['/alerting/notifications'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useNotificationConfigNav(), { wrapper });
|
||||||
|
|
||||||
|
expect(result.current.navId).toBe('receivers');
|
||||||
|
expect(result.current.pageNav).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return V2 navigation when feature flag is on', () => {
|
||||||
|
config.featureToggles.alertingNavigationV2 = true;
|
||||||
|
const store = configureStore(defaultPreloadedState);
|
||||||
|
const wrapper = getWrapper({
|
||||||
|
store,
|
||||||
|
renderWithRouter: true,
|
||||||
|
historyOptions: {
|
||||||
|
initialEntries: ['/alerting/notifications'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useNotificationConfigNav(), { wrapper });
|
||||||
|
|
||||||
|
expect(result.current.navId).toBe('notification-config');
|
||||||
|
expect(result.current.pageNav).toBeDefined();
|
||||||
|
// eslint-disable-next-line testing-library/no-node-access
|
||||||
|
expect(result.current.pageNav?.children).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect time intervals tab from V2 path', () => {
|
||||||
|
config.featureToggles.alertingNavigationV2 = true;
|
||||||
|
const store = configureStore(defaultPreloadedState);
|
||||||
|
const wrapper = getWrapper({
|
||||||
|
store,
|
||||||
|
renderWithRouter: true,
|
||||||
|
historyOptions: {
|
||||||
|
initialEntries: ['/alerting/time-intervals'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useNotificationConfigNav(), { wrapper });
|
||||||
|
|
||||||
|
// eslint-disable-next-line testing-library/no-node-access
|
||||||
|
const timeIntervalsTab = result.current.pageNav?.children?.find(
|
||||||
|
(tab) => tab.id === 'notification-config-time-intervals'
|
||||||
|
);
|
||||||
|
expect(timeIntervalsTab?.active).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter tabs based on permissions', () => {
|
||||||
|
config.featureToggles.alertingNavigationV2 = true;
|
||||||
|
const limitedNavIndex = {
|
||||||
|
'notification-config': mockNavIndex['notification-config'],
|
||||||
|
'notification-config-contact-points': mockNavIndex['notification-config-contact-points'],
|
||||||
|
// Missing other tabs - user doesn't have permission
|
||||||
|
};
|
||||||
|
const store = configureStore({
|
||||||
|
navIndex: limitedNavIndex,
|
||||||
|
});
|
||||||
|
const wrapper = getWrapper({
|
||||||
|
store,
|
||||||
|
renderWithRouter: true,
|
||||||
|
historyOptions: {
|
||||||
|
initialEntries: ['/alerting/notifications'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useNotificationConfigNav(), { wrapper });
|
||||||
|
|
||||||
|
// eslint-disable-next-line testing-library/no-node-access
|
||||||
|
expect(result.current.pageNav?.children?.length).toBe(1);
|
||||||
|
// eslint-disable-next-line testing-library/no-node-access
|
||||||
|
expect(result.current.pageNav?.children?.[0].id).toBe('notification-config-contact-points');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { useLocation } from 'react-router-dom-v5-compat';
|
||||||
|
|
||||||
|
import { NavModelItem } from '@grafana/data';
|
||||||
|
import { t } from '@grafana/i18n';
|
||||||
|
import { useSelector } from 'app/types/store';
|
||||||
|
|
||||||
|
import { shouldUseAlertingNavigationV2 } from '../featureToggles';
|
||||||
|
|
||||||
|
export function useNotificationConfigNav() {
|
||||||
|
const location = useLocation();
|
||||||
|
const navIndex = useSelector((state) => state.navIndex);
|
||||||
|
const useV2Nav = shouldUseAlertingNavigationV2();
|
||||||
|
|
||||||
|
// If V2 navigation is not enabled, return legacy navId based on current path
|
||||||
|
if (!useV2Nav) {
|
||||||
|
if (location.pathname.includes('/alerting/notifications/templates')) {
|
||||||
|
return {
|
||||||
|
navId: 'receivers',
|
||||||
|
pageNav: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (location.pathname === '/alerting/routes') {
|
||||||
|
return {
|
||||||
|
navId: 'am-routes',
|
||||||
|
pageNav: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
navId: 'receivers',
|
||||||
|
pageNav: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const notificationConfigNav = navIndex['notification-config'];
|
||||||
|
if (!notificationConfigNav) {
|
||||||
|
// Fallback to legacy navIds
|
||||||
|
if (location.pathname.includes('/alerting/notifications/templates')) {
|
||||||
|
return {
|
||||||
|
navId: 'receivers',
|
||||||
|
pageNav: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (location.pathname === '/alerting/routes') {
|
||||||
|
return {
|
||||||
|
navId: 'am-routes',
|
||||||
|
pageNav: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
navId: 'receivers',
|
||||||
|
pageNav: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're on the time intervals page
|
||||||
|
// In V2 mode, check for dedicated route; in legacy mode, check for query param
|
||||||
|
const isTimeIntervalsTab = useV2Nav
|
||||||
|
? location.pathname === '/alerting/time-intervals'
|
||||||
|
: location.pathname === '/alerting/routes' && location.search.includes('tab=time_intervals');
|
||||||
|
|
||||||
|
// All available tabs
|
||||||
|
const allTabs = [
|
||||||
|
{
|
||||||
|
id: 'notification-config-contact-points',
|
||||||
|
text: t('alerting.navigation.contact-points', 'Contact points'),
|
||||||
|
url: '/alerting/notifications',
|
||||||
|
active: location.pathname === '/alerting/notifications' && !location.pathname.includes('/templates'),
|
||||||
|
icon: 'comment-alt-share',
|
||||||
|
parentItem: notificationConfigNav,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'notification-config-policies',
|
||||||
|
text: t('alerting.navigation.notification-policies', 'Notification policies'),
|
||||||
|
url: '/alerting/routes',
|
||||||
|
active: location.pathname === '/alerting/routes' && !isTimeIntervalsTab,
|
||||||
|
icon: 'sitemap',
|
||||||
|
parentItem: notificationConfigNav,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'notification-config-templates',
|
||||||
|
text: t('alerting.navigation.notification-templates', 'Notification templates'),
|
||||||
|
url: '/alerting/notifications/templates',
|
||||||
|
active: location.pathname.includes('/alerting/notifications/templates'),
|
||||||
|
icon: 'file-alt',
|
||||||
|
parentItem: notificationConfigNav,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'notification-config-time-intervals',
|
||||||
|
text: t('alerting.navigation.time-intervals', 'Time intervals'),
|
||||||
|
url: useV2Nav ? '/alerting/time-intervals' : '/alerting/routes?tab=time_intervals',
|
||||||
|
active: isTimeIntervalsTab,
|
||||||
|
icon: 'clock-nine',
|
||||||
|
parentItem: notificationConfigNav,
|
||||||
|
},
|
||||||
|
].filter((tab) => {
|
||||||
|
// Filter based on permissions - if nav item doesn't exist, user doesn't have permission
|
||||||
|
const navItem = navIndex[tab.id];
|
||||||
|
return navItem !== undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create pageNav that represents the Notification configuration page with tabs as children
|
||||||
|
const pageNav: NavModelItem = {
|
||||||
|
...notificationConfigNav,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
children: allTabs as NavModelItem[],
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
navId: 'notification-config',
|
||||||
|
pageNav,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import { shouldUsePrometheusRulesPrimary } from '../featureToggles';
|
|||||||
import { useCombinedRuleNamespaces } from '../hooks/useCombinedRuleNamespaces';
|
import { useCombinedRuleNamespaces } from '../hooks/useCombinedRuleNamespaces';
|
||||||
import { useFilteredRules, useRulesFilter } from '../hooks/useFilteredRules';
|
import { useFilteredRules, useRulesFilter } from '../hooks/useFilteredRules';
|
||||||
import { useUnifiedAlertingSelector } from '../hooks/useUnifiedAlertingSelector';
|
import { useUnifiedAlertingSelector } from '../hooks/useUnifiedAlertingSelector';
|
||||||
|
import { useAlertRulesNav } from '../navigation/useAlertRulesNav';
|
||||||
import { fetchAllPromAndRulerRulesAction, fetchAllPromRulesAction, fetchRulerRulesAction } from '../state/actions';
|
import { fetchAllPromAndRulerRulesAction, fetchAllPromRulesAction, fetchRulerRulesAction } from '../state/actions';
|
||||||
import { RULE_LIST_POLL_INTERVAL_MS } from '../utils/constants';
|
import { RULE_LIST_POLL_INTERVAL_MS } from '../utils/constants';
|
||||||
import { GRAFANA_RULES_SOURCE_NAME, getAllRulesSourceNames } from '../utils/datasource';
|
import { GRAFANA_RULES_SOURCE_NAME, getAllRulesSourceNames } from '../utils/datasource';
|
||||||
@@ -115,11 +116,14 @@ const RuleListV1 = () => {
|
|||||||
|
|
||||||
const combinedNamespaces: CombinedRuleNamespace[] = useCombinedRuleNamespaces();
|
const combinedNamespaces: CombinedRuleNamespace[] = useCombinedRuleNamespaces();
|
||||||
const filteredNamespaces = useFilteredRules(combinedNamespaces, filterState);
|
const filteredNamespaces = useFilteredRules(combinedNamespaces, filterState);
|
||||||
|
const { navId, pageNav } = useAlertRulesNav();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// We don't want to show the Loading... indicator for the whole page.
|
// We don't want to show the Loading... indicator for the whole page.
|
||||||
// We show separate indicators for Grafana-managed and Cloud rules
|
// We show separate indicators for Grafana-managed and Cloud rules
|
||||||
<AlertingPageWrapper
|
<AlertingPageWrapper
|
||||||
navId="alert-list"
|
navId={navId}
|
||||||
|
pageNav={pageNav}
|
||||||
isLoading={false}
|
isLoading={false}
|
||||||
renderTitle={(title) => <RuleListPageTitle title={title} />}
|
renderTitle={(title) => <RuleListPageTitle title={title} />}
|
||||||
actions={<RuleListActionButtons hasAlertRulesCreated={hasAlertRulesCreated} />}
|
actions={<RuleListActionButtons hasAlertRulesCreated={hasAlertRulesCreated} />}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useListViewMode } from '../components/rules/Filter/RulesViewModeSelecto
|
|||||||
import { AIAlertRuleButtonComponent } from '../enterprise-components/AI/AIGenAlertRuleButton/addAIAlertRuleButton';
|
import { AIAlertRuleButtonComponent } from '../enterprise-components/AI/AIGenAlertRuleButton/addAIAlertRuleButton';
|
||||||
import { AlertingAction, useAlertingAbility } from '../hooks/useAbilities';
|
import { AlertingAction, useAlertingAbility } from '../hooks/useAbilities';
|
||||||
import { useRulesFilter } from '../hooks/useFilteredRules';
|
import { useRulesFilter } from '../hooks/useFilteredRules';
|
||||||
|
import { useAlertRulesNav } from '../navigation/useAlertRulesNav';
|
||||||
|
|
||||||
import { FilterView } from './FilterView';
|
import { FilterView } from './FilterView';
|
||||||
import { GroupedView } from './GroupedView';
|
import { GroupedView } from './GroupedView';
|
||||||
@@ -119,10 +120,12 @@ export function RuleListActions() {
|
|||||||
|
|
||||||
export default function RuleListPage() {
|
export default function RuleListPage() {
|
||||||
const { isApplying } = useApplyDefaultSearch();
|
const { isApplying } = useApplyDefaultSearch();
|
||||||
|
const { navId, pageNav } = useAlertRulesNav();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertingPageWrapper
|
<AlertingPageWrapper
|
||||||
navId="alert-list"
|
navId={navId}
|
||||||
|
pageNav={pageNav}
|
||||||
renderTitle={(title) => <RuleListPageTitle title={title} />}
|
renderTitle={(title) => <RuleListPageTitle title={title} />}
|
||||||
isLoading={isApplying}
|
isLoading={isApplying}
|
||||||
actions={<RuleListActions />}
|
actions={<RuleListActions />}
|
||||||
|
|||||||
@@ -3,21 +3,15 @@ import { UrlSyncContextProvider } from '@grafana/scenes';
|
|||||||
import { withErrorBoundary } from '@grafana/ui';
|
import { withErrorBoundary } from '@grafana/ui';
|
||||||
|
|
||||||
import { AlertingPageWrapper } from '../components/AlertingPageWrapper';
|
import { AlertingPageWrapper } from '../components/AlertingPageWrapper';
|
||||||
|
import { useAlertActivityNav } from '../navigation/useAlertActivityNav';
|
||||||
|
|
||||||
import { TriageScene, triageScene } from './scene/TriageScene';
|
import { TriageScene, triageScene } from './scene/TriageScene';
|
||||||
|
|
||||||
export const TriagePage = () => {
|
export const TriagePage = () => {
|
||||||
|
const { navId, pageNav } = useAlertActivityNav();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertingPageWrapper
|
<AlertingPageWrapper navId={navId || 'alert-alerts'} pageNav={pageNav}>
|
||||||
navId="alert-alerts"
|
|
||||||
subTitle={t(
|
|
||||||
'alerting.pages.triage.subtitle',
|
|
||||||
'See what is currently alerting and explore historical data to investigate current or past issues.'
|
|
||||||
)}
|
|
||||||
pageNav={{
|
|
||||||
text: t('alerting.pages.triage.title', 'Alerts'),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<UrlSyncContextProvider scene={triageScene} updateUrlOnInit={true} createBrowserHistorySteps={true}>
|
<UrlSyncContextProvider scene={triageScene} updateUrlOnInit={true} createBrowserHistorySteps={true}>
|
||||||
<TriageScene key={triageScene.state.key} />
|
<TriageScene key={triageScene.state.key} />
|
||||||
</UrlSyncContextProvider>
|
</UrlSyncContextProvider>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function RecentlyViewedDashboards() {
|
|||||||
retry();
|
retry();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!evaluateBooleanFlag('recentlyViewedDashboards', false)) {
|
if (!evaluateBooleanFlag('recentlyViewedDashboards', false) || recentDashboards.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,10 +76,6 @@ export function RecentlyViewedDashboards() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{loading && <Spinner />}
|
{loading && <Spinner />}
|
||||||
{/* TODO: Better empty state https://github.com/grafana/grafana/issues/114804 */}
|
|
||||||
{!loading && recentDashboards.length === 0 && (
|
|
||||||
<Text>{t('browse-dashboards.recently-viewed.empty', 'Nothing viewed yet')}</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && recentDashboards.length > 0 && (
|
{!loading && recentDashboards.length > 0 && (
|
||||||
<ul className={styles.list}>
|
<ul className={styles.list}>
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ describe('PanelTimeRange', () => {
|
|||||||
expect(panelTime.state.value.to.format('Z')).toBe('+00:00'); // UTC
|
expect(panelTime.state.value.to.format('Z')).toBe('+00:00'); // UTC
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle invalid time reference in timeShift', () => {
|
it('should handle invalid time reference in timeShift with relative time range', () => {
|
||||||
const panelTime = new PanelTimeRange({ timeShift: 'now-1d' });
|
const panelTime = new PanelTimeRange({ timeShift: 'now-1d' });
|
||||||
|
|
||||||
buildAndActivateSceneFor(panelTime);
|
buildAndActivateSceneFor(panelTime);
|
||||||
@@ -139,6 +139,22 @@ describe('PanelTimeRange', () => {
|
|||||||
expect(panelTime.state.to).toBe('now');
|
expect(panelTime.state.to).toBe('now');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should handle invalid time reference in timeShift with absolute time range', () => {
|
||||||
|
const panelTime = new PanelTimeRange({ timeShift: 'now-1d' });
|
||||||
|
const panel = new SceneCanvasText({ text: 'Hello', $timeRange: panelTime });
|
||||||
|
const absoluteFrom = '2019-02-11T10:00:00.000Z';
|
||||||
|
const absoluteTo = '2019-02-11T16:00:00.000Z';
|
||||||
|
const scene = new SceneFlexLayout({
|
||||||
|
$timeRange: new SceneTimeRange({ from: absoluteFrom, to: absoluteTo }),
|
||||||
|
children: [new SceneFlexItem({ body: panel })],
|
||||||
|
});
|
||||||
|
activateFullSceneTree(scene);
|
||||||
|
|
||||||
|
expect(panelTime.state.timeInfo).toBe('invalid timeshift');
|
||||||
|
expect(panelTime.state.from).toBe(absoluteFrom);
|
||||||
|
expect(panelTime.state.to).toBe(absoluteTo);
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle invalid time reference in timeShift combined with timeFrom', () => {
|
it('should handle invalid time reference in timeShift combined with timeFrom', () => {
|
||||||
const panelTime = new PanelTimeRange({
|
const panelTime = new PanelTimeRange({
|
||||||
timeFrom: 'now-2h',
|
timeFrom: 'now-2h',
|
||||||
@@ -153,6 +169,66 @@ describe('PanelTimeRange', () => {
|
|||||||
expect(panelTime.state.to).toBe('now');
|
expect(panelTime.state.to).toBe('now');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('from/to state format for liveNow compatibility', () => {
|
||||||
|
it('should store relative strings in from/to when timeShift is applied to relative time range', () => {
|
||||||
|
const panelTime = new PanelTimeRange({ timeShift: '2h' });
|
||||||
|
|
||||||
|
buildAndActivateSceneFor(panelTime);
|
||||||
|
|
||||||
|
expect(panelTime.state.from).toBe('now-6h-2h');
|
||||||
|
expect(panelTime.state.to).toBe('now-2h');
|
||||||
|
expect(panelTime.state.value.raw.from).toBe('now-6h-2h');
|
||||||
|
expect(panelTime.state.value.raw.to).toBe('now-2h');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should store relative strings when both timeFrom and timeShift are applied', () => {
|
||||||
|
const panelTime = new PanelTimeRange({ timeFrom: '2h', timeShift: '1h' });
|
||||||
|
|
||||||
|
buildAndActivateSceneFor(panelTime);
|
||||||
|
|
||||||
|
expect(panelTime.state.from).toBe('now-2h-1h');
|
||||||
|
expect(panelTime.state.to).toBe('now-1h');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should store ISO strings when timeShift is applied to absolute time range', () => {
|
||||||
|
const panelTime = new PanelTimeRange({ timeShift: '1h' });
|
||||||
|
const panel = new SceneCanvasText({ text: 'Hello', $timeRange: panelTime });
|
||||||
|
const absoluteFrom = '2019-02-11T10:00:00.000Z';
|
||||||
|
const absoluteTo = '2019-02-11T16:00:00.000Z';
|
||||||
|
const scene = new SceneFlexLayout({
|
||||||
|
$timeRange: new SceneTimeRange({ from: absoluteFrom, to: absoluteTo }),
|
||||||
|
children: [new SceneFlexItem({ body: panel })],
|
||||||
|
});
|
||||||
|
activateFullSceneTree(scene);
|
||||||
|
|
||||||
|
expect(panelTime.state.from).toBe('2019-02-11T09:00:00.000Z');
|
||||||
|
expect(panelTime.state.to).toBe('2019-02-11T15:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update from/to when ancestor time range changes', () => {
|
||||||
|
const panelTime = new PanelTimeRange({ timeShift: '1h' });
|
||||||
|
const sceneTimeRange = new SceneTimeRange({ from: 'now-6h', to: 'now' });
|
||||||
|
const panel = new SceneCanvasText({ text: 'Hello', $timeRange: panelTime });
|
||||||
|
const scene = new SceneFlexLayout({
|
||||||
|
$timeRange: sceneTimeRange,
|
||||||
|
children: [new SceneFlexItem({ body: panel })],
|
||||||
|
});
|
||||||
|
activateFullSceneTree(scene);
|
||||||
|
|
||||||
|
expect(panelTime.state.from).toBe('now-6h-1h');
|
||||||
|
expect(panelTime.state.to).toBe('now-1h');
|
||||||
|
|
||||||
|
sceneTimeRange.onTimeRangeChange({
|
||||||
|
from: dateTime('2019-02-11T12:00:00.000Z'),
|
||||||
|
to: dateTime('2019-02-11T18:00:00.000Z'),
|
||||||
|
raw: { from: 'now-12h', to: 'now' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(panelTime.state.from).toBe('now-12h-1h');
|
||||||
|
expect(panelTime.state.to).toBe('now-1h');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('onTimeRangeChange', () => {
|
describe('onTimeRangeChange', () => {
|
||||||
it('should reverse timeShift when updating time range', () => {
|
it('should reverse timeShift when updating time range', () => {
|
||||||
const oneHourShift = '1h';
|
const oneHourShift = '1h';
|
||||||
|
|||||||
@@ -81,7 +81,19 @@ export class PanelTimeRange extends SceneTimeRangeTransformerBase<PanelTimeRange
|
|||||||
}
|
}
|
||||||
|
|
||||||
const overrideResult = this.getTimeOverride(timeRange.value);
|
const overrideResult = this.getTimeOverride(timeRange.value);
|
||||||
this.setState({ value: overrideResult.timeRange, timeInfo: overrideResult.timeInfo });
|
const { timeRange: overrideTimeRange } = overrideResult;
|
||||||
|
this.setState({
|
||||||
|
value: overrideTimeRange,
|
||||||
|
timeInfo: overrideResult.timeInfo,
|
||||||
|
from:
|
||||||
|
typeof overrideTimeRange.raw.from === 'string'
|
||||||
|
? overrideTimeRange.raw.from
|
||||||
|
: overrideTimeRange.raw.from.toISOString(),
|
||||||
|
to:
|
||||||
|
typeof overrideTimeRange.raw.to === 'string'
|
||||||
|
? overrideTimeRange.raw.to
|
||||||
|
: overrideTimeRange.raw.to.toISOString(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get a time shifted request to compare with the primary request.
|
// Get a time shifted request to compare with the primary request.
|
||||||
@@ -153,10 +165,10 @@ export class PanelTimeRange extends SceneTimeRangeTransformerBase<PanelTimeRange
|
|||||||
|
|
||||||
// Only evaluate if the timeFrom if parent time is relative
|
// Only evaluate if the timeFrom if parent time is relative
|
||||||
if (rangeUtil.isRelativeTimeRange(parentTimeRange.raw)) {
|
if (rangeUtil.isRelativeTimeRange(parentTimeRange.raw)) {
|
||||||
const timeZone = this.getTimeZone();
|
const timezone = this.getTimeZone();
|
||||||
newTimeData.timeRange = {
|
newTimeData.timeRange = {
|
||||||
from: dateMath.parse(timeFromInfo.from, undefined, timeZone)!,
|
from: dateMath.toDateTime(timeFromInfo.from, { timezone })!,
|
||||||
to: dateMath.parse(timeFromInfo.to, undefined, timeZone)!,
|
to: dateMath.toDateTime(timeFromInfo.to, { timezone })!,
|
||||||
raw: { from: timeFromInfo.from, to: timeFromInfo.to },
|
raw: { from: timeFromInfo.from, to: timeFromInfo.to },
|
||||||
};
|
};
|
||||||
infoBlocks.push(timeFromInfo.display);
|
infoBlocks.push(timeFromInfo.display);
|
||||||
@@ -172,18 +184,39 @@ export class PanelTimeRange extends SceneTimeRangeTransformerBase<PanelTimeRange
|
|||||||
return newTimeData;
|
return newTimeData;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeShift = '-' + timeShiftInterpolated;
|
const shift = '-' + timeShiftInterpolated;
|
||||||
infoBlocks.push('timeshift ' + timeShift);
|
infoBlocks.push('timeshift ' + shift);
|
||||||
|
|
||||||
const from = dateMath.parseDateMath(timeShift, newTimeData.timeRange.from, false)!;
|
if (rangeUtil.isRelativeTimeRange(newTimeData.timeRange.raw)) {
|
||||||
const to = dateMath.parseDateMath(timeShift, newTimeData.timeRange.to, true)!;
|
const timezone = this.getTimeZone();
|
||||||
|
|
||||||
if (!from || !to) {
|
const rawFromShifted = `${newTimeData.timeRange.raw.from}${shift}`;
|
||||||
newTimeData.timeInfo = 'invalid timeshift';
|
const rawToShifted = `${newTimeData.timeRange.raw.to}${shift}`;
|
||||||
return newTimeData;
|
|
||||||
|
const from = dateMath.toDateTime(rawFromShifted, { timezone });
|
||||||
|
const to = dateMath.toDateTime(rawToShifted, { timezone });
|
||||||
|
|
||||||
|
if (!from || !to) {
|
||||||
|
newTimeData.timeInfo = 'invalid timeshift';
|
||||||
|
return newTimeData;
|
||||||
|
}
|
||||||
|
|
||||||
|
newTimeData.timeRange = {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
raw: { from: rawFromShifted, to: rawToShifted },
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const from = dateMath.parseDateMath(shift, newTimeData.timeRange.from, false);
|
||||||
|
const to = dateMath.parseDateMath(shift, newTimeData.timeRange.to, true);
|
||||||
|
|
||||||
|
if (!from || !to) {
|
||||||
|
newTimeData.timeInfo = 'invalid timeshift';
|
||||||
|
return newTimeData;
|
||||||
|
}
|
||||||
|
|
||||||
|
newTimeData.timeRange = { from, to, raw: { from, to } };
|
||||||
}
|
}
|
||||||
|
|
||||||
newTimeData.timeRange = { from, to, raw: { from, to } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (compareWith) {
|
if (compareWith) {
|
||||||
|
|||||||
@@ -3759,7 +3759,6 @@
|
|||||||
},
|
},
|
||||||
"recently-viewed": {
|
"recently-viewed": {
|
||||||
"clear": "Clear history",
|
"clear": "Clear history",
|
||||||
"empty": "Nothing viewed yet",
|
|
||||||
"error": "Recently viewed dashboards couldn’t be loaded.",
|
"error": "Recently viewed dashboards couldn’t be loaded.",
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"title": "Recently viewed"
|
"title": "Recently viewed"
|
||||||
|
|||||||
Reference in New Issue
Block a user