Merge branch 'main' into kristina/rtk-corr
This commit is contained in:
@@ -41,9 +41,13 @@ Select a group to expand it and view the list of alert rules within that group.
|
||||
|
||||
The list view includes a number of filters to simplify managing large volumes of alerts.
|
||||
|
||||
## Filter and save searches
|
||||
|
||||
Click the **Filter** button to open the filter popup. You can filter by name, label, folder/namespace, evaluation group, data source, contact point, rule source, rule state, rule type, and the health of the alert rule from the popup menu. Click **Apply** at the bottom of the filter popup to enact the filters as you search.
|
||||
|
||||
{{< figure src="/media/docs/alerting/alerting-list-view-filter.png" max-width="750px" alt="Alert rule filter options" >}}
|
||||
Click the **Saved searches** button to open the list of previously saved searches, or click **+ Save current search** to add your current search to the saved searches list. You can also rename a saved search or set it as a default search. When you set a saved search as the default search, the Alert rules page opens with the search applied.
|
||||
|
||||
{{< figure src="/media/docs/alerting/alerting-saved-searches.png" max-width="750px" alt="Alert rule filter options" >}}
|
||||
|
||||
## Change alert rules list view
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ killercoda:
|
||||
|
||||
This tutorial is a continuation of the [Get started with Grafana Alerting - Route alerts using dynamic labels](http://www.grafana.com/tutorials/alerting-get-started-pt5/) tutorial.
|
||||
|
||||
{{< youtube id="mqj_hN24zLU" >}}
|
||||
|
||||
<!-- USE CASE -->
|
||||
|
||||
In this tutorial you will learn how to:
|
||||
|
||||
@@ -33,12 +33,14 @@ require (
|
||||
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-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/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/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/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/beevik/etree v1.4.1 // @grafana/grafana-backend-group
|
||||
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/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/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/s3/manager v1.17.84 // 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/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/sts v1.39.1 // indirect
|
||||
github.com/axiomhq/hyperloglog v0.0.0-20240507144631-af9851f82b27 // indirect
|
||||
github.com/bahlo/generic-list-go v0.2.0 // 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());
|
||||
});
|
||||
|
||||
it('should handle multiple math expressions', () => {
|
||||
const date = dateMath.parseDateMath('-2d-6h', dateTime([2014, 1, 5]));
|
||||
expect(date!.valueOf()).toEqual(dateTime([2014, 1, 2, 18]).valueOf());
|
||||
it.each([
|
||||
['-2d-6h', [2014, 1, 5], [2014, 1, 2, 18]],
|
||||
['-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', () => {
|
||||
|
||||
@@ -400,10 +400,6 @@ export interface FeatureToggles {
|
||||
*/
|
||||
tableSharedCrosshair?: boolean;
|
||||
/**
|
||||
* Use the kubernetes API for feature toggle management in the frontend
|
||||
*/
|
||||
kubernetesFeatureToggles?: boolean;
|
||||
/**
|
||||
* Enabled grafana cloud specific RBAC roles
|
||||
*/
|
||||
cloudRBACRoles?: boolean;
|
||||
|
||||
@@ -11,6 +11,9 @@ import (
|
||||
_ "github.com/Azure/azure-sdk-for-go/services/keyvault/v7.1/keyvault"
|
||||
_ "github.com/Azure/go-autorest/autorest"
|
||||
_ "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/blugelabs/bluge"
|
||||
_ "github.com/blugelabs/bluge_segment_api"
|
||||
@@ -46,7 +49,6 @@ import (
|
||||
_ "sigs.k8s.io/randfill"
|
||||
_ "xorm.io/builder"
|
||||
|
||||
_ "github.com/aws/aws-sdk-go-v2/service/secretsmanager"
|
||||
_ "github.com/grafana/authlib/authn"
|
||||
_ "github.com/grafana/authlib/authz"
|
||||
_ "github.com/grafana/authlib/cache"
|
||||
|
||||
@@ -209,7 +209,7 @@ func (ots *TracingService) initSampler() (tracesdk.Sampler, error) {
|
||||
case "rateLimiting":
|
||||
return newRateLimiter(ots.cfg.SamplerParam), nil
|
||||
case "remote":
|
||||
return jaegerremote.New("grafana",
|
||||
return jaegerremote.New(ots.cfg.ServiceName,
|
||||
jaegerremote.WithSamplingServerURL(ots.cfg.SamplerRemoteURL),
|
||||
jaegerremote.WithInitialSampler(tracesdk.TraceIDRatioBased(ots.cfg.SamplerParam)),
|
||||
), nil
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/apiserver"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/appinstaller"
|
||||
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/pluginstore"
|
||||
)
|
||||
@@ -36,9 +37,13 @@ func ProvideAppInstaller(
|
||||
pluginStore pluginstore.Store,
|
||||
pluginAssetsService *pluginassets.Service,
|
||||
accessControlService accesscontrol.Service, accessClient authlib.AccessClient,
|
||||
features featuremgmt.FeatureToggles,
|
||||
) (*AppInstaller, error) {
|
||||
if err := registerAccessControlRoles(accessControlService); err != nil {
|
||||
return nil, fmt.Errorf("registering access control roles: %w", err)
|
||||
//nolint:staticcheck // not yet migrated to OpenFeature
|
||||
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)
|
||||
|
||||
Generated
+2
-2
@@ -785,7 +785,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
appInstaller, err := plugins.ProvideAppInstaller(configProvider, eventualRestConfigProvider, pluginstoreService, pluginassetsService, acimplService, accessClient)
|
||||
appInstaller, err := plugins.ProvideAppInstaller(configProvider, eventualRestConfigProvider, pluginstoreService, pluginassetsService, acimplService, accessClient, featureToggles)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1447,7 +1447,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
appInstaller, err := plugins.ProvideAppInstaller(configProvider, eventualRestConfigProvider, pluginstoreService, pluginassetsService, acimplService, accessClient)
|
||||
appInstaller, err := plugins.ProvideAppInstaller(configProvider, eventualRestConfigProvider, pluginstoreService, pluginassetsService, acimplService, accessClient, featureToggles)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -650,13 +650,6 @@ var (
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaDatavizSquad,
|
||||
},
|
||||
{
|
||||
Name: "kubernetesFeatureToggles",
|
||||
Description: "Use the kubernetes API for feature toggle management in the frontend",
|
||||
Stage: FeatureStageExperimental,
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaOperatorExperienceSquad,
|
||||
},
|
||||
{
|
||||
Name: "cloudRBACRoles",
|
||||
Description: "Enabled grafana cloud specific RBAC roles",
|
||||
|
||||
Generated
-1
@@ -90,7 +90,6 @@ pdfTables,preview,@grafana/grafana-operator-experience-squad,false,false,false
|
||||
canvasPanelPanZoom,preview,@grafana/dataviz-squad,false,false,true
|
||||
timeComparison,experimental,@grafana/dataviz-squad,false,false,true
|
||||
tableSharedCrosshair,experimental,@grafana/dataviz-squad,false,false,true
|
||||
kubernetesFeatureToggles,experimental,@grafana/grafana-operator-experience-squad,false,false,true
|
||||
cloudRBACRoles,preview,@grafana/identity-access-team,false,true,false
|
||||
alertingQueryOptimization,GA,@grafana/alerting-squad,false,false,false
|
||||
jitterAlertRulesWithinGroups,preview,@grafana/alerting-squad,false,true,false
|
||||
|
||||
|
+2
-1
@@ -2044,7 +2044,8 @@
|
||||
"metadata": {
|
||||
"name": "kubernetesFeatureToggles",
|
||||
"resourceVersion": "1764664939750",
|
||||
"creationTimestamp": "2024-01-18T05:32:44Z"
|
||||
"creationTimestamp": "2024-01-18T05:32:44Z",
|
||||
"deletionTimestamp": "2026-01-07T12:02:51Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Use the kubernetes API for feature toggle management in the frontend",
|
||||
|
||||
@@ -806,8 +806,10 @@ flowchart TD
|
||||
|
||||
#### Setting Dual Writer Mode
|
||||
```ini
|
||||
[unified_storage.{resource}.{kind}.{group}]
|
||||
dualWriterMode = {0-5}
|
||||
; [unified_storage.{resource}.{group}]
|
||||
[unified_storage.dashboards.dashboard.grafana.app]
|
||||
; modes {0-5}
|
||||
dualWriterMode = 0
|
||||
```
|
||||
|
||||
#### Background Sync Configuration
|
||||
@@ -1376,4 +1378,3 @@ disable_data_migrations = false
|
||||
### Documentation
|
||||
|
||||
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" }}
|
||||
)
|
||||
VALUES (
|
||||
COALESCE({{ .Arg .Value }}, ""),
|
||||
(SELECT {{ .Ident "value" }} FROM {{ .Ident "resource_history" }} WHERE {{ .Ident "guid" }} = {{ .Arg .GUID }}),
|
||||
{{ .Arg .GUID }},
|
||||
{{ .Arg .Group }},
|
||||
{{ .Arg .Resource }},
|
||||
@@ -19,13 +19,5 @@ VALUES (
|
||||
{{ .Arg .Name }},
|
||||
{{ .Arg .Action }},
|
||||
{{ .Arg .Folder }},
|
||||
CASE WHEN {{ .Arg .Action }} = 1 THEN 0 ELSE (
|
||||
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
|
||||
{{ .Arg .PreviousRV }}
|
||||
);
|
||||
|
||||
@@ -7,9 +7,7 @@ INSERT INTO {{ .Ident "resource_history" }}
|
||||
{{ .Ident "namespace" }},
|
||||
{{ .Ident "name" }},
|
||||
{{ .Ident "action" }},
|
||||
{{ .Ident "folder" }},
|
||||
{{ .Ident "previous_resource_version" }},
|
||||
{{ .Ident "generation" }}
|
||||
{{ .Ident "folder" }}
|
||||
)
|
||||
VALUES (
|
||||
COALESCE({{ .Arg .Value }}, ""),
|
||||
@@ -19,26 +17,5 @@ VALUES (
|
||||
{{ .Arg .Namespace }},
|
||||
{{ .Arg .Name }},
|
||||
{{ .Arg .Action }},
|
||||
{{ .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
|
||||
{{ .Arg .Folder }}
|
||||
);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
UPDATE {{ .Ident "resource" }}
|
||||
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 "folder" }} = {{ .Arg .Folder }}
|
||||
{{ .Ident "folder" }} = {{ .Arg .Folder }},
|
||||
{{ .Ident "previous_resource_version" }} = {{ .Arg .PreviousRV }}
|
||||
WHERE {{ .Ident "group" }} = {{ .Arg .Group }}
|
||||
AND {{ .Ident "resource" }} = {{ .Arg .Resource }}
|
||||
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"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -306,10 +309,6 @@ func (d *dataStore) GetResourceKeyAtRevision(ctx context.Context, key GetRequest
|
||||
return DataKey{}, fmt.Errorf("invalid get request key: %w", err)
|
||||
}
|
||||
|
||||
if rv == 0 {
|
||||
rv = math.MaxInt64
|
||||
}
|
||||
|
||||
listKey := ListRequestKey(key)
|
||||
|
||||
iter := d.ListResourceKeysAtRevision(ctx, ListRequestOptions{Key: listKey, ResourceVersion: rv})
|
||||
@@ -598,7 +597,7 @@ func ParseKey(key string) (DataKey, error) {
|
||||
}, 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
|
||||
func ParseKeyWithGUID(key string) (DataKey, error) {
|
||||
parts := strings.Split(key, "/")
|
||||
@@ -815,3 +814,121 @@ func (d *dataStore) getGroupResources(ctx context.Context) ([]GroupResource, err
|
||||
|
||||
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")
|
||||
sqlKVUpdateData = mustTemplate("sqlkv_update_datastore.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")
|
||||
sqlKVDelete = mustTemplate("sqlkv_delete.sql")
|
||||
sqlKVBatchDelete = mustTemplate("sqlkv_batch_delete.sql")
|
||||
@@ -157,26 +155,6 @@ func (req sqlKVSaveRequest) Validate() error {
|
||||
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 {
|
||||
sqltemplate.SQLTemplate
|
||||
sqlKVSection
|
||||
@@ -392,7 +370,7 @@ func (w *sqlWriteCloser) Close() error {
|
||||
// used to keep backwards compatibility between sql-based kvstore and unified/sql/backend
|
||||
tx, ok := rvmanager.TxFromCtx(w.ctx)
|
||||
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:
|
||||
// - move PK from GUID to key_path
|
||||
// - remove all unnecessary columns (or at least their NOT NULL constraints)
|
||||
@@ -429,11 +407,12 @@ func (w *sqlWriteCloser) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// special, temporary save that includes all the fields in resource_history that are not relevant 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
|
||||
// note that we are not touching resource_version table, neither the resource_version columns or the key_path column
|
||||
// as the RvManager will be responsible for this
|
||||
// special, temporary backwards-compatible save that includes all the fields in resource_history that are not relevant
|
||||
// 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.
|
||||
// For full backwards-compatibility, the `Save` function needs to be called within a callback that updates the resource_history
|
||||
// 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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse key: %w", err)
|
||||
@@ -448,7 +427,7 @@ func (w *sqlWriteCloser) Close() error {
|
||||
case DataActionDeleted:
|
||||
action = 3
|
||||
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{
|
||||
@@ -468,52 +447,6 @@ func (w *sqlWriteCloser) Close() error {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -332,11 +332,14 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in
|
||||
dataKey.GUID = uuid.New().String()
|
||||
var err 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 != nil {
|
||||
if err := k.dataStore.Save(rvmanager.ContextWithTx(ctx, tx), dataKey, bytes.NewReader(event.Value)); err != nil {
|
||||
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
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"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/testinfra"
|
||||
"github.com/grafana/grafana/pkg/tests/testsuite"
|
||||
@@ -177,6 +178,9 @@ func setupHelper(t *testing.T) *apis.K8sTestHelper {
|
||||
AppModeProduction: true,
|
||||
DisableAnonymous: true,
|
||||
APIServerRuntimeConfig: "plugins.grafana.app/v0alpha1=true",
|
||||
EnableFeatureToggles: []string{
|
||||
featuremgmt.FlagPluginStoreServiceLoading,
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() { helper.Shutdown() })
|
||||
return helper
|
||||
|
||||
@@ -3,7 +3,8 @@ import { render, screen, userEvent, waitFor } from 'test/test-utils';
|
||||
import { byLabelText, byRole, byText } from 'testing-library-selector';
|
||||
|
||||
import { setPluginLinksHook } from '@grafana/runtime';
|
||||
import { setupMswServer } from 'app/features/alerting/unified/mockApi';
|
||||
import server from '@grafana/test-utils/server';
|
||||
import { mockAlertRuleApi, setupMswServer } from 'app/features/alerting/unified/mockApi';
|
||||
import { AlertManagerDataSourceJsonData } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { AccessControlAction } from 'app/types/accessControl';
|
||||
import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting';
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
mockPluginLinkExtension,
|
||||
mockPromAlertingRule,
|
||||
mockRulerGrafanaRecordingRule,
|
||||
mockRulerGrafanaRule,
|
||||
} from '../../mocks';
|
||||
import { grafanaRulerRule } from '../../mocks/grafanaRulerApi';
|
||||
import { grantPermissionsHelper } from '../../test/test-utils';
|
||||
@@ -130,6 +132,8 @@ const dataSources = {
|
||||
};
|
||||
|
||||
describe('RuleViewer', () => {
|
||||
const api = mockAlertRuleApi(server);
|
||||
|
||||
beforeEach(() => {
|
||||
setupDataSources(...Object.values(dataSources));
|
||||
});
|
||||
@@ -249,19 +253,22 @@ describe('RuleViewer', () => {
|
||||
|
||||
expect(screen.getAllByRole('row')).toHaveLength(7);
|
||||
expect(screen.getAllByRole('row')[1]).toHaveTextContent(/6Provisioning2025-01-18 04:35:17/i);
|
||||
expect(screen.getAllByRole('row')[1]).toHaveTextContent('+3-3Latest');
|
||||
expect(screen.getAllByRole('row')[1]).toHaveTextContent('Updated by provisioning service');
|
||||
expect(screen.getAllByRole('row')[1]).toHaveTextContent('+4-3Latest');
|
||||
|
||||
expect(screen.getAllByRole('row')[2]).toHaveTextContent(/5Alerting2025-01-17 04:35:17/i);
|
||||
expect(screen.getAllByRole('row')[2]).toHaveTextContent('+5-5');
|
||||
expect(screen.getAllByRole('row')[2]).toHaveTextContent('+5-6');
|
||||
|
||||
expect(screen.getAllByRole('row')[3]).toHaveTextContent(/4different user2025-01-16 04:35:17/i);
|
||||
expect(screen.getAllByRole('row')[3]).toHaveTextContent('+5-5');
|
||||
expect(screen.getAllByRole('row')[3]).toHaveTextContent('Changed alert title and thresholds');
|
||||
expect(screen.getAllByRole('row')[3]).toHaveTextContent('+6-5');
|
||||
|
||||
expect(screen.getAllByRole('row')[4]).toHaveTextContent(/3user12025-01-15 04:35:17/i);
|
||||
expect(screen.getAllByRole('row')[4]).toHaveTextContent('+5-9');
|
||||
expect(screen.getAllByRole('row')[4]).toHaveTextContent('+5-10');
|
||||
|
||||
expect(screen.getAllByRole('row')[5]).toHaveTextContent(/2User ID foo2025-01-14 04:35:17/i);
|
||||
expect(screen.getAllByRole('row')[5]).toHaveTextContent('+11-7');
|
||||
expect(screen.getAllByRole('row')[5]).toHaveTextContent('Updated evaluation interval and routing');
|
||||
expect(screen.getAllByRole('row')[5]).toHaveTextContent('+12-7');
|
||||
|
||||
expect(screen.getAllByRole('row')[6]).toHaveTextContent(/1Unknown 2025-01-13 04:35:17/i);
|
||||
|
||||
@@ -275,9 +282,10 @@ describe('RuleViewer', () => {
|
||||
await renderRuleViewer(mockRule, mockRuleIdentifier, ActiveTab.VersionHistory);
|
||||
expect(await screen.findByRole('button', { name: /Compare versions/i })).toBeDisabled();
|
||||
|
||||
expect(screen.getByRole('cell', { name: /provisioning/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('cell', { name: /alerting/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('cell', { name: /Unknown/i })).toBeInTheDocument();
|
||||
// Check for special updated_by values - use getAllByRole since some text appears in multiple columns
|
||||
expect(screen.getAllByRole('cell', { name: /provisioning/i }).length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole('cell', { name: /^alerting$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('cell', { name: /^Unknown$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('cell', { name: /user id foo/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -321,6 +329,47 @@ describe('RuleViewer', () => {
|
||||
await renderRuleViewer(rule, ruleIdentifier);
|
||||
expect(screen.queryByText('Labels')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Notes column when versions have messages', async () => {
|
||||
await renderRuleViewer(mockRule, mockRuleIdentifier, ActiveTab.VersionHistory);
|
||||
|
||||
expect(await screen.findByRole('columnheader', { name: /Notes/i })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('row')).toHaveLength(7); // 1 header + 6 data rows
|
||||
expect(screen.getByRole('cell', { name: /Updated by provisioning service/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('cell', { name: /Changed alert title and thresholds/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('cell', { name: /Updated evaluation interval and routing/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show Notes column when no versions have messages', async () => {
|
||||
const versionsWithoutMessages = [
|
||||
mockRulerGrafanaRule(
|
||||
{},
|
||||
{
|
||||
uid: grafanaRulerRule.grafana_alert.uid,
|
||||
version: 2,
|
||||
updated: '2025-01-14T09:35:17.000Z',
|
||||
updated_by: { uid: 'foo', name: '' },
|
||||
}
|
||||
),
|
||||
mockRulerGrafanaRule(
|
||||
{},
|
||||
{
|
||||
uid: grafanaRulerRule.grafana_alert.uid,
|
||||
version: 1,
|
||||
updated: '2025-01-13T09:35:17.000Z',
|
||||
updated_by: null,
|
||||
}
|
||||
),
|
||||
];
|
||||
api.getAlertRuleVersionHistory(grafanaRulerRule.grafana_alert.uid, versionsWithoutMessages);
|
||||
|
||||
await renderRuleViewer(mockRule, mockRuleIdentifier, ActiveTab.VersionHistory);
|
||||
|
||||
await screen.findByRole('button', { name: /Compare versions/i });
|
||||
|
||||
expect(screen.getAllByRole('row')).toHaveLength(3); // 1 header + 2 data rows
|
||||
expect(screen.queryByRole('columnheader', { name: /Notes/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+24
-2
@@ -1,8 +1,9 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { dateTimeFormat, dateTimeFormatTimeAgo } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { Badge, Button, Checkbox, Column, InteractiveTable, Stack, Text } from '@grafana/ui';
|
||||
import { Badge, Button, Checkbox, Column, InteractiveTable, Stack, Text, useStyles2 } from '@grafana/ui';
|
||||
import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
|
||||
import { computeVersionDiff } from 'app/features/alerting/unified/utils/diff';
|
||||
import { RuleIdentifier } from 'app/types/unified-alerting';
|
||||
@@ -33,6 +34,7 @@ export function VersionHistoryTable({
|
||||
onRestoreError,
|
||||
canRestore,
|
||||
}: VersionHistoryTableProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
const [ruleToRestore, setRuleToRestore] = useState<RulerGrafanaRuleDTO<GrafanaRuleDefinition>>();
|
||||
const ruleToRestoreUid = ruleToRestore?.grafana_alert?.uid ?? '';
|
||||
@@ -41,6 +43,8 @@ export function VersionHistoryTable({
|
||||
[ruleToRestoreUid]
|
||||
);
|
||||
|
||||
const hasAnyNotes = useMemo(() => ruleVersions.some((v) => v.grafana_alert.message), [ruleVersions]);
|
||||
|
||||
const showConfirmation = (ruleToRestore: RulerGrafanaRuleDTO<GrafanaRuleDefinition>) => {
|
||||
setShowConfirmModal(true);
|
||||
setRuleToRestore(ruleToRestore);
|
||||
@@ -52,6 +56,15 @@ export function VersionHistoryTable({
|
||||
|
||||
const unknown = t('alerting.alertVersionHistory.unknown', 'Unknown');
|
||||
|
||||
const notesColumn: Column<RulerGrafanaRuleDTO<GrafanaRuleDefinition>> = {
|
||||
id: 'notes',
|
||||
header: t('core.versionHistory.table.notes', 'Notes'),
|
||||
cell: ({ row }) => {
|
||||
const message = row.original.grafana_alert.message;
|
||||
return message || null;
|
||||
},
|
||||
};
|
||||
|
||||
const columns: Array<Column<RulerGrafanaRuleDTO<GrafanaRuleDefinition>>> = [
|
||||
{
|
||||
disableGrow: true,
|
||||
@@ -91,9 +104,12 @@ export function VersionHistoryTable({
|
||||
if (!value) {
|
||||
return unknown;
|
||||
}
|
||||
return dateTimeFormat(value) + ' (' + dateTimeFormatTimeAgo(value) + ')';
|
||||
return (
|
||||
<span className={styles.nowrap}>{dateTimeFormat(value) + ' (' + dateTimeFormatTimeAgo(value) + ')'}</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
...(hasAnyNotes ? [notesColumn] : []),
|
||||
{
|
||||
id: 'diff',
|
||||
disableGrow: true,
|
||||
@@ -179,3 +195,9 @@ export function VersionHistoryTable({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = () => ({
|
||||
nowrap: css({
|
||||
whiteSpace: 'nowrap',
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -47,7 +47,7 @@ export const getFormFieldsForSilence = (silence: Silence): SilenceFormFields =>
|
||||
startsAt: interval.start.toISOString(),
|
||||
endsAt: interval.end.toISOString(),
|
||||
comment: silence.comment,
|
||||
createdBy: silence.createdBy,
|
||||
createdBy: isExpired ? contextSrv.user.name : silence.createdBy,
|
||||
duration: intervalToAbbreviatedDurationString(interval),
|
||||
isRegex: false,
|
||||
matchers: silence.matchers?.map(matcherToMatcherField) || [],
|
||||
|
||||
@@ -154,6 +154,7 @@ export const rulerRuleVersionHistoryHandler = () => {
|
||||
uid: 'service',
|
||||
name: '',
|
||||
};
|
||||
draft.grafana_alert.message = 'Updated by provisioning service';
|
||||
}),
|
||||
produce(grafanaRulerRule, (draft: RulerGrafanaRuleDTO<GrafanaRuleDefinition>) => {
|
||||
draft.grafana_alert.version = 5;
|
||||
@@ -171,6 +172,7 @@ export const rulerRuleVersionHistoryHandler = () => {
|
||||
uid: 'different',
|
||||
name: 'different user',
|
||||
};
|
||||
draft.grafana_alert.message = 'Changed alert title and thresholds';
|
||||
}),
|
||||
produce(grafanaRulerRule, (draft: RulerGrafanaRuleDTO<GrafanaRuleDefinition>) => {
|
||||
draft.grafana_alert.version = 3;
|
||||
@@ -193,6 +195,7 @@ export const rulerRuleVersionHistoryHandler = () => {
|
||||
uid: 'foo',
|
||||
name: '',
|
||||
};
|
||||
draft.grafana_alert.message = 'Updated evaluation interval and routing';
|
||||
}),
|
||||
produce(grafanaRulerRule, (draft: RulerGrafanaRuleDTO<GrafanaRuleDefinition>) => {
|
||||
draft.grafana_alert.version = 1;
|
||||
|
||||
@@ -39,7 +39,7 @@ export function RecentlyViewedDashboards() {
|
||||
retry();
|
||||
};
|
||||
|
||||
if (!evaluateBooleanFlag('recentlyViewedDashboards', false)) {
|
||||
if (!evaluateBooleanFlag('recentlyViewedDashboards', false) || recentDashboards.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -76,10 +76,6 @@ export function RecentlyViewedDashboards() {
|
||||
</>
|
||||
)}
|
||||
{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 && (
|
||||
<ul className={styles.list}>
|
||||
|
||||
@@ -128,7 +128,7 @@ describe('PanelTimeRange', () => {
|
||||
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' });
|
||||
|
||||
buildAndActivateSceneFor(panelTime);
|
||||
@@ -139,6 +139,22 @@ describe('PanelTimeRange', () => {
|
||||
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', () => {
|
||||
const panelTime = new PanelTimeRange({
|
||||
timeFrom: 'now-2h',
|
||||
@@ -153,6 +169,66 @@ describe('PanelTimeRange', () => {
|
||||
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', () => {
|
||||
it('should reverse timeShift when updating time range', () => {
|
||||
const oneHourShift = '1h';
|
||||
|
||||
@@ -81,7 +81,19 @@ export class PanelTimeRange extends SceneTimeRangeTransformerBase<PanelTimeRange
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -153,10 +165,10 @@ export class PanelTimeRange extends SceneTimeRangeTransformerBase<PanelTimeRange
|
||||
|
||||
// Only evaluate if the timeFrom if parent time is relative
|
||||
if (rangeUtil.isRelativeTimeRange(parentTimeRange.raw)) {
|
||||
const timeZone = this.getTimeZone();
|
||||
const timezone = this.getTimeZone();
|
||||
newTimeData.timeRange = {
|
||||
from: dateMath.parse(timeFromInfo.from, undefined, timeZone)!,
|
||||
to: dateMath.parse(timeFromInfo.to, undefined, timeZone)!,
|
||||
from: dateMath.toDateTime(timeFromInfo.from, { timezone })!,
|
||||
to: dateMath.toDateTime(timeFromInfo.to, { timezone })!,
|
||||
raw: { from: timeFromInfo.from, to: timeFromInfo.to },
|
||||
};
|
||||
infoBlocks.push(timeFromInfo.display);
|
||||
@@ -172,18 +184,39 @@ export class PanelTimeRange extends SceneTimeRangeTransformerBase<PanelTimeRange
|
||||
return newTimeData;
|
||||
}
|
||||
|
||||
const timeShift = '-' + timeShiftInterpolated;
|
||||
infoBlocks.push('timeshift ' + timeShift);
|
||||
const shift = '-' + timeShiftInterpolated;
|
||||
infoBlocks.push('timeshift ' + shift);
|
||||
|
||||
const from = dateMath.parseDateMath(timeShift, newTimeData.timeRange.from, false)!;
|
||||
const to = dateMath.parseDateMath(timeShift, newTimeData.timeRange.to, true)!;
|
||||
if (rangeUtil.isRelativeTimeRange(newTimeData.timeRange.raw)) {
|
||||
const timezone = this.getTimeZone();
|
||||
|
||||
if (!from || !to) {
|
||||
newTimeData.timeInfo = 'invalid timeshift';
|
||||
return newTimeData;
|
||||
const rawFromShifted = `${newTimeData.timeRange.raw.from}${shift}`;
|
||||
const rawToShifted = `${newTimeData.timeRange.raw.to}${shift}`;
|
||||
|
||||
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) {
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
useStyles2,
|
||||
} from '@grafana/ui';
|
||||
import { FILTER_FOR_OPERATOR, FILTER_OUT_OPERATOR } from '@grafana/ui/internal';
|
||||
import { LogsFrame } from 'app/features/logs/logsFrame';
|
||||
import { DATAPLANE_ID_NAME, LogsFrame } from 'app/features/logs/logsFrame';
|
||||
|
||||
import { getFieldLinksForExplore } from '../utils/links';
|
||||
|
||||
@@ -154,9 +154,9 @@ export function LogsTable(props: Props) {
|
||||
},
|
||||
});
|
||||
// `getLinks` and `applyFieldOverrides` are taken from TableContainer.tsx
|
||||
for (const [index, field] of frameWithOverrides.fields.entries()) {
|
||||
for (const [fieldIdx, field] of frameWithOverrides.fields.entries()) {
|
||||
// Hide ID field from visualization (it's only needed for row matching)
|
||||
if (logsFrame?.idField && (field.name === logsFrame.idField.name || field.name === 'id')) {
|
||||
if (logsFrame?.idField && (field.name === logsFrame.idField.name || field.name === DATAPLANE_ID_NAME)) {
|
||||
field.config = {
|
||||
...field.config,
|
||||
custom: {
|
||||
@@ -180,7 +180,7 @@ export function LogsTable(props: Props) {
|
||||
};
|
||||
|
||||
// For the first field (time), wrap the cell to include action buttons
|
||||
const isFirstField = index === 0;
|
||||
const isFirstField = fieldIdx === 0;
|
||||
|
||||
field.config = {
|
||||
...field.config,
|
||||
@@ -202,7 +202,6 @@ export function LogsTable(props: Props) {
|
||||
panelState={props.panelState}
|
||||
absoluteRange={props.absoluteRange}
|
||||
logRows={props.logRows}
|
||||
rowIndex={cellProps.rowIndex}
|
||||
/>
|
||||
<span className={styles.firstColumnCell}>
|
||||
{cellProps.field.display?.(cellProps.value).text ?? String(cellProps.value)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useState, memo } from 'react';
|
||||
|
||||
import {
|
||||
AbsoluteTimeRange,
|
||||
@@ -13,7 +13,7 @@ import { t } from '@grafana/i18n';
|
||||
import { ClipboardButton, CustomCellRendererProps, IconButton, Modal, useTheme2 } from '@grafana/ui';
|
||||
import { getLogsPermalinkRange } from 'app/core/utils/shortLinks';
|
||||
import { getUrlStateFromPaneState } from 'app/features/explore/hooks/useStateSync';
|
||||
import { LogsFrame } from 'app/features/logs/logsFrame';
|
||||
import { LogsFrame, DATAPLANE_ID_NAME } from 'app/features/logs/logsFrame';
|
||||
import { getState } from 'app/store/store';
|
||||
|
||||
import { getExploreBaseUrl } from './utils/url';
|
||||
@@ -28,25 +28,20 @@ interface Props extends CustomCellRendererProps {
|
||||
index?: number;
|
||||
}
|
||||
|
||||
export function LogsTableActionButtons(props: Props) {
|
||||
export const LogsTableActionButtons = memo((props: Props) => {
|
||||
const { exploreId, absoluteRange, logRows, rowIndex, panelState, displayedFields, logsFrame, frame } = props;
|
||||
|
||||
const theme = useTheme2();
|
||||
const [isInspecting, setIsInspecting] = useState(false);
|
||||
// Get logId from the table frame (frame), not the original logsFrame, because
|
||||
// the table frame is sorted/transformed and rowIndex refers to the table frame
|
||||
const idFieldName = logsFrame?.idField?.name ?? 'id';
|
||||
const idField = frame.fields.find((field) => field.name === idFieldName || field.name === 'id');
|
||||
const idFieldName = logsFrame?.idField?.name ?? DATAPLANE_ID_NAME;
|
||||
const idField = frame.fields.find((field) => field.name === idFieldName || field.name === DATAPLANE_ID_NAME);
|
||||
const logId = idField?.values[rowIndex];
|
||||
const getLineValue = () => {
|
||||
const bodyFieldName = logsFrame?.bodyField?.name;
|
||||
const bodyField = bodyFieldName
|
||||
? frame.fields.find((field) => field.name === bodyFieldName)
|
||||
: frame.fields.find((field) => field.type === 'string');
|
||||
return bodyField?.values[rowIndex];
|
||||
};
|
||||
|
||||
const lineValue = getLineValue();
|
||||
const getLineValue = () => {
|
||||
const logRowById = logRows?.find((row) => row.rowId === logId);
|
||||
return logRowById?.raw ?? '';
|
||||
};
|
||||
|
||||
const styles = getStyles(theme);
|
||||
|
||||
@@ -105,33 +100,29 @@ export function LogsTableActionButtons(props: Props) {
|
||||
return (
|
||||
<>
|
||||
<div className={styles.iconWrapper}>
|
||||
<div className={styles.inspect}>
|
||||
<IconButton
|
||||
className={styles.inspectButton}
|
||||
tooltip={t('explore.logs-table.action-buttons.view-log-line', 'View log line')}
|
||||
variant="secondary"
|
||||
aria-label={t('explore.logs-table.action-buttons.view-log-line', 'View log line')}
|
||||
tooltipPlacement="top"
|
||||
size="md"
|
||||
name="eye"
|
||||
onClick={handleViewClick}
|
||||
tabIndex={0}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.inspect}>
|
||||
<ClipboardButton
|
||||
className={styles.clipboardButton}
|
||||
icon="share-alt"
|
||||
variant="secondary"
|
||||
fill="text"
|
||||
size="md"
|
||||
tooltip={t('explore.logs-table.action-buttons.copy-link', 'Copy link to log line')}
|
||||
tooltipPlacement="top"
|
||||
tabIndex={0}
|
||||
aria-label={t('explore.logs-table.action-buttons.copy-link', 'Copy link to log line')}
|
||||
getText={getText}
|
||||
/>
|
||||
</div>
|
||||
<IconButton
|
||||
className={styles.icon}
|
||||
tooltip={t('explore.logs-table.action-buttons.view-log-line', 'View log line')}
|
||||
variant="secondary"
|
||||
aria-label={t('explore.logs-table.action-buttons.view-log-line', 'View log line')}
|
||||
tooltipPlacement="top"
|
||||
size="md"
|
||||
name="eye"
|
||||
onClick={handleViewClick}
|
||||
tabIndex={0}
|
||||
/>
|
||||
<ClipboardButton
|
||||
className={styles.icon}
|
||||
icon="share-alt"
|
||||
variant="secondary"
|
||||
fill="text"
|
||||
size="md"
|
||||
tooltip={t('explore.logs-table.action-buttons.copy-link', 'Copy link to log line')}
|
||||
tooltipPlacement="top"
|
||||
tabIndex={0}
|
||||
aria-label={t('explore.logs-table.action-buttons.copy-link', 'Copy link to log line')}
|
||||
getText={getText}
|
||||
/>
|
||||
</div>
|
||||
{isInspecting && (
|
||||
<Modal
|
||||
@@ -139,9 +130,9 @@ export function LogsTableActionButtons(props: Props) {
|
||||
isOpen={true}
|
||||
title={t('explore.logs-table.action-buttons.inspect-value', 'Inspect value')}
|
||||
>
|
||||
<pre>{lineValue}</pre>
|
||||
<pre>{getLineValue()}</pre>
|
||||
<Modal.ButtonRow>
|
||||
<ClipboardButton icon="copy" getText={() => lineValue}>
|
||||
<ClipboardButton icon="copy" getText={() => getLineValue()}>
|
||||
{t('explore.logs-table.action-buttons.copy-to-clipboard', 'Copy to Clipboard')}
|
||||
</ClipboardButton>
|
||||
</Modal.ButtonRow>
|
||||
@@ -149,15 +140,11 @@ export function LogsTableActionButtons(props: Props) {
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export const getStyles = (theme: GrafanaTheme2) => ({
|
||||
clipboardButton: css({
|
||||
height: '100%',
|
||||
lineHeight: '1',
|
||||
padding: 0,
|
||||
width: '20px',
|
||||
}),
|
||||
LogsTableActionButtons.displayName = 'LogsTableActionButtons';
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
iconWrapper: css({
|
||||
background: theme.colors.background.secondary,
|
||||
boxShadow: theme.shadows.z2,
|
||||
@@ -166,25 +153,50 @@ export const getStyles = (theme: GrafanaTheme2) => ({
|
||||
height: '35px',
|
||||
left: 0,
|
||||
top: 0,
|
||||
padding: `0 ${theme.spacing(0.5)}`,
|
||||
padding: 0,
|
||||
position: 'absolute',
|
||||
zIndex: 1,
|
||||
alignItems: 'center',
|
||||
// Fix switching icon direction when cell is numeric (rtl)
|
||||
direction: 'ltr',
|
||||
}),
|
||||
inspect: css({
|
||||
'& button svg': {
|
||||
marginRight: 'auto',
|
||||
icon: css({
|
||||
gap: 0,
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
borderRadius: theme.shape.radius.default,
|
||||
width: '28px',
|
||||
height: '32px',
|
||||
display: 'inline-flex',
|
||||
justifyContent: 'center',
|
||||
|
||||
'&:before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
width: 24,
|
||||
height: 24,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
margin: 'auto',
|
||||
borderRadius: theme.shape.radius.default,
|
||||
backgroundColor: theme.colors.background.primary,
|
||||
zIndex: -1,
|
||||
opacity: 0,
|
||||
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
|
||||
transitionDuration: '0.2s',
|
||||
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
transitionProperty: 'opacity',
|
||||
},
|
||||
},
|
||||
'&:hover': {
|
||||
color: theme.colors.text.link,
|
||||
cursor: 'pointer',
|
||||
background: 'none',
|
||||
'&:before': {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
padding: '5px 3px',
|
||||
}),
|
||||
inspectButton: css({
|
||||
borderRadius: theme.shape.radius.default,
|
||||
display: 'inline-flex',
|
||||
margin: 0,
|
||||
overflow: 'hidden',
|
||||
verticalAlign: 'middle',
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@ function getField(cache: FieldCache, name: string, fieldType: FieldType): FieldW
|
||||
const DATAPLANE_TIMESTAMP_NAME = 'timestamp';
|
||||
const DATAPLANE_BODY_NAME = 'body';
|
||||
const DATAPLANE_SEVERITY_NAME = 'severity';
|
||||
const DATAPLANE_ID_NAME = 'id';
|
||||
export const DATAPLANE_ID_NAME = 'id';
|
||||
const DATAPLANE_LABELS_NAME = 'labels';
|
||||
|
||||
// NOTE: this is a hot fn, we need to avoid allocating new objects here
|
||||
|
||||
@@ -293,6 +293,7 @@ export interface GrafanaRuleDefinition extends PostableGrafanaRuleDefinition {
|
||||
updated?: string;
|
||||
updated_by?: UpdatedBy | null;
|
||||
version?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// types for Grafana-managed recording and alerting rules
|
||||
|
||||
@@ -3759,7 +3759,6 @@
|
||||
},
|
||||
"recently-viewed": {
|
||||
"clear": "Clear history",
|
||||
"empty": "Nothing viewed yet",
|
||||
"error": "Recently viewed dashboards couldn’t be loaded.",
|
||||
"retry": "Retry",
|
||||
"title": "Recently viewed"
|
||||
@@ -4416,6 +4415,7 @@
|
||||
},
|
||||
"no-properties-changed": "No relevant properties changed",
|
||||
"table": {
|
||||
"notes": "Notes",
|
||||
"updated": "Date",
|
||||
"updatedBy": "Updated By",
|
||||
"version": "Version"
|
||||
|
||||
Reference in New Issue
Block a user