diff --git a/docs/sources/alerting/monitor-status/view-alert-rules.md b/docs/sources/alerting/monitor-status/view-alert-rules.md index b60ede12328..e5410d48765 100644 --- a/docs/sources/alerting/monitor-status/view-alert-rules.md +++ b/docs/sources/alerting/monitor-status/view-alert-rules.md @@ -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 diff --git a/docs/sources/tutorials/alerting-get-started-pt6/index.md b/docs/sources/tutorials/alerting-get-started-pt6/index.md index 470330237e2..edf5e61e178 100644 --- a/docs/sources/tutorials/alerting-get-started-pt6/index.md +++ b/docs/sources/tutorials/alerting-get-started-pt6/index.md @@ -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" >}} + In this tutorial you will learn how to: diff --git a/go.mod b/go.mod index 2302b2dcd4b..4d36534519e 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/packages/grafana-data/src/datetime/datemath.test.ts b/packages/grafana-data/src/datetime/datemath.test.ts index f53bbe8e6e5..3abf59ea43f 100644 --- a/packages/grafana-data/src/datetime/datemath.test.ts +++ b/packages/grafana-data/src/datetime/datemath.test.ts @@ -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', () => { diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 9e08a2efbee..5d9ad02dbc7 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -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; diff --git a/pkg/extensions/enterprise_imports.go b/pkg/extensions/enterprise_imports.go index feaf1755c94..b0e748422a1 100644 --- a/pkg/extensions/enterprise_imports.go +++ b/pkg/extensions/enterprise_imports.go @@ -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" diff --git a/pkg/infra/tracing/tracing.go b/pkg/infra/tracing/tracing.go index f8b340d12df..276014c7d6b 100644 --- a/pkg/infra/tracing/tracing.go +++ b/pkg/infra/tracing/tracing.go @@ -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 diff --git a/pkg/registry/apps/plugins/register.go b/pkg/registry/apps/plugins/register.go index 68082f59713..d29da1eb6c4 100644 --- a/pkg/registry/apps/plugins/register.go +++ b/pkg/registry/apps/plugins/register.go @@ -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) diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 1cb7301fb36..cd4844ba79e 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -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 } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 48115e6857d..5ec4bfb880b 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -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", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 92a1615be40..20009d3f30b 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -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 diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index a9b78e0d35f..ddd6d3bbc0d 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -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", diff --git a/pkg/storage/unified/README.md b/pkg/storage/unified/README.md index e9bdbf88e37..36bc5b2cea7 100644 --- a/pkg/storage/unified/README.md +++ b/pkg/storage/unified/README.md @@ -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). - \ No newline at end of file diff --git a/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource.sql b/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource.sql index 1f58bd28b43..2c034f4d757 100644 --- a/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource.sql +++ b/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource.sql @@ -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 }} ); diff --git a/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource_history.sql b/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource_history.sql index d52aac5063d..437d3ae9107 100644 --- a/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource_history.sql +++ b/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource_history.sql @@ -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 }} ); diff --git a/pkg/storage/unified/resource/data/sqlkv_update_legacy_resource.sql b/pkg/storage/unified/resource/data/sqlkv_update_legacy_resource.sql index 1565d0894a4..3c9f92d4de5 100644 --- a/pkg/storage/unified/resource/data/sqlkv_update_legacy_resource.sql +++ b/pkg/storage/unified/resource/data/sqlkv_update_legacy_resource.sql @@ -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 }} diff --git a/pkg/storage/unified/resource/data/sqlkv_update_legacy_resource_history.sql b/pkg/storage/unified/resource/data/sqlkv_update_legacy_resource_history.sql new file mode 100644 index 00000000000..ac1f135fd76 --- /dev/null +++ b/pkg/storage/unified/resource/data/sqlkv_update_legacy_resource_history.sql @@ -0,0 +1,5 @@ +UPDATE {{ .Ident "resource_history" }} +SET + {{ .Ident "previous_resource_version" }} = {{ .Arg .PreviousRV }}, + {{ .Ident "generation" }} = {{ .Arg .Generation }} + WHERE {{ .Ident "guid" }} = {{ .Arg .GUID }}; diff --git a/pkg/storage/unified/resource/datastore.go b/pkg/storage/unified/resource/datastore.go index 7a1b614323e..313f7d43852 100644 --- a/pkg/storage/unified/resource/datastore.go +++ b/pkg/storage/unified/resource/datastore.go @@ -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 +} diff --git a/pkg/storage/unified/resource/sqlkv.go b/pkg/storage/unified/resource/sqlkv.go index 6d406294a96..bae3c776d79 100644 --- a/pkg/storage/unified/resource/sqlkv.go +++ b/pkg/storage/unified/resource/sqlkv.go @@ -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 } diff --git a/pkg/storage/unified/resource/storage_backend.go b/pkg/storage/unified/resource/storage_backend.go index 13f2b9d6159..dffecbd789c 100644 --- a/pkg/storage/unified/resource/storage_backend.go +++ b/pkg/storage/unified/resource/storage_backend.go @@ -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 { diff --git a/pkg/tests/apis/plugins/plugins_test.go b/pkg/tests/apis/plugins/plugins_test.go index d8c45713337..0844bda9757 100644 --- a/pkg/tests/apis/plugins/plugins_test.go +++ b/pkg/tests/apis/plugins/plugins_test.go @@ -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 diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx index 0d8636c98bc..fcd9edebb2e 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx @@ -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(); + }); }); }); diff --git a/public/app/features/alerting/unified/components/rule-viewer/tabs/version-history/VersionHistoryTable.tsx b/public/app/features/alerting/unified/components/rule-viewer/tabs/version-history/VersionHistoryTable.tsx index 74dabe18449..ac6c75b93ef 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/tabs/version-history/VersionHistoryTable.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/tabs/version-history/VersionHistoryTable.tsx @@ -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>(); 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) => { setShowConfirmModal(true); setRuleToRestore(ruleToRestore); @@ -52,6 +56,15 @@ export function VersionHistoryTable({ const unknown = t('alerting.alertVersionHistory.unknown', 'Unknown'); + const notesColumn: Column> = { + id: 'notes', + header: t('core.versionHistory.table.notes', 'Notes'), + cell: ({ row }) => { + const message = row.original.grafana_alert.message; + return message || null; + }, + }; + const columns: Array>> = [ { disableGrow: true, @@ -91,9 +104,12 @@ export function VersionHistoryTable({ if (!value) { return unknown; } - return dateTimeFormat(value) + ' (' + dateTimeFormatTimeAgo(value) + ')'; + return ( + {dateTimeFormat(value) + ' (' + dateTimeFormatTimeAgo(value) + ')'} + ); }, }, + ...(hasAnyNotes ? [notesColumn] : []), { id: 'diff', disableGrow: true, @@ -179,3 +195,9 @@ export function VersionHistoryTable({ ); } + +const getStyles = () => ({ + nowrap: css({ + whiteSpace: 'nowrap', + }), +}); diff --git a/public/app/features/alerting/unified/components/silences/utils.ts b/public/app/features/alerting/unified/components/silences/utils.ts index a0909f7e647..af3ba2cbfcb 100644 --- a/public/app/features/alerting/unified/components/silences/utils.ts +++ b/public/app/features/alerting/unified/components/silences/utils.ts @@ -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) || [], diff --git a/public/app/features/alerting/unified/mocks/server/handlers/grafanaRuler.ts b/public/app/features/alerting/unified/mocks/server/handlers/grafanaRuler.ts index b0f58408306..85905d480b3 100644 --- a/public/app/features/alerting/unified/mocks/server/handlers/grafanaRuler.ts +++ b/public/app/features/alerting/unified/mocks/server/handlers/grafanaRuler.ts @@ -154,6 +154,7 @@ export const rulerRuleVersionHistoryHandler = () => { uid: 'service', name: '', }; + draft.grafana_alert.message = 'Updated by provisioning service'; }), produce(grafanaRulerRule, (draft: RulerGrafanaRuleDTO) => { 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) => { 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) => { draft.grafana_alert.version = 1; diff --git a/public/app/features/browse-dashboards/components/RecentlyViewedDashboards.tsx b/public/app/features/browse-dashboards/components/RecentlyViewedDashboards.tsx index 8109de32145..effa0c8009b 100644 --- a/public/app/features/browse-dashboards/components/RecentlyViewedDashboards.tsx +++ b/public/app/features/browse-dashboards/components/RecentlyViewedDashboards.tsx @@ -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 && } - {/* TODO: Better empty state https://github.com/grafana/grafana/issues/114804 */} - {!loading && recentDashboards.length === 0 && ( - {t('browse-dashboards.recently-viewed.empty', 'Nothing viewed yet')} - )} {!loading && recentDashboards.length > 0 && (
    diff --git a/public/app/features/dashboard-scene/scene/panel-timerange/PanelTimeRange.test.tsx b/public/app/features/dashboard-scene/scene/panel-timerange/PanelTimeRange.test.tsx index 8bce6364a34..a31927a3e92 100644 --- a/public/app/features/dashboard-scene/scene/panel-timerange/PanelTimeRange.test.tsx +++ b/public/app/features/dashboard-scene/scene/panel-timerange/PanelTimeRange.test.tsx @@ -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'; diff --git a/public/app/features/dashboard-scene/scene/panel-timerange/PanelTimeRange.tsx b/public/app/features/dashboard-scene/scene/panel-timerange/PanelTimeRange.tsx index 04f3eac85e2..7fe4133a7fa 100644 --- a/public/app/features/dashboard-scene/scene/panel-timerange/PanelTimeRange.tsx +++ b/public/app/features/dashboard-scene/scene/panel-timerange/PanelTimeRange.tsx @@ -81,7 +81,19 @@ export class PanelTimeRange extends SceneTimeRangeTransformerBase {cellProps.field.display?.(cellProps.value).text ?? String(cellProps.value)} diff --git a/public/app/features/explore/Logs/LogsTableActionButtons.tsx b/public/app/features/explore/Logs/LogsTableActionButtons.tsx index 88e3c1db4ed..f60d5eaa072 100644 --- a/public/app/features/explore/Logs/LogsTableActionButtons.tsx +++ b/public/app/features/explore/Logs/LogsTableActionButtons.tsx @@ -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 ( <>
    -
    - -
    -
    - -
    + +
    {isInspecting && ( -
    {lineValue}
    +
    {getLineValue()}
    - lineValue}> + getLineValue()}> {t('explore.logs-table.action-buttons.copy-to-clipboard', 'Copy to Clipboard')} @@ -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', }), }); diff --git a/public/app/features/logs/logsFrame.ts b/public/app/features/logs/logsFrame.ts index 5a9938b6e2b..90a3439d3c9 100644 --- a/public/app/features/logs/logsFrame.ts +++ b/public/app/features/logs/logsFrame.ts @@ -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 diff --git a/public/app/types/unified-alerting-dto.ts b/public/app/types/unified-alerting-dto.ts index c6440d0bac1..8bc02c3e7bd 100644 --- a/public/app/types/unified-alerting-dto.ts +++ b/public/app/types/unified-alerting-dto.ts @@ -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 diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 6aacf5f3939..2174ff0adbc 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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"