From 68f1730461427dd6d3d5b077a7963897091225cf Mon Sep 17 00:00:00 2001 From: Yuri Tseretyan Date: Tue, 4 Feb 2025 14:23:15 -0500 Subject: [PATCH 01/31] Alerting: set updated_by for system owned operations (#100068) --- pkg/services/ngalert/api/api_ruler.go | 21 +++++++++++++++---- pkg/services/ngalert/api/api_ruler_test.go | 18 ++++++++++++++++ pkg/services/ngalert/models/alert_rule.go | 5 +++++ .../ngalert/provisioning/alert_rules.go | 18 +++++++++++----- pkg/services/ngalert/store/alert_rule.go | 4 ++-- 5 files changed, 55 insertions(+), 11 deletions(-) diff --git a/pkg/services/ngalert/api/api_ruler.go b/pkg/services/ngalert/api/api_ruler.go index 215048e448c..1f122c04ad1 100644 --- a/pkg/services/ngalert/api/api_ruler.go +++ b/pkg/services/ngalert/api/api_ruler.go @@ -757,23 +757,36 @@ type userIDToUserInfoFn func(id *ngmodels.UserUID) *apimodels.UserInfo // getIdentityName returns name of either user or service account func (srv RulerSrv) resolveUserIdToNameFn(ctx context.Context) userIDToUserInfoFn { + cache := map[ngmodels.UserUID]*apimodels.UserInfo{ + ngmodels.AlertingUserUID: { + UID: string(ngmodels.AlertingUserUID), + }, + ngmodels.FileProvisioningUserUID: { + UID: string(ngmodels.FileProvisioningUserUID), + }, + } return func(id *ngmodels.UserUID) *apimodels.UserInfo { if id == nil { return nil } + if val, ok := cache[*id]; ok { + return val + } u, err := srv.userService.GetByUID(ctx, &user.GetUserByUIDQuery{ UID: string(*id), }) - var result string + var name string if err != nil { srv.log.FromContext(ctx).Warn("Failed to get user by uid. Defaulting to an empty name", "uid", id, "error", err) } if u != nil { - result = u.NameOrFallback() + name = u.NameOrFallback() } - return &apimodels.UserInfo{ + result := &apimodels.UserInfo{ UID: string(*id), - Name: result, + Name: name, } + cache[*id] = result + return result } } diff --git a/pkg/services/ngalert/api/api_ruler_test.go b/pkg/services/ngalert/api/api_ruler_test.go index dce8f9a801c..73cf2bb6300 100644 --- a/pkg/services/ngalert/api/api_ruler_test.go +++ b/pkg/services/ngalert/api/api_ruler_test.go @@ -404,6 +404,24 @@ func TestRouteGetRuleByUID(t *testing.T) { Name: "Test", }, }, + { + desc: "recognize system identifier (alerting)", + UpdatedBy: &models.AlertingUserUID, + User: nil, + UserServiceError: nil, + Expected: &apimodels.UserInfo{ + UID: string(models.AlertingUserUID), + }, + }, + { + desc: "recognize system identifier (provisioning)", + UpdatedBy: &models.FileProvisioningUserUID, + User: nil, + UserServiceError: nil, + Expected: &apimodels.UserInfo{ + UID: string(models.FileProvisioningUserUID), + }, + }, } for _, tc := range testcases { t.Run(tc.desc, func(t *testing.T) { diff --git a/pkg/services/ngalert/models/alert_rule.go b/pkg/services/ngalert/models/alert_rule.go index 07a5eb86280..b7da813141f 100644 --- a/pkg/services/ngalert/models/alert_rule.go +++ b/pkg/services/ngalert/models/alert_rule.go @@ -47,6 +47,11 @@ var ( ErrNoPanel = errors.New("no panel") ) +var ( + FileProvisioningUserUID = UserUID("__provisioning__") + AlertingUserUID = UserUID("__alerting__") +) + // swagger:enum NoDataState type NoDataState string diff --git a/pkg/services/ngalert/provisioning/alert_rules.go b/pkg/services/ngalert/provisioning/alert_rules.go index 9b8cd429653..c31413b3bdc 100644 --- a/pkg/services/ngalert/provisioning/alert_rules.go +++ b/pkg/services/ngalert/provisioning/alert_rules.go @@ -232,7 +232,7 @@ func (service *AlertRuleService) CreateAlertRule(ctx context.Context, user ident } } err = service.xact.InTransaction(ctx, func(ctx context.Context) error { - ids, err := service.ruleStore.InsertAlertRules(ctx, models.NewUserUID(user), []models.AlertRule{ + ids, err := service.ruleStore.InsertAlertRules(ctx, userUidOrFallback(user), []models.AlertRule{ rule, }) if err != nil { @@ -359,7 +359,7 @@ func (service *AlertRuleService) UpdateRuleGroup(ctx context.Context, user ident } } - return service.ruleStore.UpdateAlertRules(ctx, models.NewUserUID(user), updateRules) + return service.ruleStore.UpdateAlertRules(ctx, userUidOrFallback(user), updateRules) }) } @@ -511,7 +511,7 @@ func (service *AlertRuleService) persistDelta(ctx context.Context, user identity New: *update.New, }) } - if err := service.ruleStore.UpdateAlertRules(ctx, models.NewUserUID(user), updates); err != nil { + if err := service.ruleStore.UpdateAlertRules(ctx, userUidOrFallback(user), updates); err != nil { return fmt.Errorf("failed to update alert rules: %w", err) } for _, update := range delta.Update { @@ -522,7 +522,7 @@ func (service *AlertRuleService) persistDelta(ctx context.Context, user identity } if len(delta.New) > 0 { - uids, err := service.ruleStore.InsertAlertRules(ctx, models.NewUserUID(user), withoutNilAlertRules(delta.New)) + uids, err := service.ruleStore.InsertAlertRules(ctx, userUidOrFallback(user), withoutNilAlertRules(delta.New)) if err != nil { return fmt.Errorf("failed to insert alert rules: %w", err) } @@ -618,7 +618,7 @@ func (service *AlertRuleService) UpdateAlertRule(ctx context.Context, user ident return models.AlertRule{}, err } err = service.xact.InTransaction(ctx, func(ctx context.Context) error { - err := service.ruleStore.UpdateAlertRules(ctx, models.NewUserUID(user), []models.UpdateRule{ + err := service.ruleStore.UpdateAlertRules(ctx, userUidOrFallback(user), []models.UpdateRule{ { Existing: storedRule, New: rule, @@ -871,3 +871,11 @@ func (service *AlertRuleService) ensureNamespace(ctx context.Context, user ident return nil } + +func userUidOrFallback(user identity.Requester) *models.UserUID { + userUID := models.NewUserUID(user) + if user == nil { + return &models.FileProvisioningUserUID + } + return userUID +} diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go index adcfe8450fa..fbc9e061385 100644 --- a/pkg/services/ngalert/store/alert_rule.go +++ b/pkg/services/ngalert/store/alert_rule.go @@ -988,7 +988,7 @@ func (st DBstore) RenameReceiverInNotificationSettings(ctx context.Context, orgI } // Provide empty user identifier to ensure it's clear that the rule update was made by the system // and not by the user who changed the receiver's title. - return result, nil, st.UpdateAlertRules(ctx, nil, updates) + return result, nil, st.UpdateAlertRules(ctx, &ngmodels.AlertingUserUID, updates) } // RenameTimeIntervalInNotificationSettings renames all rules that use old time interval name to the new name. @@ -1065,7 +1065,7 @@ func (st DBstore) RenameTimeIntervalInNotificationSettings( } // Provide empty user identifier to ensure it's clear that the rule update was made by the system // and not by the user who changed the receiver's title. - return result, nil, st.UpdateAlertRules(ctx, nil, updates) + return result, nil, st.UpdateAlertRules(ctx, &ngmodels.AlertingUserUID, updates) } func ruleConstraintViolationToErr(sess *db.Session, rule ngmodels.AlertRule, err error, logger log.Logger) error { From f9c4d3edce8e8ad827dc91b18415502802123cc3 Mon Sep 17 00:00:00 2001 From: owensmallwood Date: Tue, 4 Feb 2025 13:33:44 -0600 Subject: [PATCH 02/31] Unified Storage: Updates index latency logging (#100085) updates index latency logging --- pkg/storage/unified/resource/search.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/storage/unified/resource/search.go b/pkg/storage/unified/resource/search.go index 6d03f97e6f2..f0d18960dc5 100644 --- a/pkg/storage/unified/resource/search.go +++ b/pkg/storage/unified/resource/search.go @@ -460,9 +460,9 @@ func (s *searchSupport) handleEvent(ctx context.Context, evt *WrittenEvent) { // record latency from when event was created to when it was indexed latencySeconds := float64(time.Now().UnixMicro()-evt.ResourceVersion) / 1e6 span.AddEvent("index latency", trace.WithAttributes(attribute.Float64("latency_seconds", latencySeconds))) - if latencySeconds > 5 { - s.log.Debug("high index latency object details", "resource", evt.Key.Resource, "latency_seconds", latencySeconds, "name", evt.Key.Name, "namespace", evt.Key.Namespace) - s.log.Warn("high index latency", "latency", latencySeconds) + s.log.Debug("indexed new object", "resource", evt.Key.Resource, "latency_seconds", latencySeconds, "name", evt.Key.Name, "namespace", evt.Key.Namespace, "rv", evt.ResourceVersion) + if latencySeconds > 1 { + s.log.Warn("high index latency object details", "resource", evt.Key.Resource, "latency_seconds", latencySeconds, "name", evt.Key.Name, "namespace", evt.Key.Namespace, "rv", evt.ResourceVersion) } if IndexMetrics != nil { IndexMetrics.IndexLatency.WithLabelValues(evt.Key.Resource).Observe(latencySeconds) From 7ebc81fbbfcd270ca077688c77a115ad0ecdcc44 Mon Sep 17 00:00:00 2001 From: Brendan O'Handley Date: Tue, 4 Feb 2025 15:18:30 -0600 Subject: [PATCH 03/31] Explore metrics: Fix otel bug (#100092) --- public/app/features/trails/otel/util.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/public/app/features/trails/otel/util.ts b/public/app/features/trails/otel/util.ts index cd96351c28b..166d089ff4e 100644 --- a/public/app/features/trails/otel/util.ts +++ b/public/app/features/trails/otel/util.ts @@ -522,6 +522,7 @@ export async function updateOtelData( resettingOtel: false, afterFirstOtelCheck: true, isUpdatingOtel: false, + nonPromotedOtelResources, }); } } From 17e21bff977fe970b2a7f63ad7f9cfb616d099ce Mon Sep 17 00:00:00 2001 From: Todd Treece <360020+toddtreece@users.noreply.github.com> Date: Tue, 4 Feb 2025 16:22:42 -0500 Subject: [PATCH 04/31] Dashboards: Fix title conversions (#100084) --- pkg/apis/dashboard/v1alpha1/conversion.go | 4 +++- pkg/apis/dashboard/v2alpha1/conversion.go | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/pkg/apis/dashboard/v1alpha1/conversion.go b/pkg/apis/dashboard/v1alpha1/conversion.go index 27bb5313336..358c482c4e3 100644 --- a/pkg/apis/dashboard/v1alpha1/conversion.go +++ b/pkg/apis/dashboard/v1alpha1/conversion.go @@ -34,6 +34,8 @@ func Convert_v0alpha1_Unstructured_To_v1alpha1_DashboardSpec(in *common.Unstruct func Convert_v1alpha1_DashboardSpec_To_v0alpha1_Unstructured(in *DashboardSpec, out *common.Unstructured, s conversion.Scope) error { *out = in.Unstructured - out.Object["title"] = in.Title + if in.Title != "" { + out.Object["title"] = in.Title + } return nil } diff --git a/pkg/apis/dashboard/v2alpha1/conversion.go b/pkg/apis/dashboard/v2alpha1/conversion.go index 7dcb7487b59..280cac67b14 100644 --- a/pkg/apis/dashboard/v2alpha1/conversion.go +++ b/pkg/apis/dashboard/v2alpha1/conversion.go @@ -34,6 +34,8 @@ func Convert_v0alpha1_Unstructured_To_v2alpha1_DashboardSpec(in *common.Unstruct func Convert_v2alpha1_DashboardSpec_To_v0alpha1_Unstructured(in *DashboardSpec, out *common.Unstructured, s conversion.Scope) error { *out = in.Unstructured - out.Object["title"] = in.Title + if in.Title != "" { + out.Object["title"] = in.Title + } return nil } From 5ad31ebe398e7dd65f31b076a205fc062d75d3c6 Mon Sep 17 00:00:00 2001 From: Mihai Doarna Date: Wed, 5 Feb 2025 10:55:47 +0200 Subject: [PATCH 05/31] API keys: Migrate API keys to service accounts at startup (#96924) * migrate API keys to SA at startup * send metrics with api key migration stats * address feedback * run API keys migration in a server lock * update logging --- .../serviceaccounts/manager/service.go | 62 +++++++++++++++++++ pkg/services/serviceaccounts/manager/stats.go | 36 +++++++++++ 2 files changed, 98 insertions(+) diff --git a/pkg/services/serviceaccounts/manager/service.go b/pkg/services/serviceaccounts/manager/service.go index bfb3938b65c..2bb64c3646a 100644 --- a/pkg/services/serviceaccounts/manager/service.go +++ b/pkg/services/serviceaccounts/manager/service.go @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/kvstore" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/serverlock" "github.com/grafana/grafana/pkg/infra/usagestats" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/apikey" @@ -39,6 +40,8 @@ type ServiceAccountsService struct { log log.Logger backgroundLog log.Logger secretScanService secretscan.Checker + orgService org.Service + serverLock *serverlock.ServerLockService secretScanEnabled bool secretScanInterval time.Duration @@ -54,6 +57,7 @@ func ProvideServiceAccountsService( orgService org.Service, acService accesscontrol.Service, permissions accesscontrol.ServiceAccountPermissionsService, + serverLockService *serverlock.ServerLockService, ) (*ServiceAccountsService, error) { serviceAccountsStore := database.ProvideServiceAccountsStore( cfg, @@ -71,6 +75,8 @@ func ProvideServiceAccountsService( store: serviceAccountsStore, log: log.New("serviceaccounts"), backgroundLog: log.New("serviceaccounts.background"), + orgService: orgService, + serverLock: serverLockService, } if err := RegisterRoles(acService); err != nil { @@ -102,6 +108,17 @@ func (sa *ServiceAccountsService) Run(ctx context.Context) error { sa.log.Warn("Failed to get usage metrics", "error", err.Error()) } + err := sa.serverLock.LockAndExecute(ctx, "migrate API keys to service accounts", time.Minute*30, func(context.Context) { + err := sa.migrateAPIKeysForAllOrgs(ctx) + if err != nil { + sa.log.Warn("Failed to migrate API keys", "error", err.Error()) + } + }) + + if err != nil { + sa.log.Error("Failed to lock and execute the migration of API keys to service accounts", "error", err) + } + updateStatsTicker := time.NewTicker(metricsCollectionInterval) defer updateStatsTicker.Stop() @@ -299,6 +316,51 @@ func (sa *ServiceAccountsService) MigrateApiKeysToServiceAccounts(ctx context.Co return sa.store.MigrateApiKeysToServiceAccounts(ctx, orgID) } +func (sa *ServiceAccountsService) migrateAPIKeysForAllOrgs(ctx context.Context) error { + sa.log.Debug("Starting to migrate API keys to service accounts") + + total := 0 + migrated := 0 + failed := 0 + errorsTotal := 0 + + defer func() { + if total > 0 || errorsTotal > 0 { + sa.log.Info("API key migration finished", "total_keys", total, "successful_keys", migrated, "failed_keys", failed, "errors", errorsTotal) + } + setAPIKeyMigrationStats(total, migrated, failed) + }() + + orgs, err := sa.orgService.Search(ctx, &org.SearchOrgsQuery{}) + if err != nil { + return err + } + + for _, o := range orgs { + sa.log.Debug("Migrating API keys for an org", "orgId", o.ID) + + result, err := sa.store.MigrateApiKeysToServiceAccounts(ctx, o.ID) + if err != nil { + sa.log.Warn("Failed to migrate API keys", "error", err.Error(), "orgId", o.ID) + errorsTotal += 1 + continue + } + if result.Failed > 0 { + sa.log.Warn("Some API keys failed to be migrated", "total_keys", result.Total, "failed_keys", result.Failed, "orgId", o.ID) + } else if result.Total > 0 { + sa.log.Info("API key migration was successful", "orgId", o.ID, "total_keys", result.Total) + } else { + sa.log.Debug("No API keys found to migrate", "orgId", o.ID) + } + + total += result.Total + migrated += result.Migrated + failed += result.Failed + } + + return nil +} + func validOrgID(orgID int64) error { if orgID == 0 { return serviceaccounts.ErrServiceAccountInvalidOrgID.Errorf("invalid org ID 0 has been specified") diff --git a/pkg/services/serviceaccounts/manager/stats.go b/pkg/services/serviceaccounts/manager/stats.go index 521688ca66f..a882ad2db41 100644 --- a/pkg/services/serviceaccounts/manager/stats.go +++ b/pkg/services/serviceaccounts/manager/stats.go @@ -20,6 +20,15 @@ var ( // MStatTotalServiceAccountTokens is a metric gauge for total number of service account tokens MStatTotalServiceAccountTokens prometheus.Gauge + // MStatTotalMigratedAPIKeysToSATokens is a metric gauge for total number of API keys to be migrated to service account tokens + MStatTotalMigratedAPIKeysToSATokens prometheus.Gauge + + // MStatSuccessfullyMigratedAPIKeysToSATokens is a metric gauge for total number of successful migrations of API keys to service account tokens + MStatSuccessfullyMigratedAPIKeysToSATokens prometheus.Gauge + + // MStatFailedMigratedAPIKeysToSATokens is a metric gauge for total number of failed migrations of API keys to service account tokens + MStatFailedMigratedAPIKeysToSATokens prometheus.Gauge + Initialised bool = false ) @@ -42,10 +51,31 @@ func init() { Namespace: ExporterName, }) + MStatTotalMigratedAPIKeysToSATokens = prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "stat_total_migrated_api_keys_to_sa_tokens", + Help: "total number of API keys to be migrated to service account tokens", + Namespace: ExporterName, + }) + + MStatSuccessfullyMigratedAPIKeysToSATokens = prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "stat_successfully_migrated_api_keys_to_sa_tokens", + Help: "total number of successful migrations of API keys to service account tokens", + Namespace: ExporterName, + }) + + MStatFailedMigratedAPIKeysToSATokens = prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "stat_failed_migrated_api_keys_to_sa_tokens", + Help: "total number of failed migrations of API keys to service account tokens", + Namespace: ExporterName, + }) + prometheus.MustRegister( MStatTotalServiceAccounts, MStatTotalServiceAccountTokens, MStatTotalServiceAccountsNoRole, + MStatTotalMigratedAPIKeysToSATokens, + MStatSuccessfullyMigratedAPIKeysToSATokens, + MStatFailedMigratedAPIKeysToSATokens, ) } @@ -81,3 +111,9 @@ func (sa *ServiceAccountsService) getUsageMetrics(ctx context.Context) (map[stri return stats, nil } + +func setAPIKeyMigrationStats(total, migrated, failed int) { + MStatTotalMigratedAPIKeysToSATokens.Set(float64(total)) + MStatSuccessfullyMigratedAPIKeysToSATokens.Set(float64(migrated)) + MStatFailedMigratedAPIKeysToSATokens.Set(float64(failed)) +} From bea62aa6150344439c30026b39253d6bf6da48e4 Mon Sep 17 00:00:00 2001 From: Rares Mardare Date: Wed, 5 Feb 2025 10:58:19 +0200 Subject: [PATCH 06/31] Alerting: Update IRM copies in Configuration Tracker (#100069) * updated alerting configuration tracker with new IRM copies * Configuration tracker steps * copy * CI trigger --------- Co-authored-by: Sonia Aguilar --- .../gops/configuration-tracker/irmHooks.ts | 362 +++++++++++------- 1 file changed, 215 insertions(+), 147 deletions(-) diff --git a/public/app/features/gops/configuration-tracker/irmHooks.ts b/public/app/features/gops/configuration-tracker/irmHooks.ts index 1f02021aff6..76c738e80f3 100644 --- a/public/app/features/gops/configuration-tracker/irmHooks.ts +++ b/public/app/features/gops/configuration-tracker/irmHooks.ts @@ -6,6 +6,7 @@ import { useNotificationPolicyRoute } from 'app/features/alerting/unified/compon import { getIrmIfPresentOrIncidentPluginId, getIrmIfPresentOrOnCallPluginId, + getIsIrmPluginPresent, } from 'app/features/alerting/unified/utils/config'; import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; import { RelativeUrl, createRelativeUrl } from 'app/features/alerting/unified/utils/url'; @@ -125,175 +126,242 @@ export function useGetEssentialsConfiguration(): EssentialsConfigurationData { locationService.push(urlToGoWithIntegration); } + function getGrafanaAlertingConfigSteps(): SectionDtoStep[] { + let steps: SectionDtoStep[] = [ + { + title: 'Update default contact point', + description: 'Update the default contact point to a method other than the example email address.', + button: { + type: 'openLink', + urlLink: { + url: `/alerting/notifications`, + queryParams: { search: defaultContactpoint, alertmanager: 'grafana' }, + }, + label: 'Edit', + labelOnDone: 'View', + urlLinkOnDone: { + url: `/alerting/notifications`, + }, + }, + done: isContactPointReady(defaultContactpoint, contactPoints), + }, + ]; + + if (!getIsIrmPluginPresent()) { + steps = [ + ...steps, + { + title: 'Connect alerting to OnCall', + description: 'Create an OnCall integration for an alerting contact point.', + button: { + type: 'openLink', + urlLink: { + url: '/alerting/notifications/receivers/new', + }, + label: 'Connect', + urlLinkOnDone: { + url: '/alerting/notifications', + }, + labelOnDone: 'View', + }, + done: isOnCallContactPointReady(contactPoints), + }, + ]; + } + + steps = [ + ...steps, + { + title: 'Create alert rule', + description: 'Create an alert rule to monitor your system.', + button: { + type: 'openLink', + urlLink: { + url: '/alerting/new', + }, + label: 'Create', + urlLinkOnDone: { + url: '/alerting/list', + }, + labelOnDone: 'View', + }, + done: isCreateAlertRuleDone, + }, + { + title: 'Create SLO', + description: 'Create SLOs to monitor your service.', + button: { + type: 'openLink', + urlLink: { + url: '/a/grafana-slo-app/wizard/new', + }, + label: 'Create', + urlLinkOnDone: { + url: '/a/grafana-slo-app/manage-slos', + }, + labelOnDone: 'View', + }, + done: hasSlo, + }, + { + title: 'Enable SLO alerting', + description: 'Configure SLO alerting to receive notifications when your SLOs are breached.', + button: { + type: 'openLink', + urlLink: { + queryParams: { alertsEnabled: 'disabled' }, + url: '/a/grafana-slo-app/manage-slos', + }, + label: 'Enable', + urlLinkOnDone: { + queryParams: { alertsEnabled: 'enabled' }, + url: '/a/grafana-slo-app/manage-slos', + }, + labelOnDone: 'View', + }, + done: hasSloWithAlert, + }, + ]; + + return steps; + } + const essentialContent: SectionsDto = { sections: [ { title: 'Detect', description: 'Configure Grafana Alerting', - steps: [ - { - title: 'Update default contact point', - description: 'Update the default contact point to a method other than the example email address.', - button: { - type: 'openLink', - urlLink: { - url: `/alerting/notifications`, - queryParams: { search: defaultContactpoint, alertmanager: 'grafana' }, - }, - label: 'Edit', - labelOnDone: 'View', - urlLinkOnDone: { - url: `/alerting/notifications`, - }, - }, - done: isContactPointReady(defaultContactpoint, contactPoints), - }, - { - title: 'Connect alerting to OnCall', - description: 'Create an OnCall integration for an alerting contact point.', - button: { - type: 'openLink', - urlLink: { - url: '/alerting/notifications/receivers/new', - }, - label: 'Connect', - urlLinkOnDone: { - url: '/alerting/notifications', - }, - labelOnDone: 'View', - }, - done: isOnCallContactPointReady(contactPoints), - }, - { - title: 'Create alert rule', - description: 'Create an alert rule to monitor your system.', - button: { - type: 'openLink', - urlLink: { - url: '/alerting/new', - }, - label: 'Create', - urlLinkOnDone: { - url: '/alerting/list', - }, - labelOnDone: 'View', - }, - done: isCreateAlertRuleDone, - }, - { - title: 'Create your first SLO', - description: 'Create SLOs to monitor your service.', - button: { - type: 'openLink', - urlLink: { - url: '/a/grafana-slo-app/wizard/new', - }, - label: 'Create', - urlLinkOnDone: { - url: '/a/grafana-slo-app/manage-slos', - }, - labelOnDone: 'View', - }, - done: hasSlo, - }, - { - title: 'Enable SLO alerting', - description: 'Configure SLO alerting to receive notifications when your SLOs are breached.', - button: { - type: 'openLink', - urlLink: { - queryParams: { alertsEnabled: 'disabled' }, - url: '/a/grafana-slo-app/manage-slos', - }, - label: 'Enable', - urlLinkOnDone: { - queryParams: { alertsEnabled: 'enabled' }, - url: '/a/grafana-slo-app/manage-slos', - }, - labelOnDone: 'View', - }, - done: hasSloWithAlert, - }, - ], + steps: getGrafanaAlertingConfigSteps(), }, { title: 'Respond', description: 'Configure OnCall and Incident', - steps: [ - { - title: 'Initialize Incident plugin', - description: 'Initialize the Incident plugin to declare and manage incidents.', - button: { - type: 'openLink', - urlLink: { - url: `/a/${getIrmIfPresentOrIncidentPluginId()}/walkthrough/generate-key`, + steps: getIsIrmPluginPresent() + ? [ + { + title: 'Connect alerting to IRM', + description: 'Create an IRM integration for an alerting contact point.', + button: { + type: 'openLink', + urlLink: { + url: '/alerting/notifications/receivers/new', + }, + label: 'Connect', + urlLinkOnDone: { + url: '/alerting/notifications', + }, + labelOnDone: 'View', + }, + done: isOnCallContactPointReady(contactPoints), }, - label: 'Initialize', - urlLinkOnDone: { - url: `/a/${getIrmIfPresentOrIncidentPluginId()}`, + { + title: 'Connect IRM to your Slack workspace', + description: + 'Receive alerts and oncall notifications, or automatically create an incident channel and manage incidents directly within your chat environment.', + button: { + type: 'openLink', + urlLink: { + url: `/a/${getIrmIfPresentOrIncidentPluginId()}/integrations/grate.slack`, + }, + label: 'Connect', + urlLinkOnDone: { + url: `/a/${getIrmIfPresentOrIncidentPluginId()}/integrations`, + }, + }, + done: isChatOpsInstalled, }, - labelOnDone: 'View', - }, - done: isIncidentsInstalled, - }, - { - title: 'Connect your Messaging workspace to OnCall', - description: 'Receive alerts and oncall notifications within your chat environment.', - button: { - type: 'openLink', - urlLink: { - url: `/a/${getIrmIfPresentOrOnCallPluginId()}/settings`, - queryParams: { tab: 'ChatOps', chatOpsTab: 'Slack' }, + { + title: 'Add Slack notifications to IRM integrations', + description: 'Select ChatOps channels to route notifications', + button: { + type: 'openLink', + urlLink: { + url: `/a/${getIrmIfPresentOrOnCallPluginId()}/integrations/`, + }, + label: 'Add', + urlLinkOnDone: { + url: `/a/${getIrmIfPresentOrOnCallPluginId()}/integrations/`, + }, + labelOnDone: 'View', + }, + done: is_integration_chatops_connected, }, - label: 'Connect', - urlLinkOnDone: { - url: `/a/${getIrmIfPresentOrOnCallPluginId()}/settings`, - queryParams: { tab: 'ChatOps' }, + ] + : [ + { + title: 'Initialize Incident plugin', + description: 'Initialize the Incident plugin to declare and manage incidents.', + button: { + type: 'openLink', + urlLink: { + url: `/a/${getIrmIfPresentOrIncidentPluginId()}/walkthrough/generate-key`, + }, + label: 'Initialize', + urlLinkOnDone: { + url: `/a/${getIrmIfPresentOrIncidentPluginId()}`, + }, + labelOnDone: 'View', + }, + done: isIncidentsInstalled, }, - labelOnDone: 'View', - }, - done: is_chatops_connected, - }, - { - title: 'Connect your Messaging workspace to Incident', - description: - 'Automatically create an incident channel and manage incidents directly within your chat environment.', - button: { - type: 'openLink', - urlLink: { - url: `/a/${getIrmIfPresentOrIncidentPluginId()}/integrations/grate.slack`, + { + title: 'Connect your Messaging workspace to OnCall', + description: 'Receive alerts and oncall notifications within your chat environment.', + button: { + type: 'openLink', + urlLink: { + url: `/a/${getIrmIfPresentOrOnCallPluginId()}/settings`, + queryParams: { tab: 'ChatOps', chatOpsTab: 'Slack' }, + }, + label: 'Connect', + urlLinkOnDone: { + url: `/a/${getIrmIfPresentOrOnCallPluginId()}/settings`, + queryParams: { tab: 'ChatOps' }, + }, + labelOnDone: 'View', + }, + done: is_chatops_connected, }, - label: 'Connect', - urlLinkOnDone: { - url: `/a/${getIrmIfPresentOrIncidentPluginId()}/integrations`, + { + title: 'Connect your Messaging workspace to Incident', + description: + 'Automatically create an incident channel and manage incidents directly within your chat environment.', + button: { + type: 'openLink', + urlLink: { + url: `/a/${getIrmIfPresentOrIncidentPluginId()}/integrations/grate.slack`, + }, + label: 'Connect', + urlLinkOnDone: { + url: `/a/${getIrmIfPresentOrIncidentPluginId()}/integrations`, + }, + }, + done: isChatOpsInstalled, }, - }, - done: isChatOpsInstalled, - }, - { - title: 'Add Messaging workspace channel to OnCall Integration', - description: 'Select ChatOps channels to route notifications', - button: { - type: 'openLink', - urlLink: { - url: `/a/${getIrmIfPresentOrOnCallPluginId()}/integrations/`, + { + title: 'Add Messaging workspace channel to OnCall Integration', + description: 'Select ChatOps channels to route notifications', + button: { + type: 'openLink', + urlLink: { + url: `/a/${getIrmIfPresentOrOnCallPluginId()}/integrations/`, + }, + label: 'Add', + urlLinkOnDone: { + url: `/a/${getIrmIfPresentOrOnCallPluginId()}/integrations/`, + }, + labelOnDone: 'View', + }, + done: is_integration_chatops_connected, }, - label: 'Add', - urlLinkOnDone: { - url: `/a/${getIrmIfPresentOrOnCallPluginId()}/integrations/`, - }, - labelOnDone: 'View', - }, - done: is_integration_chatops_connected, - }, - ], + ], }, { title: 'Test your configuration', description: '', steps: [ { - title: 'Send OnCall demo alert via Alerting integration', + title: getIsIrmPluginPresent() ? 'Send test alert' : 'Send OnCall demo alert via Alerting integration', description: 'In the integration page, click Send demo alert, to review your notification', button: { type: 'dropDown', From f51571db5d35e9e6e8ff459b4663b1db3c08deb6 Mon Sep 17 00:00:00 2001 From: Bogdan Matei Date: Wed, 5 Feb 2025 11:08:41 +0200 Subject: [PATCH 07/31] Dashboards: Refactor types for dynamic dashboards (#100064) --- .betterer.results | 4 - .../edit-pane/DashboardEditPane.tsx | 4 +- .../edit-pane/DashboardEditableElement.tsx | 9 +- .../edit-pane/ElementEditPane.tsx | 5 +- .../edit-pane/ElementSelection.ts | 9 +- .../MultiSelectedObjectsEditableElement.tsx | 11 +- .../MultiSelectedVizPanelsEditableElement.tsx | 9 +- .../edit-pane/VizPanelEditableElement.tsx | 11 +- .../edit-pane/useEditableElement.ts | 3 +- .../panel-edit/PanelEditor.tsx | 2 +- .../panel-edit/getPanelFrameOptions.tsx | 2 +- .../dashboard-scene/scene/DashboardScene.tsx | 6 +- .../scene/DashboardSceneUrlSync.test.ts | 2 +- .../scene/DashboardSceneUrlSync.ts | 2 +- .../layout-default/DashboardGridItem.tsx | 10 +- .../DefaultGridLayoutManager.test.tsx | 14 +- .../DefaultGridLayoutManager.tsx | 26 +-- .../layout-default/RowRepeaterBehavior.ts | 2 +- .../ResponsiveGridItem.tsx | 9 +- .../ResponsiveGridLayoutManager.tsx | 30 +-- .../MultiSelectedRowItemsElement.tsx | 9 +- .../scene/layout-rows/RowItem.tsx | 12 +- .../layout-rows/RowItemRepeaterBehavior.ts | 2 +- .../scene/layout-rows/RowsLayoutManager.tsx | 26 +-- .../DashboardLayoutSelector.tsx | 8 +- .../scene/layouts-shared/layoutRegistry.ts | 8 +- .../scene/layouts-shared/utils.ts | 2 +- .../features/dashboard-scene/scene/types.ts | 207 ------------------ .../scene/types/BulkActionElement.ts | 8 + .../scene/types/DashboardLayoutItem.ts | 32 +++ .../scene/types/DashboardLayoutManager.ts | 82 +++++++ .../types/DashboardRepeatsProcessedEvent.ts | 10 + .../scene/types/EditableDashboardElement.ts | 40 ++++ .../scene/types/LayoutParent.ts | 14 ++ .../scene/types/LayoutRegistryItem.ts | 20 ++ .../MultiSelectedEditableDashboardElement.ts | 29 +++ .../transformSaveModelSchemaV2ToScene.test.ts | 2 +- .../dashboard-scene/solo/useSoloPanel.ts | 2 +- .../features/dashboard-scene/utils/utils.ts | 2 +- 39 files changed, 337 insertions(+), 348 deletions(-) delete mode 100644 public/app/features/dashboard-scene/scene/types.ts create mode 100644 public/app/features/dashboard-scene/scene/types/BulkActionElement.ts create mode 100644 public/app/features/dashboard-scene/scene/types/DashboardLayoutItem.ts create mode 100644 public/app/features/dashboard-scene/scene/types/DashboardLayoutManager.ts create mode 100644 public/app/features/dashboard-scene/scene/types/DashboardRepeatsProcessedEvent.ts create mode 100644 public/app/features/dashboard-scene/scene/types/EditableDashboardElement.ts create mode 100644 public/app/features/dashboard-scene/scene/types/LayoutParent.ts create mode 100644 public/app/features/dashboard-scene/scene/types/LayoutRegistryItem.ts create mode 100644 public/app/features/dashboard-scene/scene/types/MultiSelectedEditableDashboardElement.ts diff --git a/.betterer.results b/.betterer.results index c9f78d230b0..629774cfa72 100644 --- a/.betterer.results +++ b/.betterer.results @@ -3435,10 +3435,6 @@ exports[`better eslint`] = { "public/app/features/dashboard-scene/scene/UnlinkModal.tsx:5381": [ [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"] ], - "public/app/features/dashboard-scene/scene/types.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] - ], "public/app/features/dashboard-scene/serialization/angularMigration.test.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx index 86ed2e16772..497b45bf109 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx @@ -176,7 +176,7 @@ export function DashboardEditPaneRenderer({ editPane, isCollapsed, onToggleColla {openOverlay && ( - + )} @@ -185,7 +185,7 @@ export function DashboardEditPaneRenderer({ editPane, isCollapsed, onToggleColla return (
- +
); } diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditableElement.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditableElement.tsx index f3814c2a7e1..1e458430481 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardEditableElement.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditableElement.tsx @@ -6,10 +6,11 @@ import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/Pan import { DashboardScene } from '../scene/DashboardScene'; import { useLayoutCategory } from '../scene/layouts-shared/DashboardLayoutSelector'; -import { EditableDashboardElement } from '../scene/types'; +import { EditableDashboardElement } from '../scene/types/EditableDashboardElement'; export class DashboardEditableElement implements EditableDashboardElement { - public isEditableDashboardElement: true = true; + public readonly isEditableDashboardElement = true; + public readonly typeName = 'Dashboard'; public constructor(private dashboard: DashboardScene) {} @@ -47,10 +48,6 @@ export class DashboardEditableElement implements EditableDashboardElement { return [dashboardOptions, layoutCategory]; } - - public getTypeName(): string { - return 'Dashboard'; - } } export function DashboardTitleInput({ dashboard }: { dashboard: DashboardScene }) { diff --git a/public/app/features/dashboard-scene/edit-pane/ElementEditPane.tsx b/public/app/features/dashboard-scene/edit-pane/ElementEditPane.tsx index b60b48fd549..16adfe8004a 100644 --- a/public/app/features/dashboard-scene/edit-pane/ElementEditPane.tsx +++ b/public/app/features/dashboard-scene/edit-pane/ElementEditPane.tsx @@ -4,7 +4,8 @@ import { GrafanaTheme2 } from '@grafana/data'; import { Stack, useStyles2 } from '@grafana/ui'; import { OptionsPaneCategory } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategory'; -import { EditableDashboardElement, MultiSelectedEditableDashboardElement } from '../scene/types'; +import { EditableDashboardElement } from '../scene/types/EditableDashboardElement'; +import { MultiSelectedEditableDashboardElement } from '../scene/types/MultiSelectedEditableDashboardElement'; export interface Props { element: EditableDashboardElement | MultiSelectedEditableDashboardElement; @@ -19,7 +20,7 @@ export function ElementEditPane({ element }: Props) { {element.renderActions && ( diff --git a/public/app/features/dashboard-scene/edit-pane/ElementSelection.ts b/public/app/features/dashboard-scene/edit-pane/ElementSelection.ts index 29f107a2d84..7a5a9ee61af 100644 --- a/public/app/features/dashboard-scene/edit-pane/ElementSelection.ts +++ b/public/app/features/dashboard-scene/edit-pane/ElementSelection.ts @@ -2,12 +2,9 @@ import { SceneObject, SceneObjectRef, VizPanel } from '@grafana/scenes'; import { ElementSelectionContextItem } from '@grafana/ui'; import { DashboardScene } from '../scene/DashboardScene'; -import { - EditableDashboardElement, - isBulkActionElement, - isEditableDashboardElement, - MultiSelectedEditableDashboardElement, -} from '../scene/types'; +import { isBulkActionElement } from '../scene/types/BulkActionElement'; +import { EditableDashboardElement, isEditableDashboardElement } from '../scene/types/EditableDashboardElement'; +import { MultiSelectedEditableDashboardElement } from '../scene/types/MultiSelectedEditableDashboardElement'; import { DashboardEditableElement } from './DashboardEditableElement'; import { MultiSelectedObjectsEditableElement } from './MultiSelectedObjectsEditableElement'; diff --git a/public/app/features/dashboard-scene/edit-pane/MultiSelectedObjectsEditableElement.tsx b/public/app/features/dashboard-scene/edit-pane/MultiSelectedObjectsEditableElement.tsx index 574de4164c1..79724e4474e 100644 --- a/public/app/features/dashboard-scene/edit-pane/MultiSelectedObjectsEditableElement.tsx +++ b/public/app/features/dashboard-scene/edit-pane/MultiSelectedObjectsEditableElement.tsx @@ -3,10 +3,13 @@ import { ReactNode } from 'react'; import { Stack, Text, Button } from '@grafana/ui'; import { Trans } from 'app/core/internationalization'; -import { BulkActionElement, MultiSelectedEditableDashboardElement } from '../scene/types'; +import { BulkActionElement } from '../scene/types/BulkActionElement'; +import { MultiSelectedEditableDashboardElement } from '../scene/types/MultiSelectedEditableDashboardElement'; export class MultiSelectedObjectsEditableElement implements MultiSelectedEditableDashboardElement { - public isMultiSelectedEditableDashboardElement: true = true; + public readonly isMultiSelectedEditableDashboardElement = true; + public readonly typeName = 'Objects'; + private items?: BulkActionElement[]; constructor(items: BulkActionElement[]) { @@ -19,10 +22,6 @@ export class MultiSelectedObjectsEditableElement implements MultiSelectedEditabl } }; - public getTypeName(): string { - return 'Objects'; - } - renderActions(): ReactNode { return ( diff --git a/public/app/features/dashboard-scene/edit-pane/MultiSelectedVizPanelsEditableElement.tsx b/public/app/features/dashboard-scene/edit-pane/MultiSelectedVizPanelsEditableElement.tsx index bf9de853158..38db6544d3a 100644 --- a/public/app/features/dashboard-scene/edit-pane/MultiSelectedVizPanelsEditableElement.tsx +++ b/public/app/features/dashboard-scene/edit-pane/MultiSelectedVizPanelsEditableElement.tsx @@ -5,11 +5,12 @@ import { Button, Stack, Text } from '@grafana/ui'; import { Trans } from 'app/core/internationalization'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; -import { MultiSelectedEditableDashboardElement } from '../scene/types'; +import { MultiSelectedEditableDashboardElement } from '../scene/types/MultiSelectedEditableDashboardElement'; import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; export class MultiSelectedVizPanelsEditableElement implements MultiSelectedEditableDashboardElement { - public isMultiSelectedEditableDashboardElement: true = true; + public readonly isMultiSelectedEditableDashboardElement = true; + public readonly typeName = 'Panels'; private items?: VizPanel[]; @@ -34,10 +35,6 @@ export class MultiSelectedVizPanelsEditableElement implements MultiSelectedEdita } }; - public getTypeName(): string { - return 'Panels'; - } - renderActions(): ReactNode { return ( diff --git a/public/app/features/dashboard-scene/edit-pane/VizPanelEditableElement.tsx b/public/app/features/dashboard-scene/edit-pane/VizPanelEditableElement.tsx index 36a2928a309..762dcb5f745 100644 --- a/public/app/features/dashboard-scene/edit-pane/VizPanelEditableElement.tsx +++ b/public/app/features/dashboard-scene/edit-pane/VizPanelEditableElement.tsx @@ -12,11 +12,14 @@ import { PanelDescriptionTextArea, PanelFrameTitleInput, } from '../panel-edit/getPanelFrameOptions'; -import { BulkActionElement, EditableDashboardElement, isDashboardLayoutItem } from '../scene/types'; +import { BulkActionElement } from '../scene/types/BulkActionElement'; +import { isDashboardLayoutItem } from '../scene/types/DashboardLayoutItem'; +import { EditableDashboardElement } from '../scene/types/EditableDashboardElement'; import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; export class VizPanelEditableElement implements EditableDashboardElement, BulkActionElement { - public isEditableDashboardElement: true = true; + public readonly isEditableDashboardElement = true; + public readonly typeName = 'Panel'; public constructor(private panel: VizPanel) {} @@ -96,10 +99,6 @@ export class VizPanelEditableElement implements EditableDashboardElement, BulkAc return categories; } - public getTypeName(): string { - return 'Panel'; - } - public onDelete = () => { const layout = dashboardSceneGraph.getLayoutManagerFor(this.panel); layout.removePanel(this.panel); diff --git a/public/app/features/dashboard-scene/edit-pane/useEditableElement.ts b/public/app/features/dashboard-scene/edit-pane/useEditableElement.ts index 2bfc44fbb30..2d6c79ff0f0 100644 --- a/public/app/features/dashboard-scene/edit-pane/useEditableElement.ts +++ b/public/app/features/dashboard-scene/edit-pane/useEditableElement.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react'; -import { EditableDashboardElement, MultiSelectedEditableDashboardElement } from '../scene/types'; +import { EditableDashboardElement } from '../scene/types/EditableDashboardElement'; +import { MultiSelectedEditableDashboardElement } from '../scene/types/MultiSelectedEditableDashboardElement'; import { ElementSelection } from './ElementSelection'; diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx index c8de84d8d46..19d25fc7bd6 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx @@ -21,7 +21,7 @@ import { saveLibPanel } from 'app/features/library-panels/state/api'; import { DashboardSceneChangeTracker } from '../saving/DashboardSceneChangeTracker'; import { getPanelChanges } from '../saving/getDashboardChanges'; -import { DashboardLayoutItem, isDashboardLayoutItem } from '../scene/types'; +import { DashboardLayoutItem, isDashboardLayoutItem } from '../scene/types/DashboardLayoutItem'; import { vizPanelToPanel } from '../serialization/transformSceneToSaveModel'; import { activateSceneObjectAndParentTree, diff --git a/public/app/features/dashboard-scene/panel-edit/getPanelFrameOptions.tsx b/public/app/features/dashboard-scene/panel-edit/getPanelFrameOptions.tsx index 98c93b0635e..8e0ac6a5e4b 100644 --- a/public/app/features/dashboard-scene/panel-edit/getPanelFrameOptions.tsx +++ b/public/app/features/dashboard-scene/panel-edit/getPanelFrameOptions.tsx @@ -10,7 +10,7 @@ import { getPanelLinksVariableSuggestions } from 'app/features/panel/panellinks/ import { VizPanelLinks } from '../scene/PanelLinks'; import { PanelTimeRange } from '../scene/PanelTimeRange'; -import { isDashboardLayoutItem } from '../scene/types'; +import { isDashboardLayoutItem } from '../scene/types/DashboardLayoutItem'; import { vizPanelToPanel, transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel'; import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; import { getDashboardSceneFor } from '../utils/utils'; diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index 02b179a003c..e015172d4c6 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -71,7 +71,7 @@ import { isUsingAngularDatasourcePlugin, isUsingAngularPanelPlugin } from './ang import { setupKeyboardShortcuts } from './keyboardShortcuts'; import { DashboardGridItem } from './layout-default/DashboardGridItem'; import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager'; -import { DashboardLayoutManager } from './types'; +import { DashboardLayoutManager } from './types/DashboardLayoutManager'; export const PERSISTED_PROPS = ['title', 'description', 'tags', 'editable', 'graphTooltip', 'links', 'meta', 'preload']; export const PANEL_SEARCH_VAR = 'systemPanelFilterVar'; @@ -258,7 +258,7 @@ export class DashboardScene extends SceneObjectBase { this.setState({ isEditing: true, showHiddenElements: true }); // Propagate change edit mode change to children - this.state.body.editModeChanged(true); + this.state.body.editModeChanged?.(true); // Propagate edit mode to scopes this._scopesFacade?.enterReadOnly(); @@ -349,7 +349,7 @@ export class DashboardScene extends SceneObjectBase { } // Disable grid dragging - this.state.body.editModeChanged(false); + this.state.body.editModeChanged?.(false); } public canDiscard() { diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.test.ts b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.test.ts index 6092bac64c2..4a7e9fca190 100644 --- a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.test.ts +++ b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.test.ts @@ -8,7 +8,7 @@ import { getCloneKey } from '../utils/clone'; import { DashboardScene } from './DashboardScene'; import { DashboardGridItem } from './layout-default/DashboardGridItem'; import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager'; -import { DashboardRepeatsProcessedEvent } from './types'; +import { DashboardRepeatsProcessedEvent } from './types/DashboardRepeatsProcessedEvent'; describe('DashboardSceneUrlSync', () => { describe('Given a standard scene', () => { diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts index 1eb85045504..3493be625f9 100644 --- a/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts +++ b/public/app/features/dashboard-scene/scene/DashboardSceneUrlSync.ts @@ -19,7 +19,7 @@ import { DashboardScene, DashboardSceneState } from './DashboardScene'; import { LibraryPanelBehavior } from './LibraryPanelBehavior'; import { ViewPanelScene } from './ViewPanelScene'; import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager'; -import { DashboardRepeatsProcessedEvent } from './types'; +import { DashboardRepeatsProcessedEvent } from './types/DashboardRepeatsProcessedEvent'; export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler { private _eventSub?: Unsubscribable; diff --git a/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx b/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx index 165016a3493..2f1be088090 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx @@ -25,7 +25,8 @@ import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components import { getCloneKey } from '../../utils/clone'; import { getMultiVariableValues, getQueryRunnerFor } from '../../utils/utils'; -import { DashboardLayoutItem, DashboardRepeatsProcessedEvent } from '../types'; +import { DashboardLayoutItem } from '../types/DashboardLayoutItem'; +import { DashboardRepeatsProcessedEvent } from '../types/DashboardRepeatsProcessedEvent'; import { getDashboardGridItemOptions } from './DashboardGridItemEditor'; @@ -47,6 +48,8 @@ export class DashboardGridItem private _prevRepeatValues?: VariableValueSingle[]; protected _variableDependency = new DashboardGridItemVariableDependencyHandler(this); + public readonly isDashboardLayoutItem = true; + public constructor(state: DashboardGridItemState) { super(state); @@ -188,11 +191,6 @@ export class DashboardGridItem this.setState(stateUpdate); } - /** - * DashboardLayoutItem interface start - */ - public isDashboardLayoutItem: true = true; - /** * Returns options for panel edit */ diff --git a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.test.tsx b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.test.tsx index e4bb910235e..8a06e7c9031 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.test.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.test.tsx @@ -26,19 +26,19 @@ describe('DefaultGridLayoutManager', () => { }); }); - describe('getNextPanelId', () => { - it('should get next panel id in a simple 3 panel layout', () => { + describe('getMaxPanelId', () => { + it('should get max panel id in a simple 3 panel layout', () => { const { manager } = setup(); - const id = manager.getNextPanelId(); + const id = manager.getMaxPanelId(); - expect(id).toBe(4); + expect(id).toBe(3); }); - it('should return 1 if no panels are found', () => { + it('should return 0 if no panels are found', () => { const { manager } = setup({ gridItems: [] }); - const id = manager.getNextPanelId(); + const id = manager.getMaxPanelId(); - expect(id).toBe(1); + expect(id).toBe(0); }); }); diff --git a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx index 3bae6e1c50c..131df5197b4 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx @@ -22,7 +22,7 @@ import { getGridItemKeyForPanelId, getDashboardSceneFor, } from '../../utils/utils'; -import { DashboardLayoutManager, LayoutRegistryItem } from '../types'; +import { DashboardLayoutManager } from '../types/DashboardLayoutManager'; import { DashboardGridItem } from './DashboardGridItem'; import { RowRepeaterBehavior } from './RowRepeaterBehavior'; @@ -39,7 +39,16 @@ export class DefaultGridLayoutManager extends SceneObjectBase implements DashboardLayoutManager { - public isDashboardLayoutManager: true = true; + public readonly isDashboardLayoutManager = true; + + public static readonly descriptor = { + name: 'Default grid', + description: 'The default grid layout', + id: 'default-grid', + createFromLayout: DefaultGridLayoutManager.createFromLayout, + }; + + public readonly descriptor = DefaultGridLayoutManager.descriptor; public editModeChanged(isEditing: boolean): void { const updateResizeAndDragging = () => { @@ -328,10 +337,6 @@ export class DefaultGridLayoutManager }); } - public getDescriptor(): LayoutRegistryItem { - return DefaultGridLayoutManager.getDescriptor(); - } - public cloneLayout(ancestorKey: string, isSource: boolean): DashboardLayoutManager { return this.clone({ grid: this.state.grid.clone({ @@ -402,15 +407,6 @@ export class DefaultGridLayoutManager }); } - public static getDescriptor(): LayoutRegistryItem { - return { - name: 'Default grid', - description: 'The default grid layout', - id: 'default-grid', - createFromLayout: DefaultGridLayoutManager.createFromLayout, - }; - } - /** * Handle switching to the manual grid layout from other layouts * @param currentLayout diff --git a/public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.ts b/public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.ts index 6ac38847198..8cfe331ec85 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.ts +++ b/public/app/features/dashboard-scene/scene/layout-default/RowRepeaterBehavior.ts @@ -23,7 +23,7 @@ import { isClonedKey, } from '../../utils/clone'; import { getMultiVariableValues } from '../../utils/utils'; -import { DashboardRepeatsProcessedEvent } from '../types'; +import { DashboardRepeatsProcessedEvent } from '../types/DashboardRepeatsProcessedEvent'; import { DashboardGridItem } from './DashboardGridItem'; diff --git a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItem.tsx b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItem.tsx index 342d23b42de..844211dab6f 100644 --- a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItem.tsx @@ -6,7 +6,7 @@ import { Switch, useStyles2 } from '@grafana/ui'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; -import { DashboardLayoutItem } from '../types'; +import { DashboardLayoutItem } from '../types/DashboardLayoutItem'; export interface ResponsiveGridItemState extends SceneObjectState { body: VizPanel; @@ -14,6 +14,8 @@ export interface ResponsiveGridItemState extends SceneObjectState { } export class ResponsiveGridItem extends SceneObjectBase implements DashboardLayoutItem { + public readonly isDashboardLayoutItem = true; + public constructor(state: ResponsiveGridItemState) { super(state); this.addActivationHandler(() => this._activationHandler()); @@ -29,11 +31,6 @@ export class ResponsiveGridItem extends SceneObjectBase this.setState({ hideWhenNoData: !this.state.hideWhenNoData }); } - /** - * DashboardLayoutElement interface - */ - public isDashboardLayoutItem: true = true; - public getOptions?(): OptionsPaneCategoryDescriptor { const model = this; diff --git a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx index 11f9e107640..54059d61707 100644 --- a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx @@ -5,7 +5,7 @@ import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/Pan import { getDashboardSceneFor, getPanelIdForVizPanel, getVizPanelKeyForPanelId } from '../../utils/utils'; import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager'; -import { DashboardLayoutManager, LayoutRegistryItem } from '../types'; +import { DashboardLayoutManager } from '../types/DashboardLayoutManager'; import { ResponsiveGridItem } from './ResponsiveGridItem'; @@ -17,7 +17,16 @@ export class ResponsiveGridLayoutManager extends SceneObjectBase implements DashboardLayoutManager { - public isDashboardLayoutManager: true = true; + public readonly isDashboardLayoutManager = true; + + public static readonly descriptor = { + name: 'Responsive grid', + description: 'CSS layout that adjusts to the available space', + id: 'responsive-grid', + createFromLayout: ResponsiveGridLayoutManager.createFromLayout, + }; + + public readonly descriptor = ResponsiveGridLayoutManager.descriptor; public editModeChanged(isEditing: boolean): void {} @@ -83,19 +92,6 @@ export class ResponsiveGridLayoutManager return getOptions(this); } - public getDescriptor(): LayoutRegistryItem { - return ResponsiveGridLayoutManager.getDescriptor(); - } - - public static getDescriptor(): LayoutRegistryItem { - return { - name: 'Responsive grid', - description: 'CSS layout that adjusts to the available space', - id: 'responsive-grid', - createFromLayout: ResponsiveGridLayoutManager.createFromLayout, - }; - } - public static createEmpty() { return new ResponsiveGridLayoutManager({ layout: new SceneCSSGridLayout({ @@ -123,10 +119,6 @@ export class ResponsiveGridLayoutManager }); } - toSaveModel?() { - throw new Error('Method not implemented.'); - } - activateRepeaters?(): void { throw new Error('Method not implemented.'); } diff --git a/public/app/features/dashboard-scene/scene/layout-rows/MultiSelectedRowItemsElement.tsx b/public/app/features/dashboard-scene/scene/layout-rows/MultiSelectedRowItemsElement.tsx index 8cfc0704ef0..41f57d48577 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/MultiSelectedRowItemsElement.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/MultiSelectedRowItemsElement.tsx @@ -6,12 +6,13 @@ import { t, Trans } from 'app/core/internationalization'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; -import { MultiSelectedEditableDashboardElement } from '../types'; +import { MultiSelectedEditableDashboardElement } from '../types/MultiSelectedEditableDashboardElement'; import { RowItem } from './RowItem'; export class MultiSelectedRowItemsElement implements MultiSelectedEditableDashboardElement { - public isMultiSelectedEditableDashboardElement: true = true; + public readonly isMultiSelectedEditableDashboardElement = true; + public readonly typeName = 'Rows'; private items?: RowItem[]; @@ -44,10 +45,6 @@ export class MultiSelectedRowItemsElement implements MultiSelectedEditableDashbo return [rowOptions]; } - public getTypeName(): string { - return 'Rows'; - } - public onDelete = () => { for (const item of this.items || []) { item.onDelete(); diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx index 51a92a72c92..756092f00eb 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx @@ -33,7 +33,10 @@ import { isClonedKey } from '../../utils/clone'; import { getDashboardSceneFor, getDefaultVizPanel, getQueryRunnerFor } from '../../utils/utils'; import { DashboardScene } from '../DashboardScene'; import { useLayoutCategory } from '../layouts-shared/DashboardLayoutSelector'; -import { BulkActionElement, DashboardLayoutManager, EditableDashboardElement, LayoutParent } from '../types'; +import { BulkActionElement } from '../types/BulkActionElement'; +import { DashboardLayoutManager } from '../types/DashboardLayoutManager'; +import { EditableDashboardElement } from '../types/EditableDashboardElement'; +import { LayoutParent } from '../types/LayoutParent'; import { MultiSelectedRowItemsElement } from './MultiSelectedRowItemsElement'; import { RowItemRepeaterBehavior } from './RowItemRepeaterBehavior'; @@ -55,7 +58,8 @@ export class RowItem statePaths: ['title'], }); - public isEditableDashboardElement: true = true; + public readonly isEditableDashboardElement = true; + public readonly typeName = 'Row'; public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] { const row = this; @@ -107,10 +111,6 @@ export class RowItem return [rowOptions, rowRepeatOptions, layoutOptions]; } - public getTypeName(): string { - return 'Row'; - } - public createMultiSelectedElement(items: SceneObject[]) { return new MultiSelectedRowItemsElement(items); } diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeaterBehavior.ts b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeaterBehavior.ts index 15b376fd0dd..175351744a6 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeaterBehavior.ts +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRepeaterBehavior.ts @@ -13,7 +13,7 @@ import { import { isClonedKeyOf, getCloneKey } from '../../utils/clone'; import { getMultiVariableValues } from '../../utils/utils'; -import { DashboardRepeatsProcessedEvent } from '../types'; +import { DashboardRepeatsProcessedEvent } from '../types/DashboardRepeatsProcessedEvent'; import { RowItem } from './RowItem'; import { RowsLayoutManager } from './RowsLayoutManager'; diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx index c888f5d9c8f..8aaa20972c5 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx @@ -18,7 +18,7 @@ import { DashboardGridItem } from '../layout-default/DashboardGridItem'; import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager'; import { RowRepeaterBehavior } from '../layout-default/RowRepeaterBehavior'; import { ResponsiveGridLayoutManager } from '../layout-responsive-grid/ResponsiveGridLayoutManager'; -import { DashboardLayoutManager, LayoutRegistryItem } from '../types'; +import { DashboardLayoutManager } from '../types/DashboardLayoutManager'; import { RowItem } from './RowItem'; import { RowItemRepeaterBehavior } from './RowItemRepeaterBehavior'; @@ -28,7 +28,16 @@ interface RowsLayoutManagerState extends SceneObjectState { } export class RowsLayoutManager extends SceneObjectBase implements DashboardLayoutManager { - public isDashboardLayoutManager: true = true; + public readonly isDashboardLayoutManager = true; + + public static readonly descriptor = { + name: 'Rows', + description: 'Rows layout', + id: 'rows-layout', + createFromLayout: RowsLayoutManager.createFromLayout, + }; + + public readonly descriptor = RowsLayoutManager.descriptor; public editModeChanged(isEditing: boolean): void {} @@ -113,23 +122,10 @@ export class RowsLayoutManager extends SceneObjectBase i }); } - public getDescriptor(): LayoutRegistryItem { - return RowsLayoutManager.getDescriptor(); - } - public getSelectedObject() { return sceneGraph.getAncestor(this, DashboardScene).state.editPane.state.selection?.getFirstObject(); } - public static getDescriptor(): LayoutRegistryItem { - return { - name: 'Rows', - description: 'Rows layout', - id: 'rows-layout', - createFromLayout: RowsLayoutManager.createFromLayout, - }; - } - public static createEmpty() { return new RowsLayoutManager({ rows: [] }); } diff --git a/public/app/features/dashboard-scene/scene/layouts-shared/DashboardLayoutSelector.tsx b/public/app/features/dashboard-scene/scene/layouts-shared/DashboardLayoutSelector.tsx index d1af76ef4f2..d4b2da7b609 100644 --- a/public/app/features/dashboard-scene/scene/layouts-shared/DashboardLayoutSelector.tsx +++ b/public/app/features/dashboard-scene/scene/layouts-shared/DashboardLayoutSelector.tsx @@ -4,7 +4,9 @@ import { Select } from '@grafana/ui'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; -import { DashboardLayoutManager, isLayoutParent, LayoutRegistryItem } from '../types'; +import { DashboardLayoutManager } from '../types/DashboardLayoutManager'; +import { isLayoutParent } from '../types/LayoutParent'; +import { LayoutRegistryItem } from '../types/LayoutRegistryItem'; import { layoutRegistry } from './layoutRegistry'; import { findParentLayout } from './utils'; @@ -16,7 +18,7 @@ export interface Props { export function DashboardLayoutSelector({ layoutManager }: Props) { const options = useMemo(() => { const parentLayout = findParentLayout(layoutManager); - const parentLayoutId = parentLayout?.getDescriptor().id; + const parentLayoutId = parentLayout?.descriptor.id; return layoutRegistry .list() @@ -27,7 +29,7 @@ export function DashboardLayoutSelector({ layoutManager }: Props) { })); }, [layoutManager]); - const currentLayoutId = layoutManager.getDescriptor().id; + const currentLayoutId = layoutManager.descriptor.id; const currentOption = options.find((option) => option.value.id === currentLayoutId); return ( diff --git a/public/app/features/dashboard-scene/scene/layouts-shared/layoutRegistry.ts b/public/app/features/dashboard-scene/scene/layouts-shared/layoutRegistry.ts index 95f345e71b9..20236c20018 100644 --- a/public/app/features/dashboard-scene/scene/layouts-shared/layoutRegistry.ts +++ b/public/app/features/dashboard-scene/scene/layouts-shared/layoutRegistry.ts @@ -3,12 +3,8 @@ import { Registry } from '@grafana/data'; import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager'; import { ResponsiveGridLayoutManager } from '../layout-responsive-grid/ResponsiveGridLayoutManager'; import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager'; -import { LayoutRegistryItem } from '../types'; +import { LayoutRegistryItem } from '../types/LayoutRegistryItem'; export const layoutRegistry: Registry = new Registry(() => { - return [ - DefaultGridLayoutManager.getDescriptor(), - ResponsiveGridLayoutManager.getDescriptor(), - RowsLayoutManager.getDescriptor(), - ]; + return [DefaultGridLayoutManager.descriptor, ResponsiveGridLayoutManager.descriptor, RowsLayoutManager.descriptor]; }); diff --git a/public/app/features/dashboard-scene/scene/layouts-shared/utils.ts b/public/app/features/dashboard-scene/scene/layouts-shared/utils.ts index 7fab7ee9aea..61a6d574877 100644 --- a/public/app/features/dashboard-scene/scene/layouts-shared/utils.ts +++ b/public/app/features/dashboard-scene/scene/layouts-shared/utils.ts @@ -1,6 +1,6 @@ import { SceneObject } from '@grafana/scenes'; -import { DashboardLayoutManager, isDashboardLayoutManager } from '../types'; +import { DashboardLayoutManager, isDashboardLayoutManager } from '../types/DashboardLayoutManager'; export function findParentLayout(sceneObject: SceneObject): DashboardLayoutManager | null { let parent = sceneObject.parent; diff --git a/public/app/features/dashboard-scene/scene/types.ts b/public/app/features/dashboard-scene/scene/types.ts deleted file mode 100644 index d147d1579d9..00000000000 --- a/public/app/features/dashboard-scene/scene/types.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { BusEventWithPayload, RegistryItem } from '@grafana/data'; -import { SceneObject, VizPanel } from '@grafana/scenes'; -import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; -import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; - -/** - * A scene object that usually wraps an underlying layout - * Dealing with all the state management and editing of the layout - */ -export interface DashboardLayoutManager extends SceneObject { - /** Marks it as a DashboardLayoutManager */ - isDashboardLayoutManager: true; - - /** - * Notify the layout manager that the edit mode has changed - * @param isEditing - */ - editModeChanged(isEditing: boolean): void; - - /** - * Remove an element / panel - * @param element - */ - removePanel(panel: VizPanel): void; - - /** - * Creates a copy of an existing element and adds it to the layout - * @param element - */ - duplicatePanel(panel: VizPanel): void; - - /** - * Adds a new panel to the layout - */ - addPanel(panel: VizPanel): void; - - /** - * Add row - */ - addNewRow(): void; - - /** - * getVizPanels - */ - getVizPanels(): VizPanel[]; - - /** - * Turn into a save model - * @param saveModel - */ - toSaveModel?(): any; - - /** - * For dynamic panels that need to be viewed in isolation (SoloRoute) - */ - activateRepeaters?(): void; - - /** - * Gets the layout descriptor (which has the name and id) - */ - getDescriptor(): LayoutRegistryItem; - - /** - * Renders options and layout actions - */ - getOptions?(): OptionsPaneItemDescriptor[]; - - /** - * Create a clone of the layout manager given an ancestor key - * @param ancestorKey - * @param isSource - */ - cloneLayout?(ancestorKey: string, isSource: boolean): DashboardLayoutManager; - - /** - * Returns the highest panel id in the layout - */ - getMaxPanelId(): number; -} - -export function isDashboardLayoutManager(obj: SceneObject): obj is DashboardLayoutManager { - return 'isDashboardLayoutManager' in obj; -} - -/** - * The layout descriptor used when selecting / switching layouts - */ -export interface LayoutRegistryItem extends RegistryItem { - /** - * When switching between layouts - * @param currentLayout - */ - createFromLayout(currentLayout: DashboardLayoutManager): DashboardLayoutManager; - /** - * Create from persisted state - * @param saveModel - */ - createFromSaveModel?(saveModel: any): void; -} - -/** - * This interface is needed to support layouts existing on different levels of the scene (DashboardScene and inside the TabsLayoutManager) - */ -export interface LayoutParent extends SceneObject { - switchLayout(newLayout: DashboardLayoutManager): void; -} - -export function isLayoutParent(obj: SceneObject): obj is LayoutParent { - return 'switchLayout' in obj; -} - -/** - * Abstraction to handle editing of different layout elements (wrappers for VizPanels and other objects) - * Also useful to when rendering / viewing an element outside it's layout scope - */ -export interface DashboardLayoutItem extends SceneObject { - /** - * Marks this object as a layout item - */ - isDashboardLayoutItem: true; - /** - * Return layout item options (like repeat, repeat direction, etc for the default DashboardGridItem) - */ - getOptions?(): OptionsPaneCategoryDescriptor; - /** - * When going into panel edit - **/ - editingStarted?(): void; - /** - * When coming out of panel edit - */ - editingCompleted?(withChanges: boolean): void; -} - -export function isDashboardLayoutItem(obj: SceneObject): obj is DashboardLayoutItem { - return 'isDashboardLayoutItem' in obj; -} - -export interface DashboardRepeatsProcessedEventPayload { - source: SceneObject; -} - -export class DashboardRepeatsProcessedEvent extends BusEventWithPayload { - public static type = 'dashboard-repeats-processed'; -} - -/** - * Interface for elements that have options - */ -export interface EditableDashboardElement { - /** - * Marks this object as an element that can be selected and edited directly on the canvas - */ - isEditableDashboardElement: true; - /** - * Hook that returns edit pane options - */ - useEditPaneOptions(): OptionsPaneCategoryDescriptor[]; - /** - * Get the type name of the element - */ - getTypeName(): string; - /** - * Panel Actions - **/ - renderActions?(): React.ReactNode; - /** - * creates a new multi-selection element from a list of selected items - */ - createMultiSelectedElement?(items: SceneObject[]): MultiSelectedEditableDashboardElement; -} - -export function isEditableDashboardElement(obj: object): obj is EditableDashboardElement { - return 'isEditableDashboardElement' in obj; -} - -export interface MultiSelectedEditableDashboardElement { - /** - * Marks this object as an element that can be selected and edited directly on the canvas - */ - isMultiSelectedEditableDashboardElement: true; - /** - * Get the type name of the element - */ - getTypeName(): string; - /** - * Hook that returns edit pane options - */ - useEditPaneOptions?(): OptionsPaneCategoryDescriptor[]; - /** - * Panel Actions - **/ - renderActions?(): React.ReactNode; -} - -export function isMultiSelectedEditableDashboardElement(obj: object): obj is MultiSelectedEditableDashboardElement { - return 'isMultiSelectedEditableDashboardElement' in obj; -} - -export interface BulkActionElement { - onDelete(): void; - onCopy?(): void; -} - -export function isBulkActionElement(obj: object): obj is BulkActionElement { - return 'onDelete' in obj; -} diff --git a/public/app/features/dashboard-scene/scene/types/BulkActionElement.ts b/public/app/features/dashboard-scene/scene/types/BulkActionElement.ts new file mode 100644 index 00000000000..a654693d64d --- /dev/null +++ b/public/app/features/dashboard-scene/scene/types/BulkActionElement.ts @@ -0,0 +1,8 @@ +export interface BulkActionElement { + onDelete(): void; + onCopy?(): void; +} + +export function isBulkActionElement(obj: object): obj is BulkActionElement { + return 'onDelete' in obj; +} diff --git a/public/app/features/dashboard-scene/scene/types/DashboardLayoutItem.ts b/public/app/features/dashboard-scene/scene/types/DashboardLayoutItem.ts new file mode 100644 index 00000000000..fef58b0d071 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/types/DashboardLayoutItem.ts @@ -0,0 +1,32 @@ +import { SceneObject } from '@grafana/scenes'; +import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; + +/** + * Abstraction to handle editing of different layout elements (wrappers for VizPanels and other objects) + * Also useful to when rendering / viewing an element outside it's layout scope + */ +export interface DashboardLayoutItem extends SceneObject { + /** + * Marks this object as a layout item + */ + isDashboardLayoutItem: true; + + /** + * Return layout item options (like repeat, repeat direction, etc. for the default DashboardGridItem) + */ + getOptions?(): OptionsPaneCategoryDescriptor; + + /** + * When going into panel edit + **/ + editingStarted?(): void; + + /** + * When coming out of panel edit + */ + editingCompleted?(withChanges: boolean): void; +} + +export function isDashboardLayoutItem(obj: SceneObject): obj is DashboardLayoutItem { + return 'isDashboardLayoutItem' in obj; +} diff --git a/public/app/features/dashboard-scene/scene/types/DashboardLayoutManager.ts b/public/app/features/dashboard-scene/scene/types/DashboardLayoutManager.ts new file mode 100644 index 00000000000..572d7da9549 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/types/DashboardLayoutManager.ts @@ -0,0 +1,82 @@ +import { SceneObject, VizPanel } from '@grafana/scenes'; +import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; + +import { LayoutRegistryItem } from './LayoutRegistryItem'; + +/** + * A scene object that usually wraps an underlying layout + * Dealing with all the state management and editing of the layout + */ +export interface DashboardLayoutManager extends SceneObject { + /** Marks it as a DashboardLayoutManager */ + isDashboardLayoutManager: true; + + /** + * The layout descriptor (which has the name and id) + */ + descriptor: Readonly; + + /** + * Adds a new panel to the layout + */ + addPanel(panel: VizPanel): void; + + /** + * Remove an element / panel + * @param panel + */ + removePanel(panel: VizPanel): void; + + /** + * Creates a copy of an existing element and adds it to the layout + * @param panel + */ + duplicatePanel(panel: VizPanel): void; + + /** + * getVizPanels + */ + getVizPanels(): VizPanel[]; + + /** + * Returns the highest panel id in the layout + */ + getMaxPanelId(): number; + + /** + * Add row + */ + addNewRow(): void; + + /** + * Notify the layout manager that the edit mode has changed + * @param isEditing + */ + editModeChanged?(isEditing: boolean): void; + + /** + * Turn into a save model + */ + toSaveModel?(): S; + + /** + * For dynamic panels that need to be viewed in isolation (SoloRoute) + */ + activateRepeaters?(): void; + + /** + * Renders options and layout actions + */ + getOptions?(): OptionsPaneItemDescriptor[]; + + /** + * Create a clone of the layout manager given an ancestor key + * @param ancestorKey + * @param isSource + */ + cloneLayout?(ancestorKey: string, isSource: boolean): DashboardLayoutManager; +} + +export function isDashboardLayoutManager(obj: SceneObject): obj is DashboardLayoutManager { + return 'isDashboardLayoutManager' in obj; +} diff --git a/public/app/features/dashboard-scene/scene/types/DashboardRepeatsProcessedEvent.ts b/public/app/features/dashboard-scene/scene/types/DashboardRepeatsProcessedEvent.ts new file mode 100644 index 00000000000..9bf602cc171 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/types/DashboardRepeatsProcessedEvent.ts @@ -0,0 +1,10 @@ +import { BusEventWithPayload } from '@grafana/data'; +import { SceneObject } from '@grafana/scenes'; + +export interface DashboardRepeatsProcessedEventPayload { + source: SceneObject; +} + +export class DashboardRepeatsProcessedEvent extends BusEventWithPayload { + public static type = 'dashboard-repeats-processed'; +} diff --git a/public/app/features/dashboard-scene/scene/types/EditableDashboardElement.ts b/public/app/features/dashboard-scene/scene/types/EditableDashboardElement.ts new file mode 100644 index 00000000000..396b4f76431 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/types/EditableDashboardElement.ts @@ -0,0 +1,40 @@ +import { ReactNode } from 'react'; + +import { SceneObject } from '@grafana/scenes'; +import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; + +import { MultiSelectedEditableDashboardElement } from './MultiSelectedEditableDashboardElement'; + +/** + * Interface for elements that have options + */ +export interface EditableDashboardElement { + /** + * Marks this object as an element that can be selected and edited directly on the canvas + */ + isEditableDashboardElement: true; + + /** + * Type name of the element + */ + typeName: Readonly; + + /** + * Hook that returns edit pane options + */ + useEditPaneOptions(): OptionsPaneCategoryDescriptor[]; + + /** + * Panel Actions + **/ + renderActions?(): ReactNode; + + /** + * creates a new multi-selection element from a list of selected items + */ + createMultiSelectedElement?(items: SceneObject[]): MultiSelectedEditableDashboardElement; +} + +export function isEditableDashboardElement(obj: object): obj is EditableDashboardElement { + return 'isEditableDashboardElement' in obj; +} diff --git a/public/app/features/dashboard-scene/scene/types/LayoutParent.ts b/public/app/features/dashboard-scene/scene/types/LayoutParent.ts new file mode 100644 index 00000000000..5d57ba0fc63 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/types/LayoutParent.ts @@ -0,0 +1,14 @@ +import { SceneObject } from '@grafana/scenes'; + +import { DashboardLayoutManager } from './DashboardLayoutManager'; + +/** + * This interface is needed to support layouts existing on different levels of the scene (DashboardScene and inside the TabsLayoutManager) + */ +export interface LayoutParent extends SceneObject { + switchLayout(newLayout: DashboardLayoutManager): void; +} + +export function isLayoutParent(obj: SceneObject): obj is LayoutParent { + return 'switchLayout' in obj; +} diff --git a/public/app/features/dashboard-scene/scene/types/LayoutRegistryItem.ts b/public/app/features/dashboard-scene/scene/types/LayoutRegistryItem.ts new file mode 100644 index 00000000000..f35b4c62d3c --- /dev/null +++ b/public/app/features/dashboard-scene/scene/types/LayoutRegistryItem.ts @@ -0,0 +1,20 @@ +import { RegistryItem } from '@grafana/data'; + +import { DashboardLayoutManager } from './DashboardLayoutManager'; + +/** + * The layout descriptor used when selecting / switching layouts + */ +export interface LayoutRegistryItem extends RegistryItem { + /** + * When switching between layouts + * @param currentLayout + */ + createFromLayout(currentLayout: DashboardLayoutManager): DashboardLayoutManager; + + /** + * Create from persisted state + * @param saveModel + */ + createFromSaveModel?(saveModel: S): void; +} diff --git a/public/app/features/dashboard-scene/scene/types/MultiSelectedEditableDashboardElement.ts b/public/app/features/dashboard-scene/scene/types/MultiSelectedEditableDashboardElement.ts new file mode 100644 index 00000000000..bf956261f95 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/types/MultiSelectedEditableDashboardElement.ts @@ -0,0 +1,29 @@ +import { ReactNode } from 'react'; + +import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; + +export interface MultiSelectedEditableDashboardElement { + /** + * Marks this object as an element that can be selected and edited directly on the canvas + */ + isMultiSelectedEditableDashboardElement: true; + + /** + * Type name of the element + */ + typeName: Readonly; + + /** + * Hook that returns edit pane options + */ + useEditPaneOptions?(): OptionsPaneCategoryDescriptor[]; + + /** + * Panel Actions + **/ + renderActions?(): ReactNode; +} + +export function isMultiSelectedEditableDashboardElement(obj: object): obj is MultiSelectedEditableDashboardElement { + return 'isMultiSelectedEditableDashboardElement' in obj; +} diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts index 64df731435d..7be999edebc 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelSchemaV2ToScene.test.ts @@ -33,7 +33,7 @@ import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSou import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet'; import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager'; -import { DashboardLayoutManager } from '../scene/types'; +import { DashboardLayoutManager } from '../scene/types/DashboardLayoutManager'; import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; import { getQueryRunnerFor } from '../utils/utils'; import { validateVariable, validateVizPanel } from '../v2schema/test-helpers'; diff --git a/public/app/features/dashboard-scene/solo/useSoloPanel.ts b/public/app/features/dashboard-scene/solo/useSoloPanel.ts index 3db708c0ae9..acc7409bdc3 100644 --- a/public/app/features/dashboard-scene/solo/useSoloPanel.ts +++ b/public/app/features/dashboard-scene/solo/useSoloPanel.ts @@ -3,7 +3,7 @@ import { useState, useEffect } from 'react'; import { VizPanel, UrlSyncManager } from '@grafana/scenes'; import { DashboardScene } from '../scene/DashboardScene'; -import { DashboardRepeatsProcessedEvent } from '../scene/types'; +import { DashboardRepeatsProcessedEvent } from '../scene/types/DashboardRepeatsProcessedEvent'; import { containsCloneKey } from '../utils/clone'; import { findVizPanelByKey } from '../utils/utils'; diff --git a/public/app/features/dashboard-scene/utils/utils.ts b/public/app/features/dashboard-scene/utils/utils.ts index 64df007a931..27ecaac2fe2 100644 --- a/public/app/features/dashboard-scene/utils/utils.ts +++ b/public/app/features/dashboard-scene/utils/utils.ts @@ -19,7 +19,7 @@ import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior'; import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks'; import { panelMenuBehavior } from '../scene/PanelMenuBehavior'; import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem'; -import { DashboardLayoutManager, isDashboardLayoutManager } from '../scene/types'; +import { DashboardLayoutManager, isDashboardLayoutManager } from '../scene/types/DashboardLayoutManager'; import { getLastKeyFromClone, getOriginalKey } from './clone'; From 39a6d2e586f7c2fe7e6b55515ba17134bcc197ba Mon Sep 17 00:00:00 2001 From: Nick Richmond <5732000+NWRichmond@users.noreply.github.com> Date: Wed, 5 Feb 2025 06:09:16 -0500 Subject: [PATCH 08/31] ExploreMetrics: Use redirect to route traffic to metrics drilldown app (#100098) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: drilldown app redirect * chore: make copilot review happy, I guess 🤷 --- .../features/trails/RedirectToDrilldownApp.tsx | 17 +++++++++++++++++ public/app/routes/routes.tsx | 7 +++++-- 2 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 public/app/features/trails/RedirectToDrilldownApp.tsx diff --git a/public/app/features/trails/RedirectToDrilldownApp.tsx b/public/app/features/trails/RedirectToDrilldownApp.tsx new file mode 100644 index 00000000000..0a4ff04e8d7 --- /dev/null +++ b/public/app/features/trails/RedirectToDrilldownApp.tsx @@ -0,0 +1,17 @@ +import { Navigate, useLocation, useParams } from 'react-router-dom-v5-compat'; + +import { getRouteForAppPlugin } from 'app/features/plugins/routes'; + +/** + * Navigate to the drilldown app with the remaining path parameters and search params + */ +const RedirectToDrilldownApp = () => { + const { '*': remainingPath } = useParams(); + const location = useLocation(); + const appPath = getRouteForAppPlugin('grafana-metricsdrilldown-app').path.replaceAll('*', ''); + const newPath = `${appPath}${remainingPath}${location.search}`; + + return ; +}; + +export default RedirectToDrilldownApp; diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index ddadc2f40b7..839eb62ad85 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -15,7 +15,7 @@ import { getRoutes as getDataConnectionsRoutes } from 'app/features/connections/ import { DATASOURCES_ROUTES } from 'app/features/datasources/constants'; import { ConfigureIRM } from 'app/features/gops/configuration-tracker/components/ConfigureIRM'; import { getRoutes as getPluginCatalogRoutes } from 'app/features/plugins/admin/routes'; -import { getAppPluginRoutes, getRouteForAppPlugin } from 'app/features/plugins/routes'; +import { getAppPluginRoutes } from 'app/features/plugins/routes'; import { getProfileRoutes } from 'app/features/profile/routes'; import { AccessControlAction, DashboardRoutes } from 'app/types'; @@ -518,7 +518,10 @@ export function getAppRoutes(): RouteDescriptor[] { roles: () => contextSrv.evaluatePermission([AccessControlAction.DataSourcesExplore]), ...(config.featureToggles.exploreMetricsUseExternalAppPlugin ? { - component: getRouteForAppPlugin('grafana-metricsdrilldown-app').component, + component: SafeDynamicImport( + () => + import(/* webpackChunkName: "MetricsDrilldownRedirect"*/ 'app/features/trails/RedirectToDrilldownApp') + ), } : { chromeless: false, From c4d01143761ed3323ca62bcb3ab6dbb49f8d9fcf Mon Sep 17 00:00:00 2001 From: Bogdan Matei Date: Wed, 5 Feb 2025 13:14:03 +0200 Subject: [PATCH 09/31] Dashboards: Use i18n for texts in dynamic dashboards (#100108) --- .betterer.results | 38 ------ .../edit-pane/DashboardEditPane.tsx | 7 +- .../edit-pane/DashboardEditableElement.tsx | 7 +- .../edit-pane/VizPanelEditableElement.tsx | 14 +-- .../scene/NavToolbarActions.tsx | 98 ++++++++------- .../DashboardGridItemEditor.tsx | 19 +-- .../DefaultGridLayoutManager.tsx | 9 +- .../ResponsiveGridItem.tsx | 5 +- .../ResponsiveGridLayoutManager.tsx | 34 ++++-- .../scene/layout-rows/RowItem.tsx | 28 +++-- .../scene/layout-rows/RowsLayoutManager.tsx | 15 ++- public/locales/en-US/grafana.json | 112 +++++++++++++++++- public/locales/pseudo-LOCALE/grafana.json | 112 +++++++++++++++++- 13 files changed, 361 insertions(+), 137 deletions(-) diff --git a/.betterer.results b/.betterer.results index 629774cfa72..c9ce73693f2 100644 --- a/.betterer.results +++ b/.betterer.results @@ -3212,10 +3212,6 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings. Wrap text with ", "6"], [0, 0, 0, "No untranslated strings. Wrap text with ", "7"] ], - "public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx:5381": [ - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "1"] - ], "public/app/features/dashboard-scene/embedding/EmbeddedDashboardTestPage.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] ], @@ -3389,40 +3385,6 @@ exports[`better eslint`] = { "public/app/features/dashboard-scene/scene/DashboardLinksControls.tsx:5381": [ [0, 0, 0, "\'@grafana/data/src/text/sanitize\' import is restricted from being used by a pattern. Import from the public export instead.", "0"] ], - "public/app/features/dashboard-scene/scene/NavToolbarActions.tsx:5381": [ - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "1"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "2"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "3"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "4"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "5"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "6"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "7"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "8"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "9"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "10"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "11"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "12"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "13"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "14"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "15"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "16"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "17"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "18"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "19"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "20"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "21"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "22"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "23"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "24"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "25"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "26"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "27"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "28"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "29"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "30"], - [0, 0, 0, "No untranslated strings. Wrap text with ", "31"] - ], "public/app/features/dashboard-scene/scene/PanelLinks.tsx:5381": [ [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"] ], diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx index 497b45bf109..35f2ce7123b 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx @@ -5,6 +5,7 @@ import { useEffect, useRef } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { SceneObjectState, SceneObjectBase, SceneObject, sceneGraph, useSceneObjectState } from '@grafana/scenes'; import { ElementSelectionContextItem, ElementSelectionContextState, ToolbarButton, useStyles2 } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; import { isInCloneChain } from '../utils/clone'; import { getDashboardSceneFor } from '../utils/utils'; @@ -165,12 +166,12 @@ export function DashboardEditPaneRenderer({ editPane, isCollapsed, onToggleColla <>
diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditableElement.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditableElement.tsx index 1e458430481..4699e8c1c3f 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardEditableElement.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditableElement.tsx @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { Input, TextArea } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; @@ -22,13 +23,13 @@ export class DashboardEditableElement implements EditableDashboardElement { const dashboardOptions = useMemo(() => { return new OptionsPaneCategoryDescriptor({ - title: 'Dashboard options', + title: t('dashboard.options.title', 'Dashboard options'), id: 'dashboard-options', isOpenDefault: true, }) .addItem( new OptionsPaneItemDescriptor({ - title: 'Title', + title: t('dashboard.options.title-option', 'Title'), render: function renderTitle() { return ; }, @@ -36,7 +37,7 @@ export class DashboardEditableElement implements EditableDashboardElement { ) .addItem( new OptionsPaneItemDescriptor({ - title: 'Description', + title: t('dashboard.options.description', 'Description'), render: function renderTitle() { return ; }, diff --git a/public/app/features/dashboard-scene/edit-pane/VizPanelEditableElement.tsx b/public/app/features/dashboard-scene/edit-pane/VizPanelEditableElement.tsx index 762dcb5f745..732ac9df825 100644 --- a/public/app/features/dashboard-scene/edit-pane/VizPanelEditableElement.tsx +++ b/public/app/features/dashboard-scene/edit-pane/VizPanelEditableElement.tsx @@ -1,8 +1,8 @@ -import { useMemo } from 'react'; +import { ReactNode, useMemo } from 'react'; import { sceneGraph, VizPanel } from '@grafana/scenes'; import { Button } from '@grafana/ui'; -import { Trans } from 'app/core/internationalization'; +import { t, Trans } from 'app/core/internationalization'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; import { getVisualizationOptions2 } from 'app/features/dashboard/components/PanelEditor/getVisualizationOptions'; @@ -29,13 +29,13 @@ export class VizPanelEditableElement implements EditableDashboardElement, BulkAc const panelOptions = useMemo(() => { return new OptionsPaneCategoryDescriptor({ - title: 'Panel options', + title: t('dashboard.viz-panel.options.title', 'Panel options'), id: 'panel-options', isOpenDefault: true, }) .addItem( new OptionsPaneItemDescriptor({ - title: 'Title', + title: t('dashboard.viz-panel.options.title-option', 'Title'), value: panel.state.title, popularRank: 1, render: function renderTitle() { @@ -45,7 +45,7 @@ export class VizPanelEditableElement implements EditableDashboardElement, BulkAc ) .addItem( new OptionsPaneItemDescriptor({ - title: 'Description', + title: t('dashboard.viz-panel.options.description', 'Description'), value: panel.state.description, render: function renderDescription() { return ; @@ -54,7 +54,7 @@ export class VizPanelEditableElement implements EditableDashboardElement, BulkAc ) .addItem( new OptionsPaneItemDescriptor({ - title: 'Transparent background', + title: t('dashboard.viz-panel.options.transparent-background', 'Transparent background'), render: function renderTransparent() { return ; }, @@ -104,7 +104,7 @@ export class VizPanelEditableElement implements EditableDashboardElement, BulkAc layout.removePanel(this.panel); }; - public renderActions(): React.ReactNode { + public renderActions(): ReactNode { return ( <> ), }); @@ -191,7 +190,7 @@ export function ToolbarActions({ dashboard }: Props) { }} data-testid={selectors.components.PageToolbar.itemButton('add_row')} > - Row + Row ), }); @@ -211,7 +210,7 @@ export function ToolbarActions({ dashboard }: Props) { DashboardInteractions.toolbarAddButtonClicked({ item: 'add_library_panel' }); }} > - Import + Import ), }); @@ -363,7 +362,7 @@ export function ToolbarActions({ dashboard }: Props) { icon="arrow-left" data-testid={selectors.components.NavToolbar.editDashboard.backToDashboardButton} > - Back to dashboard + Back to dashboard ), }); @@ -384,7 +383,7 @@ export function ToolbarActions({ dashboard }: Props) { icon="arrow-left" data-testid={selectors.components.NavToolbar.editDashboard.backToDashboardButton} > - Back to dashboard + Back to dashboard ), }); @@ -396,7 +395,7 @@ export function ToolbarActions({ dashboard }: Props) { render: () => ( ), }); @@ -419,14 +418,14 @@ export function ToolbarActions({ dashboard }: Props) { onClick={() => { dashboard.onEnterEditMode(); }} - tooltip="Enter edit mode" + tooltip={t('dashboard.toolbar.edit.tooltip', 'Enter edit mode')} key="edit" className={styles.buttonWithExtraMargin} variant={config.featureToggles.newDashboardSharingComponent ? 'secondary' : 'primary'} size="sm" data-testid={selectors.components.NavToolbar.editDashboard.editButton} > - Edit + Edit ), }); @@ -440,14 +439,14 @@ export function ToolbarActions({ dashboard }: Props) { dashboard.onEnterEditMode(); dashboard.setState({ editable: true, meta: { ...meta, canEdit: true } }); }} - tooltip="This dashboard was marked as read only" + tooltip={t('dashboard.toolbar.enter-edit-mode.tooltip', 'This dashboard was marked as read only')} key="edit" className={styles.buttonWithExtraMargin} variant="secondary" size="sm" data-testid={selectors.components.NavToolbar.editDashboard.editButton} > - Make editable + Make editable ), }); @@ -472,14 +471,14 @@ export function ToolbarActions({ dashboard }: Props) { onClick={() => { dashboard.onOpenSettings(); }} - tooltip="Dashboard settings" + tooltip={t('dashboard.toolbar.dashboard-settings.tooltip', 'Dashboard settings')} fill="text" size="sm" key="settings" variant="secondary" data-testid={selectors.components.NavToolbar.editDashboard.settingsButton} > - Settings + Settings ), }); @@ -490,14 +489,14 @@ export function ToolbarActions({ dashboard }: Props) { render: () => ( ), }); @@ -508,7 +507,11 @@ export function ToolbarActions({ dashboard }: Props) { render: () => ( ), }); @@ -527,14 +534,14 @@ export function ToolbarActions({ dashboard }: Props) { render: () => ( ), }); @@ -545,14 +552,14 @@ export function ToolbarActions({ dashboard }: Props) { render: () => ( ), }); @@ -563,14 +570,14 @@ export function ToolbarActions({ dashboard }: Props) { render: () => ( ), }); @@ -587,13 +594,13 @@ export function ToolbarActions({ dashboard }: Props) { dashboard.openSaveDrawer({}); }} className={styles.buttonWithExtraMargin} - tooltip="Save changes" + tooltip={t('dashboard.toolbar.save-dashboard.tooltip', 'Save changes')} key="save" size="sm" - variant={'primary'} + variant="primary" data-testid={selectors.components.NavToolbar.editDashboard.saveButton} > - Save dashboard + Save dashboard ); } @@ -606,12 +613,12 @@ export function ToolbarActions({ dashboard }: Props) { dashboard.openSaveDrawer({ saveAsCopy: true }); }} className={styles.buttonWithExtraMargin} - tooltip="Save as copy" + tooltip={t('dashboard.toolbar.save-dashboard-copy.tooltip', 'Save as copy')} key="save" size="sm" variant={isDirty ? 'primary' : 'secondary'} > - Save as copy + Save as copy ); } @@ -620,14 +627,14 @@ export function ToolbarActions({ dashboard }: Props) { const menu = ( { dashboard.openSaveDrawer({}); }} /> { dashboard.openSaveDrawer({ saveAsCopy: true }); @@ -642,16 +649,16 @@ export function ToolbarActions({ dashboard }: Props) { onClick={() => { dashboard.openSaveDrawer({}); }} - tooltip="Save changes" + tooltip={t('dashboard.toolbar.save-dashboard.tooltip', 'Save changes')} size="sm" data-testid={selectors.components.NavToolbar.editDashboard.saveButton} variant={isDirty ? 'primary' : 'secondary'} > - Save dashboard + Save dashboard + + + ); +} + +const getStyles = (theme: GrafanaTheme2) => { + const baseStyles = getInputStyles({ theme }); + + return { + wrapper: baseStyles.wrapper, + inputWrapper: baseStyles.inputWrapper, + fakeInput: css([ + baseStyles.input, + { + textAlign: 'left', + }, + ]), + }; +}; diff --git a/public/app/core/components/NestedFolderPicker/Trigger.tsx b/public/app/core/components/NestedFolderPicker/Trigger.tsx index 942beab6e96..fe352b806d3 100644 --- a/public/app/core/components/NestedFolderPicker/Trigger.tsx +++ b/public/app/core/components/NestedFolderPicker/Trigger.tsx @@ -1,13 +1,14 @@ import { css, cx } from '@emotion/css'; import { forwardRef, ReactNode, ButtonHTMLAttributes } from 'react'; import * as React from 'react'; -import Skeleton from 'react-loading-skeleton'; import { GrafanaTheme2 } from '@grafana/data'; import { Icon, getInputStyles, useTheme2, Text } from '@grafana/ui'; import { getFocusStyles, getMouseFocusStyles } from '@grafana/ui/src/themes/mixins'; import { Trans, t } from 'app/core/internationalization'; +import { FolderPickerSkeleton } from './Skeleton'; + interface TriggerProps extends ButtonHTMLAttributes { isLoading: boolean; handleClearSelection?: (event: React.MouseEvent | React.KeyboardEvent) => void; @@ -28,6 +29,10 @@ function Trigger( } }; + if (isLoading) { + return ; + } + return (
@@ -43,9 +48,7 @@ function Trigger( {...rest} ref={ref} > - {isLoading ? ( - - ) : label ? ( + {label ? ( {label} ) : ( diff --git a/public/app/plugins/panel/dashlist/module.tsx b/public/app/plugins/panel/dashlist/module.tsx index 8cb3c286e16..a4033ebc7dc 100644 --- a/public/app/plugins/panel/dashlist/module.tsx +++ b/public/app/plugins/panel/dashlist/module.tsx @@ -1,7 +1,6 @@ import { PanelPlugin } from '@grafana/data'; import { TagsInput } from '@grafana/ui'; import { FolderPicker } from 'app/core/components/Select/FolderPicker'; -import { PermissionLevelString } from 'app/types'; import { DashList } from './DashList'; import { dashlistMigrationHandler } from './migrations'; @@ -62,12 +61,7 @@ export const plugin = new PanelPlugin(DashList) defaultValue: undefined, editor: function RenderFolderPicker({ value, onChange }) { return ( - onChange(folderUID)} - /> + onChange(folderUID)} /> ); }, }) diff --git a/public/app/types/acl.ts b/public/app/types/acl.ts index b939d67c726..684a3a158b1 100644 --- a/public/app/types/acl.ts +++ b/public/app/types/acl.ts @@ -7,6 +7,9 @@ export enum TeamPermissionLevel { export { OrgRole as OrgRole }; +export type PermissionLevel = 'view' | 'edit' | 'admin'; + +/** @deprecated Use PermissionLevel instead */ export enum PermissionLevelString { View = 'View', Edit = 'Edit', From a93664ff3d189eb436a8fb449876d9dcabd3cf2d Mon Sep 17 00:00:00 2001 From: Matthew Jacobson Date: Wed, 5 Feb 2025 11:12:30 -0500 Subject: [PATCH 18/31] Alerting: Add EmbeddedContents as alternative embedding in smtp (#99937) Adds support for embedding []byte in SmtpClient instead of filenames. This is backwards compatible as it uses a new field EmbeddedContents to add an alternative to the existing EmbeddedFiles which takes filenames. --- pkg/services/notifications/email.go | 25 ++++++++++++------- pkg/services/notifications/mailer.go | 17 +++++++------ pkg/services/notifications/models.go | 19 +++++++------- pkg/services/notifications/notifications.go | 19 +++++++------- .../notifications/notifications_test.go | 25 +++++++++++++++++++ pkg/services/notifications/smtp.go | 7 ++++++ 6 files changed, 77 insertions(+), 35 deletions(-) diff --git a/pkg/services/notifications/email.go b/pkg/services/notifications/email.go index e50646b9662..c60adf605d5 100644 --- a/pkg/services/notifications/email.go +++ b/pkg/services/notifications/email.go @@ -11,17 +11,24 @@ type AttachedFile struct { Content []byte } +// EmbeddedContent struct represents an embedded file. +type EmbeddedContent struct { + Name string + Content []byte +} + // Message is representation of the email message. type Message struct { - To []string - SingleEmail bool - From string - Subject string - Body map[string]string - Info string - ReplyTo []string - EmbeddedFiles []string - AttachedFiles []*AttachedFile + To []string + SingleEmail bool + From string + Subject string + Body map[string]string + Info string + ReplyTo []string + EmbeddedFiles []string + EmbeddedContents []EmbeddedContent + AttachedFiles []*AttachedFile } func setDefaultTemplateData(cfg *setting.Cfg, data map[string]any, u *user.User) { diff --git a/pkg/services/notifications/mailer.go b/pkg/services/notifications/mailer.go index 7382454751f..eceaef3caf4 100644 --- a/pkg/services/notifications/mailer.go +++ b/pkg/services/notifications/mailer.go @@ -112,14 +112,15 @@ func (ns *NotificationService) buildEmailMessage(cmd *SendEmailCommand) (*Messag addr := mail.Address{Name: ns.Cfg.Smtp.FromName, Address: ns.Cfg.Smtp.FromAddress} return &Message{ - To: cmd.To, - SingleEmail: cmd.SingleEmail, - From: addr.String(), - Subject: subject, - Body: body, - EmbeddedFiles: cmd.EmbeddedFiles, - AttachedFiles: buildAttachedFiles(cmd.AttachedFiles), - ReplyTo: cmd.ReplyTo, + To: cmd.To, + SingleEmail: cmd.SingleEmail, + From: addr.String(), + Subject: subject, + Body: body, + EmbeddedFiles: cmd.EmbeddedFiles, + EmbeddedContents: cmd.EmbeddedContents, + AttachedFiles: buildAttachedFiles(cmd.AttachedFiles), + ReplyTo: cmd.ReplyTo, }, nil } diff --git a/pkg/services/notifications/models.go b/pkg/services/notifications/models.go index 364c6e6d262..6f5b1419ea5 100644 --- a/pkg/services/notifications/models.go +++ b/pkg/services/notifications/models.go @@ -18,15 +18,16 @@ type SendEmailAttachFile struct { // SendEmailCommand is the command for sending emails type SendEmailCommand struct { - To []string - SingleEmail bool - Template string - Subject string - Data map[string]any - Info string - ReplyTo []string - EmbeddedFiles []string - AttachedFiles []*SendEmailAttachFile + To []string + SingleEmail bool + Template string + Subject string + Data map[string]any + Info string + ReplyTo []string + EmbeddedFiles []string + EmbeddedContents []EmbeddedContent + AttachedFiles []*SendEmailAttachFile } // SendEmailCommandSync is the command for sending emails synchronously diff --git a/pkg/services/notifications/notifications.go b/pkg/services/notifications/notifications.go index 2be85628090..dc453874d14 100644 --- a/pkg/services/notifications/notifications.go +++ b/pkg/services/notifications/notifications.go @@ -201,15 +201,16 @@ func __dangerouslyInjectHTML(s string) template.HTML { func (ns *NotificationService) SendEmailCommandHandlerSync(ctx context.Context, cmd *SendEmailCommandSync) error { message, err := ns.buildEmailMessage(&SendEmailCommand{ - Data: cmd.Data, - Info: cmd.Info, - Template: cmd.Template, - To: cmd.To, - SingleEmail: cmd.SingleEmail, - EmbeddedFiles: cmd.EmbeddedFiles, - AttachedFiles: cmd.AttachedFiles, - Subject: cmd.Subject, - ReplyTo: cmd.ReplyTo, + Data: cmd.Data, + Info: cmd.Info, + Template: cmd.Template, + To: cmd.To, + SingleEmail: cmd.SingleEmail, + EmbeddedFiles: cmd.EmbeddedFiles, + EmbeddedContents: cmd.EmbeddedContents, + AttachedFiles: cmd.AttachedFiles, + Subject: cmd.Subject, + ReplyTo: cmd.ReplyTo, }) if err != nil { return err diff --git a/pkg/services/notifications/notifications_test.go b/pkg/services/notifications/notifications_test.go index 47c1f56ccb8..b1b3bdfdcaa 100644 --- a/pkg/services/notifications/notifications_test.go +++ b/pkg/services/notifications/notifications_test.go @@ -132,6 +132,31 @@ func TestSendEmailSync(t *testing.T) { require.Equal(t, []byte("text file content"), file.Content) }) + t.Run("When embedding readers to emails", func(t *testing.T) { + ns, mailer := createSut(t, bus) + cmd := &SendEmailCommandSync{ + SendEmailCommand: SendEmailCommand{ + Subject: "subject", + To: []string{"asdf@grafana.com"}, + SingleEmail: true, + Template: "welcome_on_signup", + EmbeddedContents: []EmbeddedContent{ + {Name: "embed.jpg", Content: []byte("image content")}, + }, + }, + } + + err := ns.SendEmailCommandHandlerSync(context.Background(), cmd) + require.NoError(t, err) + + require.NotEmpty(t, mailer.Sent) + sent := mailer.Sent[len(mailer.Sent)-1] + require.Len(t, sent.EmbeddedContents, 1) + f := sent.EmbeddedContents[0] + require.Equal(t, "embed.jpg", f.Name) + require.Equal(t, "image content", string(f.Content)) + }) + t.Run("When SMTP disabled in configuration", func(t *testing.T) { cfg := createSmtpConfig() cfg.Smtp.Enabled = false diff --git a/pkg/services/notifications/smtp.go b/pkg/services/notifications/smtp.go index 9ac0ecff570..c5eea8a958c 100644 --- a/pkg/services/notifications/smtp.go +++ b/pkg/services/notifications/smtp.go @@ -142,6 +142,13 @@ func (sc *SmtpClient) setFiles( m.Embed(file) } + for _, file := range msg.EmbeddedContents { + m.Embed(file.Name, gomail.SetCopyFunc(func(writer io.Writer) error { + _, err := writer.Write(file.Content) + return err + })) + } + for _, file := range msg.AttachedFiles { file := file m.Attach(file.Name, gomail.SetCopyFunc(func(writer io.Writer) error { From 130d268b51df3265891c7ea305f975ba8815725a Mon Sep 17 00:00:00 2001 From: Artur Wierzbicki Date: Wed, 5 Feb 2025 22:47:08 +0600 Subject: [PATCH 19/31] Dashboards: Update `POST /api/dashboards/db` docs (#99363) update dashboard update docs --- docs/sources/developers/http_api/dashboard.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/sources/developers/http_api/dashboard.md b/docs/sources/developers/http_api/dashboard.md index d37b0e080c9..fe08dc99f15 100644 --- a/docs/sources/developers/http_api/dashboard.md +++ b/docs/sources/developers/http_api/dashboard.md @@ -139,7 +139,6 @@ The **412** status code is used for explaining that you cannot create the dashbo There can be different reasons for this: - The dashboard has been changed by someone else, `status=version-mismatch` -- A dashboard with the same name in the folder already exists, `status=name-exists` - A dashboard with the same uid already exists, `status=name-exists` - The dashboard belongs to plugin ``, `status=plugin-dashboard` @@ -156,8 +155,6 @@ Content-Length: 97 } ``` -In case of title already exists the `status` property will be `name-exists`. - ## Get dashboard by uid `GET /api/dashboards/uid/:uid` From 92bb51bddacbbac5210e4ad8c959531ca5ec49c1 Mon Sep 17 00:00:00 2001 From: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> Date: Wed, 5 Feb 2025 12:08:50 -0500 Subject: [PATCH 20/31] Docs: move missing ref URI to correct page (#100131) --- docs/sources/dashboards/create-reports/_index.md | 5 ----- .../dashboards/create-reports/report-settings/index.md | 6 ++++++ 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/sources/dashboards/create-reports/_index.md b/docs/sources/dashboards/create-reports/_index.md index 12218600293..0b1de37d290 100644 --- a/docs/sources/dashboards/create-reports/_index.md +++ b/docs/sources/dashboards/create-reports/_index.md @@ -19,11 +19,6 @@ title: Create and manage reports description: Generate and share PDF reports from your Grafana dashboards weight: 600 refs: - change-ui-theme: - - pattern: /docs/grafana/ - destination: /docs/grafana//administration/organization-preferences/#change-grafana-ui-theme - - pattern: /docs/grafana-cloud/ - destination: /docs/grafana//administration/organization-preferences/#change-grafana-ui-theme grafana-enterprise: - pattern: /docs/grafana/ destination: /docs/grafana//introduction/grafana-enterprise/ diff --git a/docs/sources/dashboards/create-reports/report-settings/index.md b/docs/sources/dashboards/create-reports/report-settings/index.md index e0c606a8f65..b878953b71a 100644 --- a/docs/sources/dashboards/create-reports/report-settings/index.md +++ b/docs/sources/dashboards/create-reports/report-settings/index.md @@ -11,6 +11,12 @@ menuTitle: Settings title: Reporting settings description: Manage organizational Reporting settings weight: 700 +refs: + change-ui-theme: + - pattern: /docs/grafana/ + destination: /docs/grafana//administration/organization-preferences/#change-grafana-ui-theme + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana//administration/organization-preferences/#change-grafana-ui-theme --- # Reporting settings From d58dec79511964034c22846dc15d292728168d64 Mon Sep 17 00:00:00 2001 From: colin-stuart Date: Wed, 5 Feb 2025 12:58:14 -0500 Subject: [PATCH 21/31] Docs: Add docs for Passwordless Authentication Using Magic Links (#96877) * Docs: Add docs for Passwordless Authentication Using Magic Links * Update docs/sources/setup-grafana/configure-security/configure-authentication/passwordless/index.md Co-authored-by: Misi * Update docs/sources/setup-grafana/configure-security/configure-authentication/passwordless/index.md Co-authored-by: Misi * Update docs/sources/setup-grafana/configure-security/configure-authentication/passwordless/index.md Co-authored-by: Misi * match Writer's Toolkit style * Update docs/sources/setup-grafana/configure-security/configure-authentication/passwordless/index.md Co-authored-by: Jack Baldry --------- Co-authored-by: Misi Co-authored-by: Jack Baldry --- .../configure-authentication/_index.md | 1 + .../passwordless/index.md | 46 +++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 docs/sources/setup-grafana/configure-security/configure-authentication/passwordless/index.md diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/_index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/_index.md index 1c6b0ce9256..3e20606bb1e 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/_index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/_index.md @@ -25,6 +25,7 @@ The following table shows all supported authentication methods and the features | [Auth Proxy]({{< relref "./auth-proxy" >}}) | no | yes | yes | no | yes | no | N/A | no | N/A | N/A | | [Azure AD OAuth]({{< relref "./azuread" >}}) | yes | yes | yes | yes | yes | yes | N/A | yes | yes | yes | | [Basic auth]({{< relref "./grafana" >}}) | yes | N/A | yes | yes | N/A | N/A | N/A | N/A | N/A | N/A | +| [Passwordless auth]({{< relref "./passwordless" >}}) | yes | N/A | yes | yes | N/A | N/A | N/A | N/A | N/A | N/A | | [Generic OAuth]({{< relref "./generic-oauth" >}}) | yes | yes | yes | yes | yes | no | N/A | yes | yes | yes | | [GitHub OAuth]({{< relref "./github" >}}) | yes | yes | yes | yes | yes | yes | N/A | yes | yes | yes | | [GitLab OAuth]({{< relref "./gitlab" >}}) | yes | yes | yes | yes | yes | yes | N/A | yes | yes | yes | diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/passwordless/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/passwordless/index.md new file mode 100644 index 00000000000..06120e6c7da --- /dev/null +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/passwordless/index.md @@ -0,0 +1,46 @@ +--- +description: Learn how to configure passwordless authentication with magic links in Grafana +labels: + products: + - enterprise + - oss +menuTitle: Passwordless +title: Configure passwordless authentication with magic links +weight: 200 +--- + +# Configure passwordless authentication with magic links + +Passwordless authentication lets Grafana users authenticate with a magic link or one-time password (OTP) sent via email. + +## Enable passwordless authentication + +{{< docs/experimental product="passwordless authentication" featureFlag="passwordlessMagicLinkAuthentication" >}} + +To enable passwordless authentication, use the following configuration: + +```bash +[auth.passwordless] +enabled = true +``` + +## Code expiration + +By default, the one-time password (OTP) sent to a user's email is valid for 20 minutes. Use the `code_expiration` option to change the duration that the OTP is valid. + +```bash +[auth.passwordless] +enabled = true +code_expiration = 20m +``` + +## Enable SMTP server + +The SMTP server must be enabled so that Grafana can send emails. +The following configuration enables the SMTP server. +For more information on configuring the SMTP server, refer to [SMTP](https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/#smtp). + +```bash +[smtp] +enabled = true +``` From 6200361f366465069b309657a64a02274a8ba157 Mon Sep 17 00:00:00 2001 From: colin-stuart Date: Wed, 5 Feb 2025 13:16:36 -0500 Subject: [PATCH 22/31] Auth: Add IP address login attempt validation (#98123) * Auth: Add IP address login attempt validation * LoginAttempt struct IpAddress field must be camelCase to match db ip_address column * add setting DisableIPAddressLoginProtection * lint * add DisableIPAddressLoginProtection setting to tests * add request object to authenticate password test * nit suggestions & rename tests * add login attempt on failed password authentication * dont need to reset login attempts if successful * don't change error message * revert go.work.sum * Update pkg/services/authn/clients/password.go Co-authored-by: Misi --------- Co-authored-by: Misi --- conf/defaults.ini | 3 + conf/sample.ini | 3 + .../setup-grafana/configure-grafana/_index.md | 4 + pkg/services/authn/clients/password.go | 13 +- pkg/services/authn/clients/password_test.go | 26 ++-- pkg/services/authn/clients/passwordless.go | 10 +- pkg/services/loginattempt/login_attempt.go | 5 +- .../loginattemptimpl/login_attempt.go | 24 +++- .../loginattemptimpl/login_attempt_test.go | 114 ++++++++++++++++-- .../loginattempt/loginattemptimpl/models.go | 7 +- .../loginattempt/loginattemptimpl/store.go | 19 ++- .../loginattemptimpl/store_test.go | 12 +- .../loginattempt/loginattempttest/fake.go | 4 + .../loginattempt/loginattempttest/mock.go | 7 +- pkg/setting/setting.go | 2 + 15 files changed, 224 insertions(+), 29 deletions(-) diff --git a/conf/defaults.ini b/conf/defaults.ini index 9fa38434c95..e8e0a152dc8 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -354,6 +354,9 @@ disable_brute_force_login_protection = false # max number of failed login attempts before user gets locked brute_force_login_protection_max_attempts = 5 +# disable protection against brute force login attempts by IP address +disable_ip_address_login_protection = true + # set to true if you host Grafana behind HTTPS. default is false. cookie_secure = false diff --git a/conf/sample.ini b/conf/sample.ini index 985fb7034b6..9834c647f5a 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -353,6 +353,9 @@ # max number of failed login attempts before user gets locked ;brute_force_login_protection_max_attempts = 5 +# disable protection against brute force login attempts by IP address +; disable_ip_address_login_protection = true + # set to true if you host Grafana behind HTTPS. default is false. ;cookie_secure = false diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md index 5fae19f203e..4805b42a1a7 100644 --- a/docs/sources/setup-grafana/configure-grafana/_index.md +++ b/docs/sources/setup-grafana/configure-grafana/_index.md @@ -700,6 +700,10 @@ An existing user's account is unable to login for five minutes if all login atte Configure how many login attempts a user can have within a five minute window before their account is locked. Default is `5`. +#### `disable_ip_address_login_protection` + +Set to `true` to disable [brute force login protection by IP address](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html#account-lockout). Default is `true`. Anyone from the IP address will be unable to login for 5 minutes if all login attempts are spent within a 5 minute window. + #### `cookie_secure` Set to `true` if you host Grafana behind HTTPS. Default is `false`. diff --git a/pkg/services/authn/clients/password.go b/pkg/services/authn/clients/password.go index a44f8615086..76e82d4fc59 100644 --- a/pkg/services/authn/clients/password.go +++ b/pkg/services/authn/clients/password.go @@ -39,6 +39,14 @@ func (c *Password) AuthenticatePassword(ctx context.Context, r *authn.Request, u return nil, errPasswordAuthFailed.Errorf("too many consecutive incorrect login attempts for user - login for user temporarily blocked") } + ok, err = c.loginAttempts.ValidateIPAddress(ctx, web.RemoteAddr(r.HTTPRequest)) + if err != nil { + return nil, err + } + if !ok { + return nil, errPasswordlessClientTooManyLoginAttempts.Errorf("too many consecutive incorrect login attempts for IP address - login for IP address temporarily blocked") + } + if len(password) == 0 { return nil, errPasswordAuthFailed.Errorf("no password provided") } @@ -56,8 +64,9 @@ func (c *Password) AuthenticatePassword(ctx context.Context, r *authn.Request, u return identity, nil } - if errors.Is(clientErrs, errInvalidPassword) { - _ = c.loginAttempts.Add(ctx, username, web.RemoteAddr(r.HTTPRequest)) + err = c.loginAttempts.Add(ctx, username, web.RemoteAddr(r.HTTPRequest)) + if err != nil { + return nil, err } return nil, errPasswordAuthFailed.Errorf("failed to authenticate identity: %w", clientErrs) diff --git a/pkg/services/authn/clients/password_test.go b/pkg/services/authn/clients/password_test.go index c981cfd4b01..6f7619ce1b7 100644 --- a/pkg/services/authn/clients/password_test.go +++ b/pkg/services/authn/clients/password_test.go @@ -2,6 +2,8 @@ package clients import ( "context" + "net/http" + "net/url" "testing" "github.com/stretchr/testify/assert" @@ -18,7 +20,6 @@ func TestPassword_AuthenticatePassword(t *testing.T) { desc string username string password string - req *authn.Request blockLogin bool clients []authn.PasswordClient expectedErr error @@ -30,7 +31,6 @@ func TestPassword_AuthenticatePassword(t *testing.T) { desc: "should success when password client return identity", username: "test", password: "test", - req: &authn.Request{}, clients: []authn.PasswordClient{authntest.FakePasswordClient{ExpectedIdentity: &authn.Identity{ID: "1", Type: claims.TypeUser}}}, expectedIdentity: &authn.Identity{ID: "1", Type: claims.TypeUser}, }, @@ -38,7 +38,6 @@ func TestPassword_AuthenticatePassword(t *testing.T) { desc: "should success when found in second client", username: "test", password: "test", - req: &authn.Request{}, clients: []authn.PasswordClient{authntest.FakePasswordClient{ExpectedErr: errIdentityNotFound}, authntest.FakePasswordClient{ExpectedIdentity: &authn.Identity{ID: "2", Type: claims.TypeUser}}}, expectedIdentity: &authn.Identity{ID: "2", Type: claims.TypeUser}, }, @@ -46,14 +45,12 @@ func TestPassword_AuthenticatePassword(t *testing.T) { desc: "should fail for empty password", username: "test", password: "", - req: &authn.Request{}, expectedErr: errPasswordAuthFailed, }, { desc: "should if login is blocked by to many attempts", username: "test", password: "test", - req: &authn.Request{}, blockLogin: true, expectedErr: errPasswordAuthFailed, }, @@ -61,7 +58,6 @@ func TestPassword_AuthenticatePassword(t *testing.T) { desc: "should fail when not found in any clients", username: "test", password: "test", - req: &authn.Request{}, clients: []authn.PasswordClient{authntest.FakePasswordClient{ExpectedErr: errIdentityNotFound}, authntest.FakePasswordClient{ExpectedErr: errIdentityNotFound}}, expectedErr: errPasswordAuthFailed, }, @@ -70,8 +66,22 @@ func TestPassword_AuthenticatePassword(t *testing.T) { for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { c := ProvidePassword(loginattempttest.FakeLoginAttemptService{ExpectedValid: !tt.blockLogin}, tt.clients...) - - identity, err := c.AuthenticatePassword(context.Background(), tt.req, tt.username, tt.password) + r := &authn.Request{ + OrgID: 12345, + HTTPRequest: &http.Request{ + Method: "GET", + URL: &url.URL{ + Scheme: "https", + Host: "example.com", + Path: "/api/v1/resource", + }, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + "User-Agent": []string{"MyApp/1.0"}, + }, + }, + } + identity, err := c.AuthenticatePassword(context.Background(), r, tt.username, tt.password) if tt.expectedErr != nil { assert.ErrorIs(t, err, tt.expectedErr) assert.Nil(t, identity) diff --git a/pkg/services/authn/clients/passwordless.go b/pkg/services/authn/clients/passwordless.go index cda17035e81..3ee20ff055f 100644 --- a/pkg/services/authn/clients/passwordless.go +++ b/pkg/services/authn/clients/passwordless.go @@ -105,7 +105,6 @@ func (c *Passwordless) RedirectURL(ctx context.Context, r *authn.Request) (*auth return nil, err } - // TODO: add IP address validation ok, err := c.loginAttempts.Validate(ctx, form.Email) if err != nil { return nil, err @@ -115,6 +114,15 @@ func (c *Passwordless) RedirectURL(ctx context.Context, r *authn.Request) (*auth return nil, errPasswordlessClientTooManyLoginAttempts.Errorf("too many consecutive incorrect login attempts for user - login for user temporarily blocked") } + ok, err = c.loginAttempts.ValidateIPAddress(ctx, web.RemoteAddr(r.HTTPRequest)) + if err != nil { + return nil, err + } + + if !ok { + return nil, errPasswordlessClientTooManyLoginAttempts.Errorf("too many consecutive incorrect login attempts for IP address - login for IP address temporarily blocked") + } + err = c.loginAttempts.Add(ctx, form.Email, web.RemoteAddr(r.HTTPRequest)) if err != nil { return nil, err diff --git a/pkg/services/loginattempt/login_attempt.go b/pkg/services/loginattempt/login_attempt.go index ce3233f0f3b..7758e6716d4 100644 --- a/pkg/services/loginattempt/login_attempt.go +++ b/pkg/services/loginattempt/login_attempt.go @@ -6,10 +6,13 @@ import ( type Service interface { // Add adds a new login attempt record for provided username - Add(ctx context.Context, username, IPAddress string) error + Add(ctx context.Context, username, ipAddress string) error // Validate checks if username has to many login attempts inside a window. // Will return true if provided username do not have too many attempts. Validate(ctx context.Context, username string) (bool, error) + // Validate checks if IP address has to many login attempts inside a window. + // Will return true if provided IP address do not have too many attempts. + ValidateIPAddress(ctx context.Context, ipAddress string) (bool, error) // Reset resets all login attempts attached to username Reset(ctx context.Context, username string) error } diff --git a/pkg/services/loginattempt/loginattemptimpl/login_attempt.go b/pkg/services/loginattempt/loginattemptimpl/login_attempt.go index f55b8fe34b1..3c901415333 100644 --- a/pkg/services/loginattempt/loginattemptimpl/login_attempt.go +++ b/pkg/services/loginattempt/loginattemptimpl/login_attempt.go @@ -53,7 +53,7 @@ func (s *Service) Add(ctx context.Context, username, IPAddress string) error { _, err := s.store.CreateLoginAttempt(ctx, CreateLoginAttemptCommand{ Username: strings.ToLower(username), - IpAddress: IPAddress, + IPAddress: IPAddress, }) return err } @@ -84,6 +84,28 @@ func (s *Service) Validate(ctx context.Context, username string) (bool, error) { return true, nil } +func (s *Service) ValidateIPAddress(ctx context.Context, IPAddress string) (bool, error) { + if s.cfg.DisableIPAddressLoginProtection { + return true, nil + } + + loginAttemptCountQuery := GetIPLoginAttemptCountQuery{ + IPAddress: IPAddress, + Since: time.Now().Add(-loginAttemptsWindow), + } + + count, err := s.store.GetIPLoginAttemptCount(ctx, loginAttemptCountQuery) + if err != nil { + return false, err + } + + if count >= s.cfg.BruteForceLoginProtectionMaxAttempts { + return false, nil + } + + return true, nil +} + func (s *Service) cleanup(ctx context.Context) { err := s.lock.LockAndExecute(ctx, "delete old login attempts", time.Minute*10, func(context.Context) { cmd := DeleteOldLoginAttemptsCommand{ diff --git a/pkg/services/loginattempt/loginattemptimpl/login_attempt_test.go b/pkg/services/loginattempt/loginattemptimpl/login_attempt_test.go index 7015dff01e9..96e19039f28 100644 --- a/pkg/services/loginattempt/loginattemptimpl/login_attempt_test.go +++ b/pkg/services/loginattempt/loginattemptimpl/login_attempt_test.go @@ -22,40 +22,40 @@ func TestService_Validate(t *testing.T) { expectedErr error }{ { - name: "When brute force protection enabled and user login attempt count is less than max", + name: "Should be valid when brute force protection enabled and user login attempt count is less than max", loginAttempts: maxInvalidLoginAttempts - 1, expected: true, expectedErr: nil, }, { - name: "When brute force protection enabled and user login attempt count equals max", + name: "Should be invalid when brute force protection enabled and user login attempt count equals max", loginAttempts: maxInvalidLoginAttempts, expected: false, expectedErr: nil, }, { - name: "When brute force protection enabled and user login attempt count is greater than max", + name: "Should be invalid when brute force protection enabled and user login attempt count is greater than max", loginAttempts: maxInvalidLoginAttempts + 1, expected: false, expectedErr: nil, }, { - name: "When brute force protection disabled and user login attempt count is less than max", + name: "Should be valid when brute force protection disabled and user login attempt count is less than max", loginAttempts: maxInvalidLoginAttempts - 1, disabled: true, expected: true, expectedErr: nil, }, { - name: "When brute force protection disabled and user login attempt count equals max", + name: "Should be valid when brute force protection disabled and user login attempt count equals max", loginAttempts: maxInvalidLoginAttempts, disabled: true, expected: true, expectedErr: nil, }, { - name: "When brute force protection disabled and user login attempt count is greater than max", + name: "Should be valid when brute force protection disabled and user login attempt count is greater than max", loginAttempts: maxInvalidLoginAttempts + 1, disabled: true, expected: true, @@ -83,7 +83,7 @@ func TestService_Validate(t *testing.T) { } } -func TestLoginAttempts(t *testing.T) { +func TestUserLoginAttempts(t *testing.T) { ctx := context.Background() cfg := setting.NewCfg() cfg.DisableBruteForceLoginProtection = false @@ -109,6 +109,102 @@ func TestLoginAttempts(t *testing.T) { assert.Nil(t, err) } +func TestService_ValidateIPAddress(t *testing.T) { + const maxInvalidLoginAttempts = 5 + + testCases := []struct { + name string + loginAttempts int64 + disabled bool + expected bool + expectedErr error + }{ + { + name: "Should be valid when brute force protection enabled and IP address login attempt count is less than max", + loginAttempts: maxInvalidLoginAttempts - 1, + expected: true, + expectedErr: nil, + }, + { + name: "Should be invalid when brute force protection enabled and IP address login attempt count equals max", + loginAttempts: maxInvalidLoginAttempts, + expected: false, + expectedErr: nil, + }, + { + name: "Should be invalid when brute force protection enabled and IP address login attempt count is greater than max", + loginAttempts: maxInvalidLoginAttempts + 1, + expected: false, + expectedErr: nil, + }, + + { + name: "Should be valid when brute force protection disabled and IP address login attempt count is less than max", + loginAttempts: maxInvalidLoginAttempts - 1, + disabled: true, + expected: true, + expectedErr: nil, + }, + { + name: "Should be valid when brute force protection disabled and IP address login attempt count equals max", + loginAttempts: maxInvalidLoginAttempts, + disabled: true, + expected: true, + expectedErr: nil, + }, + { + name: "Should be valid when brute force protection disabled and IP address login attempt count is greater than max", + loginAttempts: maxInvalidLoginAttempts + 1, + disabled: true, + expected: true, + expectedErr: nil, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + cfg := setting.NewCfg() + cfg.BruteForceLoginProtectionMaxAttempts = maxInvalidLoginAttempts + cfg.DisableIPAddressLoginProtection = tt.disabled + service := &Service{ + store: fakeStore{ + ExpectedCount: tt.loginAttempts, + ExpectedErr: tt.expectedErr, + }, + cfg: cfg, + } + + ok, err := service.ValidateIPAddress(context.Background(), "192.168.1.1") + assert.Equal(t, tt.expected, ok) + assert.Equal(t, tt.expectedErr, err) + }) + } +} + +func TestIPLoginAttempts(t *testing.T) { + ctx := context.Background() + cfg := setting.NewCfg() + cfg.DisableIPAddressLoginProtection = false + cfg.BruteForceLoginProtectionMaxAttempts = 3 + db := db.InitTestDB(t) + service := ProvideService(db, cfg, nil) + + _ = service.Add(ctx, "user1", "192.168.1.1") + _ = service.Add(ctx, "user2", "10.0.0.123") + _ = service.Add(ctx, "user3", "192.168.1.1") + _ = service.Add(ctx, "user4", "[::1]") + _ = service.Add(ctx, "user5", "192.168.1.1") + _ = service.Add(ctx, "user6", "192.168.1.1") + + count, err := service.store.GetIPLoginAttemptCount(ctx, GetIPLoginAttemptCountQuery{IPAddress: "192.168.1.1"}) + assert.Nil(t, err) + assert.Equal(t, int64(4), count) + + ok, err := service.ValidateIPAddress(ctx, "192.168.1.1") + assert.False(t, ok) + assert.Nil(t, err) +} + var _ store = new(fakeStore) type fakeStore struct { @@ -121,6 +217,10 @@ func (f fakeStore) GetUserLoginAttemptCount(ctx context.Context, query GetUserLo return f.ExpectedCount, f.ExpectedErr } +func (f fakeStore) GetIPLoginAttemptCount(ctx context.Context, query GetIPLoginAttemptCountQuery) (int64, error) { + return f.ExpectedCount, f.ExpectedErr +} + func (f fakeStore) CreateLoginAttempt(ctx context.Context, command CreateLoginAttemptCommand) (loginattempt.LoginAttempt, error) { return loginattempt.LoginAttempt{}, f.ExpectedErr } diff --git a/pkg/services/loginattempt/loginattemptimpl/models.go b/pkg/services/loginattempt/loginattemptimpl/models.go index f235cd3d308..4957daa8414 100644 --- a/pkg/services/loginattempt/loginattemptimpl/models.go +++ b/pkg/services/loginattempt/loginattemptimpl/models.go @@ -6,7 +6,7 @@ import ( type CreateLoginAttemptCommand struct { Username string - IpAddress string + IPAddress string } type GetUserLoginAttemptCountQuery struct { @@ -14,6 +14,11 @@ type GetUserLoginAttemptCountQuery struct { Since time.Time } +type GetIPLoginAttemptCountQuery struct { + IPAddress string + Since time.Time +} + type DeleteOldLoginAttemptsCommand struct { OlderThan time.Time } diff --git a/pkg/services/loginattempt/loginattemptimpl/store.go b/pkg/services/loginattempt/loginattemptimpl/store.go index f273e6f5ef0..0afa93fbaeb 100644 --- a/pkg/services/loginattempt/loginattemptimpl/store.go +++ b/pkg/services/loginattempt/loginattemptimpl/store.go @@ -18,13 +18,14 @@ type store interface { DeleteOldLoginAttempts(ctx context.Context, cmd DeleteOldLoginAttemptsCommand) (int64, error) DeleteLoginAttempts(ctx context.Context, cmd DeleteLoginAttemptsCommand) error GetUserLoginAttemptCount(ctx context.Context, query GetUserLoginAttemptCountQuery) (int64, error) + GetIPLoginAttemptCount(ctx context.Context, query GetIPLoginAttemptCountQuery) (int64, error) } func (xs *xormStore) CreateLoginAttempt(ctx context.Context, cmd CreateLoginAttemptCommand) (result loginattempt.LoginAttempt, err error) { err = xs.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error { loginAttempt := loginattempt.LoginAttempt{ Username: cmd.Username, - IpAddress: cmd.IpAddress, + IpAddress: cmd.IPAddress, Created: xs.now().Unix(), } @@ -73,6 +74,22 @@ func (xs *xormStore) GetUserLoginAttemptCount(ctx context.Context, query GetUser And("created >= ?", query.Since.Unix()). Count(loginAttempt) + return queryErr + }) + + return total, err +} + +func (xs *xormStore) GetIPLoginAttemptCount(ctx context.Context, query GetIPLoginAttemptCountQuery) (int64, error) { + var total int64 + err := xs.db.WithDbSession(ctx, func(dbSession *db.Session) error { + var queryErr error + loginAttempt := new(loginattempt.LoginAttempt) + total, queryErr = dbSession. + Where("ip_address = ?", query.IPAddress). + And("created >= ?", query.Since.Unix()). + Count(loginAttempt) + if queryErr != nil { return queryErr } diff --git a/pkg/services/loginattempt/loginattemptimpl/store_test.go b/pkg/services/loginattempt/loginattemptimpl/store_test.go index 194e64ff572..c8041bbd6da 100644 --- a/pkg/services/loginattempt/loginattemptimpl/store_test.go +++ b/pkg/services/loginattempt/loginattemptimpl/store_test.go @@ -60,21 +60,21 @@ func TestIntegrationLoginAttemptsQuery(t *testing.T) { _, err := s.CreateLoginAttempt(context.Background(), CreateLoginAttemptCommand{ Username: user, - IpAddress: "192.168.0.1", + IPAddress: "192.168.0.1", }) require.Nil(t, err) mockTime = timePlusOneMinute _, err = s.CreateLoginAttempt(context.Background(), CreateLoginAttemptCommand{ Username: user, - IpAddress: "192.168.0.1", + IPAddress: "192.168.0.1", }) require.Nil(t, err) mockTime = timePlusTwoMinutes _, err = s.CreateLoginAttempt(context.Background(), CreateLoginAttemptCommand{ Username: user, - IpAddress: "192.168.0.1", + IPAddress: "192.168.0.1", }) require.Nil(t, err) @@ -125,21 +125,21 @@ func TestIntegrationLoginAttemptsDelete(t *testing.T) { _, err := s.CreateLoginAttempt(context.Background(), CreateLoginAttemptCommand{ Username: user, - IpAddress: "192.168.0.1", + IPAddress: "192.168.0.1", }) require.Nil(t, err) mockTime = timePlusOneMinute _, err = s.CreateLoginAttempt(context.Background(), CreateLoginAttemptCommand{ Username: user, - IpAddress: "192.168.0.1", + IPAddress: "192.168.0.1", }) require.Nil(t, err) mockTime = timePlusTwoMinutes _, err = s.CreateLoginAttempt(context.Background(), CreateLoginAttemptCommand{ Username: user, - IpAddress: "192.168.0.1", + IPAddress: "192.168.0.1", }) require.Nil(t, err) diff --git a/pkg/services/loginattempt/loginattempttest/fake.go b/pkg/services/loginattempt/loginattempttest/fake.go index 581aeae1742..4e6cb56b52b 100644 --- a/pkg/services/loginattempt/loginattempttest/fake.go +++ b/pkg/services/loginattempt/loginattempttest/fake.go @@ -24,3 +24,7 @@ func (f FakeLoginAttemptService) Reset(ctx context.Context, username string) err func (f FakeLoginAttemptService) Validate(ctx context.Context, username string) (bool, error) { return f.ExpectedValid, f.ExpectedErr } + +func (f FakeLoginAttemptService) ValidateIPAddress(ctx context.Context, IpAddress string) (bool, error) { + return f.ExpectedValid, f.ExpectedErr +} diff --git a/pkg/services/loginattempt/loginattempttest/mock.go b/pkg/services/loginattempt/loginattempttest/mock.go index c775323a9a3..33cff397161 100644 --- a/pkg/services/loginattempt/loginattempttest/mock.go +++ b/pkg/services/loginattempt/loginattempttest/mock.go @@ -17,7 +17,7 @@ type MockLoginAttemptService struct { ExpectedErr error } -func (f *MockLoginAttemptService) Add(ctx context.Context, username, IPAddress string) error { +func (f *MockLoginAttemptService) Add(ctx context.Context, username, ipAddress string) error { f.AddCalled = true return f.ExpectedErr } @@ -31,3 +31,8 @@ func (f *MockLoginAttemptService) Validate(ctx context.Context, username string) f.ValidateCalled = true return f.ExpectedValid, f.ExpectedErr } + +func (f *MockLoginAttemptService) ValidateIPAddress(ctx context.Context, ipAddress string) (bool, error) { + f.ValidateCalled = true + return f.ExpectedValid, f.ExpectedErr +} diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index d1f47153138..5b560c3b937 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -156,6 +156,7 @@ type Cfg struct { DisableInitAdminCreation bool DisableBruteForceLoginProtection bool BruteForceLoginProtectionMaxAttempts int64 + DisableIPAddressLoginProtection bool CookieSecure bool CookieSameSiteDisabled bool CookieSameSiteMode http.SameSite @@ -1527,6 +1528,7 @@ func readSecuritySettings(iniFile *ini.File, cfg *Cfg) error { cfg.DisableBruteForceLoginProtection = security.Key("disable_brute_force_login_protection").MustBool(false) cfg.BruteForceLoginProtectionMaxAttempts = security.Key("brute_force_login_protection_max_attempts").MustInt64(5) + cfg.DisableIPAddressLoginProtection = security.Key("disable_ip_address_login_protection").MustBool(true) // Ensure at least one login attempt can be performed. if cfg.BruteForceLoginProtectionMaxAttempts <= 0 { From 6787cdccb9670f79ed90a2ea938f7b5a2878c3cd Mon Sep 17 00:00:00 2001 From: Kevin Minehart <5140827+kminehart@users.noreply.github.com> Date: Wed, 5 Feb 2025 13:04:03 -0600 Subject: [PATCH 23/31] CI: Backport to release branches (#100067) * update backport and release comms * Backport to release branches and change docs source branch for publishing * Add new workflows to CODEOWNERS * Re-add removed line oops * backport-testing -> grafana * checkout grafana repo in backport action, reference repo / branch in reusable action * generate -> create --- .github/CODEOWNERS | 2 + .github/workflows/backport.yml | 15 ++---- .../workflows/create-next-release-branch.yml | 43 ++++++++++++++++ .github/workflows/migrate-prs.yml | 50 +++++++++++++++++++ ...ublish-technical-documentation-release.yml | 6 +-- .github/workflows/release-comms.yml | 50 +++++++++++++++++++ .github/workflows/release-pr.yml | 18 ++++--- 7 files changed, 165 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/create-next-release-branch.yml create mode 100644 .github/workflows/migrate-prs.yml diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 645625d3d19..cd0bde5129f 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -756,6 +756,8 @@ embed.go @grafana/grafana-as-code /.github/workflows/close-milestone.yml @grafana/grafana-developer-enablement-squad /.github/workflows/release-pr.yml @grafana/grafana-developer-enablement-squad /.github/workflows/release-comms.yml @grafana/grafana-developer-enablement-squad +/.github/workflows/migrate-prs.yml @grafana/grafana-developer-enablement-squad +/.github/workflows/create-next-release-branch.yml @grafana/grafana-developer-enablement-squad /.github/workflows/codeowners-validator.yml @tolzhabayev /.github/workflows/codeql-analysis.yml @DanCech /.github/workflows/commands.yml @torkelo diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml index 18407b67f76..384a3b36ddf 100644 --- a/.github/workflows/backport.yml +++ b/.github/workflows/backport.yml @@ -10,24 +10,19 @@ jobs: if: github.repository == 'grafana/grafana' runs-on: ubuntu-latest steps: - - name: Checkout Actions + - name: Checkout uses: actions/checkout@v4 - with: - repository: "grafana/grafana-github-actions" - path: ./actions ref: main - - name: Install Actions - run: npm install --production --prefix ./actions - name: "Generate token" id: generate_token uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 with: app_id: ${{ secrets.GRAFANA_DELIVERY_BOT_APP_ID }} private_key: ${{ secrets.GRAFANA_DELIVERY_BOT_APP_PEM }} + - run: git config --global user.email '132647405+grafana-delivery-bot[bot]@users.noreply.github.com' + - run: git config --global user.name 'grafana-delivery-bot[bot]' + - run: git remote set-url origin "https://grafana-delivery-bot:${{ steps.generate_token.outputs.token }}@github.com/grafana/grafana.git" - name: Run backport - uses: ./actions/backport + uses: grafana/grafana-github-actions-go/backport@main with: - metricsWriteAPIKey: ${{secrets.GRAFANA_MISC_STATS_API_KEY}} token: ${{ steps.generate_token.outputs.token }} - labelsToAdd: "backport" - title: "[{{base}}] {{originalTitle}}" diff --git a/.github/workflows/create-next-release-branch.yml b/.github/workflows/create-next-release-branch.yml new file mode 100644 index 00000000000..e1cc71c02a0 --- /dev/null +++ b/.github/workflows/create-next-release-branch.yml @@ -0,0 +1,43 @@ +name: Create next release branch +on: + workflow_call: + inputs: + ownerRepo: + type: string + description: Owner/repo of the repository where the branch is created (e.g. 'grafana/grafana') + required: true + source: + description: The release branch to increment (eg providing `release-11.2.3` will result in `release-11.2.4` being created) + type: string + required: true + secrets: + token: + required: true + outputs: + branch: + description: The new branch that was created + value: ${{ jobs.main.outputs.branch }} + workflow_dispatch: + inputs: + ownerRepo: + description: Owner/repo of the repository where the branch is created (e.g. 'grafana/grafana') + source: + description: The release branch to increment (eg providing `release-11.2.3` will result in `release-11.2.4` being created) + type: string + required: true + secrets: + token: + required: true +jobs: + main: + runs-on: ubuntu-latest + outputs: + branch: ${{ steps.branch.outputs.branch }} + steps: + - name: Create release branch + id: branch + uses: grafana/grafana-github-actions-go/bump-release@main + with: + ownerRepo: ${{ inputs.ownerRepo }} + source: ${{ inputs.source }} + token: ${{ secrets.token }} diff --git a/.github/workflows/migrate-prs.yml b/.github/workflows/migrate-prs.yml new file mode 100644 index 00000000000..9a41ee6ee8a --- /dev/null +++ b/.github/workflows/migrate-prs.yml @@ -0,0 +1,50 @@ +name: Migrate open PRs +# Migrate open PRs from a superseded release branch to the current release branch and notify authors +on: + workflow_call: + inputs: + from: + description: 'The base branch to check for open PRs' + required: true + type: string + to: + description: 'The base branch to migrate open PRs to' + required: true + type: string + ownerRepo: + description: Owner/repo of the repository where the branch is created (e.g. 'grafana/grafana') + required: true + type: string + secrets: + token: + required: true + workflow_dispatch: + inputs: + from: + description: 'The base branch to check for open PRs' + required: true + type: string + to: + description: 'The base branch to migrate open PRs to' + required: true + type: string + ownerRepo: + description: Owner/repo of the repository where the branch is created (e.g. 'grafana/grafana') + required: true + type: string + secrets: + token: + required: true + +jobs: + main: + runs-on: ubuntu-latest + steps: + - name: Migrate PRs + uses: grafana/grafana-github-actions-go/migrate-open-prs@main + with: + token: ${{ secrets.token }} + ownerRepo: ${{ inputs.ownerRepo }} + from: ${{ inputs.from }} + to: ${{ inputs.to }} + binary_release_tag: 'dev' diff --git a/.github/workflows/publish-technical-documentation-release.yml b/.github/workflows/publish-technical-documentation-release.yml index b36eb48885a..bd77c286018 100644 --- a/.github/workflows/publish-technical-documentation-release.yml +++ b/.github/workflows/publish-technical-documentation-release.yml @@ -3,7 +3,7 @@ name: publish-technical-documentation-release on: push: branches: - - v[0-9]+.[0-9]+.x + - release-v[0-9]+.[0-9]+.[0-9]+ tags: - v[0-9]+.[0-9]+.[0-9]+ paths: @@ -23,7 +23,7 @@ jobs: - uses: grafana/writers-toolkit/publish-technical-documentation-release@publish-technical-documentation-release/v2 with: release_tag_regexp: "^v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$" - release_branch_regexp: "^v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.x$" - release_branch_with_patch_regexp: "^v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$" + release_branch_regexp: "^release-(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$" + release_branch_with_patch_regexp: "^release-(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$" website_directory: content/docs/grafana version_suffix: "" diff --git a/.github/workflows/release-comms.yml b/.github/workflows/release-comms.yml index 02d53057184..fbc7d94ec13 100644 --- a/.github/workflows/release-comms.yml +++ b/.github/workflows/release-comms.yml @@ -27,10 +27,18 @@ jobs: name: Setup and establish latest outputs: version: ${{ steps.output.outputs.version }} + release_branch: ${{ steps.output.outputs.release_branch }} dry_run: ${{ steps.output.outputs.dry_run }} latest: ${{ steps.output.outputs.latest }} + token: ${{ steps.output.outputs.token }} runs-on: ubuntu-latest steps: + - name: "Generate token" + id: generate_token + uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 + with: + app_id: ${{ secrets.GRAFANA_DELIVERY_BOT_APP_ID }} + private_key: ${{ secrets.GRAFANA_DELIVERY_BOT_APP_PEM }} # The github-release action expects a `LATEST` value of a string of either '1' or '0' - if: ${{ github.event_name == 'workflow_dispatch' }} run: | @@ -49,9 +57,51 @@ jobs: echo "latest: $LATEST" echo "version: $VERSION" + echo "release_branch=$(echo $VERSION | sed -s 's/^v/release-/g')" >> "$GITHUB_OUTPUT" + echo "token=${{ steps.generate_token.outputs.token }}" >> "$GITHUB_OUTPUT" echo "dry_run=$DRY_RUN" >> "$GITHUB_OUTPUT" echo "latest=$LATEST" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT" + create_next_release_branch_grafana: + name: Create next release branch (Grafana) + needs: setup + uses: ./.github/workflows/create-next-release-branch.yml + with: + ownerRepo: 'grafana/grafana' + source: ${{ needs.setup.outputs.release_branch }} + secrets: + token: ${{ needs.setup.outputs.token }} + create_next_release_branch_enterprise: + name: Create next release branch (Grafana Enterprise) + needs: setup + uses: ./.github/workflows/create-next-release-branch.yml + with: + ownerRepo: 'grafana/grafana' + source: ${{ needs.setup.outputs.release_branch }} + secrets: + token: ${{ needs.setup.outputs.token }} + migrate_prs_grafana: + needs: + - setup + - create_next_release_branch_grafana + uses: ./.github/workflows/migrate-prs.yml + with: + ownerRepo: 'grafana/grafana' + from: ${{ needs.setup.outputs.release_branch }} + to: ${{ needs.create_next_release_branch_grafana.outputs.branch }} + secrets: + token: ${{ needs.setup.outputs.token }} + migrate_prs_enterprise: + needs: + - setup + - create_next_release_branch_enterprise + uses: ./.github/workflows/migrate-prs.yml + with: + ownerRepo: 'grafana/grafana-enterprise' + from: ${{ needs.setup.outputs.release_branch }} + to: ${{ needs.create_next_release_branch_enterprise.outputs.branch }} + secrets: + token: ${{ needs.setup.outputs.token }} post_changelog_on_forum: needs: setup uses: ./.github/workflows/community-release.yml diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index d6bcd7d5446..004d1ab3299 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -4,7 +4,7 @@ # Please refrain from including any processes that do not result in code changes in this workflow. Instead, they should # either be triggered in the release promotion process or in the release comms process (that is triggered by merging # this PR). -name: Complete a Grafana release +name: Grafana Release PR on: workflow_dispatch: inputs: @@ -19,7 +19,7 @@ on: target: required: true type: string - description: The base branch that these changes are being merged into + description: The release branch pattern (eg v9.5.x) that these changes are being merged into backport: required: false type: string @@ -61,10 +61,17 @@ jobs: with: app_id: ${{ secrets.GRAFANA_DELIVERY_BOT_APP_ID }} private_key: ${{ secrets.GRAFANA_DELIVERY_BOT_APP_PEM }} + - name: Get release branch + id: branch + uses: grafana/grafana-github-actions-go/latest-release-branch@main + with: + token: ${{ steps.generate_token.outputs.token }} + ownerRepo: 'grafana/grafana' + pattern: ${{ inputs.target }} - name: Checkout Grafana uses: actions/checkout@v4 with: - ref: ${{ inputs.target }} + ref: ${{ steps.branch.outputs.branch }} fetch-depth: 0 fetch-tags: true - name: Checkout Grafana (main) @@ -124,7 +131,6 @@ jobs: rm -f CHANGELOG.part changelog_items.md git diff CHANGELOG.md - - name: "Prettify CHANGELOG.md" run: npx prettier --write CHANGELOG.md - name: Commit CHANGELOG.md changes @@ -151,7 +157,7 @@ jobs: $( [ "x${{ inputs.latest }}" == "xtrue" ] && printf %s '-l "release/latest"') \ -l "no-changelog" \ --dry-run=${{ inputs.dry_run }} \ - -B "${{ inputs.target }}" \ + -B "${{ steps.branch.outputs.branch }}" \ --title "Release: ${{ inputs.version }}" \ --body "These code changes must be merged after a release is complete" env: @@ -165,7 +171,7 @@ jobs: -l "product-approved" \ -l "no-changelog" \ --dry-run=${{ inputs.dry_run }} \ - -B "${{ inputs.target }}" \ + -B "${{ steps.branch.outputs.branch }}" \ --title "Release: ${{ inputs.version }}" \ --body "These code changes must be merged after a release is complete" env: From 33b11d5c76baa5f6885776e16ba9090bcbd1a71f Mon Sep 17 00:00:00 2001 From: Yuri Tseretyan Date: Wed, 5 Feb 2025 14:15:02 -0500 Subject: [PATCH 24/31] Alerting: Remove ID and OrgID from hash calculation (#100140) --- pkg/services/ngalert/schedule/registry.go | 2 -- pkg/services/ngalert/schedule/registry_test.go | 2 ++ 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/services/ngalert/schedule/registry.go b/pkg/services/ngalert/schedule/registry.go index 2b162ca87be..11f72229f51 100644 --- a/pkg/services/ngalert/schedule/registry.go +++ b/pkg/services/ngalert/schedule/registry.go @@ -314,8 +314,6 @@ func (r ruleWithFolder) Fingerprint() fingerprint { // fields that do not affect the state. // TODO consider removing fields below from the fingerprint - writeInt(rule.ID) - writeInt(rule.OrgID) writeInt(int64(rule.For)) if rule.DashboardUID != nil { writeString(*rule.DashboardUID) diff --git a/pkg/services/ngalert/schedule/registry_test.go b/pkg/services/ngalert/schedule/registry_test.go index 6ee2517a706..fb779c346ea 100644 --- a/pkg/services/ngalert/schedule/registry_test.go +++ b/pkg/services/ngalert/schedule/registry_test.go @@ -262,6 +262,8 @@ func TestRuleWithFolderFingerprint(t *testing.T) { "UpdatedBy": {}, "IntervalSeconds": {}, "Annotations": {}, + "ID": {}, + "OrgID": {}, } tp := reflect.TypeOf(rule).Elem() From b7b2e2bbaaf615767fb08b6029599a49358fa2f1 Mon Sep 17 00:00:00 2001 From: Kevin Yu Date: Wed, 5 Feb 2025 11:29:18 -0800 Subject: [PATCH 25/31] CI: Commit package.json changes in e2e/test-plugins when bumping versions (#97506) --- .github/workflows/release-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 004d1ab3299..56a5e50f332 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -143,7 +143,7 @@ jobs: - name: Add package.json changes run: | - git add package.json lerna.json yarn.lock packages public + git add package.json lerna.json yarn.lock packages public e2e/test-plugins git commit -m "Update version to ${{ inputs.version }}" - name: Git push From 8e10ee90568630aefa25fdfe0386f9e3d43ba4c2 Mon Sep 17 00:00:00 2001 From: Kevin Minehart <5140827+kminehart@users.noreply.github.com> Date: Wed, 5 Feb 2025 13:37:19 -0600 Subject: [PATCH 26/31] CI: remove unnecessary line in backport.yml (#100144) remove bad line --- .github/workflows/backport.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml index 384a3b36ddf..5f21ab276ee 100644 --- a/.github/workflows/backport.yml +++ b/.github/workflows/backport.yml @@ -12,7 +12,6 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - ref: main - name: "Generate token" id: generate_token uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 From 364559e85629e3551c9f89fb8a4e74ab8d94f0ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Philippe=20Qu=C3=A9m=C3=A9ner?= Date: Wed, 5 Feb 2025 21:02:35 +0100 Subject: [PATCH 27/31] feat(unified-storage): use continue token when building list (#100143) --- pkg/apiserver/rest/dualwriter_syncer.go | 41 ++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/pkg/apiserver/rest/dualwriter_syncer.go b/pkg/apiserver/rest/dualwriter_syncer.go index 1c18c71737e..9c36fdd22b7 100644 --- a/pkg/apiserver/rest/dualwriter_syncer.go +++ b/pkg/apiserver/rest/dualwriter_syncer.go @@ -179,7 +179,9 @@ func legacyToUnifiedStorageDataSyncer(ctx context.Context, cfg *SyncerConfig) (b log.Info("got items from unified storage", "items", len(storageList)) - legacyList, err := getList(ctx, cfg.LegacyStorage, &metainternalversion.ListOptions{}) + legacyList, err := getList(ctx, cfg.LegacyStorage, &metainternalversion.ListOptions{ + Limit: int64(cfg.DataSyncerRecordsLimit), + }) if err != nil { log.Error(err, "unable to extract list from legacy storage") return @@ -295,10 +297,39 @@ func legacyToUnifiedStorageDataSyncer(ctx context.Context, cfg *SyncerConfig) (b } func getList(ctx context.Context, obj rest.Lister, listOptions *metainternalversion.ListOptions) ([]runtime.Object, error) { - ll, err := obj.List(ctx, listOptions) - if err != nil { - return nil, err + var allItems []runtime.Object + + for { + if int64(len(allItems)) >= listOptions.Limit { + return nil, fmt.Errorf("list has more than %d records. Aborting sync", listOptions.Limit) + } + + ll, err := obj.List(ctx, listOptions) + if err != nil { + return nil, err + } + + items, err := meta.ExtractList(ll) + if err != nil { + return nil, err + } + + allItems = append(allItems, items...) + + // Get continue token from the list metadata. + listMeta, err := meta.ListAccessor(ll) + if err != nil { + return nil, err + } + + // If no continue token, we're done paginating. + if listMeta.GetContinue() == "" { + break + } + + // Set continue token for next page. + listOptions.Continue = listMeta.GetContinue() } - return meta.ExtractList(ll) + return allItems, nil } From 69e4d8468b6ab9a8e4bf48e5a5750f49e2d0cc9a Mon Sep 17 00:00:00 2001 From: Todd Treece <360020+toddtreece@users.noreply.github.com> Date: Wed, 5 Feb 2025 16:01:51 -0500 Subject: [PATCH 28/31] Dashboard: Add v1alpha1 test coverage (#100149) --- pkg/tests/apis/dashboard/dashboards_test.go | 109 ++++++++++++++++---- 1 file changed, 91 insertions(+), 18 deletions(-) diff --git a/pkg/tests/apis/dashboard/dashboards_test.go b/pkg/tests/apis/dashboard/dashboards_test.go index 4232fd478ba..26c9d65fa06 100644 --- a/pkg/tests/apis/dashboard/dashboards_test.go +++ b/pkg/tests/apis/dashboard/dashboards_test.go @@ -17,17 +17,11 @@ import ( "github.com/grafana/grafana/pkg/tests/testsuite" ) -var gvr = schema.GroupVersionResource{ - Group: "dashboard.grafana.app", - Version: "v0alpha1", - Resource: "dashboards", -} - func TestMain(m *testing.M) { testsuite.Run(m) } -func runDashboardTest(t *testing.T, helper *apis.K8sTestHelper) { +func runDashboardTest(t *testing.T, helper *apis.K8sTestHelper, gvr schema.GroupVersionResource) { t.Run("simple crud+list", func(t *testing.T) { ctx := context.Background() client := helper.GetResourceClient(apis.ResourceClientArgs{ @@ -62,6 +56,7 @@ func runDashboardTest(t *testing.T, helper *apis.K8sTestHelper) { require.NoError(t, err) require.Equal(t, created, obj.GetName()) require.Equal(t, int64(1), obj.GetGeneration()) + require.Equal(t, "Test empty dashboard", obj.Object["spec"].(map[string]any)["title"]) wrap, err := utils.MetaAccessor(obj) require.NoError(t, err) @@ -87,6 +82,7 @@ func runDashboardTest(t *testing.T, helper *apis.K8sTestHelper) { require.Equal(t, obj.GetName(), updated.GetName()) require.Equal(t, obj.GetUID(), updated.GetUID()) require.Less(t, obj.GetResourceVersion(), updated.GetResourceVersion()) + require.Equal(t, "Changed title", updated.Object["spec"].(map[string]any)["title"]) // Delete the object, skipping the provisioned dashboard check zeroInt64 := int64(0) @@ -102,12 +98,17 @@ func runDashboardTest(t *testing.T, helper *apis.K8sTestHelper) { }) } -func TestIntegrationDashboardsApp(t *testing.T) { +func TestIntegrationDashboardsAppV0Alpha1(t *testing.T) { + gvr := schema.GroupVersionResource{ + Group: "dashboard.grafana.app", + Version: "v0alpha1", + Resource: "dashboards", + } if testing.Short() { t.Skip("skipping integration test") } - t.Run("with dual writer mode 0", func(t *testing.T) { + t.Run("v0alpha1 with dual writer mode 0", func(t *testing.T) { helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ DisableAnonymous: true, UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ @@ -116,10 +117,10 @@ func TestIntegrationDashboardsApp(t *testing.T) { }, }, }) - runDashboardTest(t, helper) + runDashboardTest(t, helper, gvr) }) - t.Run("with dual writer mode 1", func(t *testing.T) { + t.Run("v0alpha1 with dual writer mode 1", func(t *testing.T) { helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ DisableAnonymous: true, UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ @@ -128,10 +129,10 @@ func TestIntegrationDashboardsApp(t *testing.T) { }, }, }) - runDashboardTest(t, helper) + runDashboardTest(t, helper, gvr) }) - t.Run("with dual writer mode 2", func(t *testing.T) { + t.Run("v0alpha1 with dual writer mode 2", func(t *testing.T) { helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ DisableAnonymous: true, UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ @@ -140,10 +141,10 @@ func TestIntegrationDashboardsApp(t *testing.T) { }, }, }) - runDashboardTest(t, helper) + runDashboardTest(t, helper, gvr) }) - t.Run("with dual writer mode 3", func(t *testing.T) { + t.Run("v0alpha1 with dual writer mode 3", func(t *testing.T) { helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ DisableAnonymous: true, UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ @@ -152,10 +153,10 @@ func TestIntegrationDashboardsApp(t *testing.T) { }, }, }) - runDashboardTest(t, helper) + runDashboardTest(t, helper, gvr) }) - t.Run("with dual writer mode 4", func(t *testing.T) { + t.Run("v0alpha1 with dual writer mode 4", func(t *testing.T) { t.Skip("skipping test because of authorizer issue") helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ DisableAnonymous: true, @@ -165,6 +166,78 @@ func TestIntegrationDashboardsApp(t *testing.T) { }, }, }) - runDashboardTest(t, helper) + runDashboardTest(t, helper, gvr) + }) +} + +func TestIntegrationDashboardsAppV1Alpha1(t *testing.T) { + gvr := schema.GroupVersionResource{ + Group: "dashboard.grafana.app", + Version: "v1alpha1", + Resource: "dashboards", + } + if testing.Short() { + t.Skip("skipping integration test") + } + + t.Run("v1alpha1 with dual writer mode 0", func(t *testing.T) { + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + DisableAnonymous: true, + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + "dashboards.dashboard.grafana.app": { + DualWriterMode: 0, + }, + }, + }) + runDashboardTest(t, helper, gvr) + }) + + t.Run("v1alpha1 with dual writer mode 1", func(t *testing.T) { + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + DisableAnonymous: true, + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + "dashboards.dashboard.grafana.app": { + DualWriterMode: 1, + }, + }, + }) + runDashboardTest(t, helper, gvr) + }) + + t.Run("v1alpha1 with dual writer mode 2", func(t *testing.T) { + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + DisableAnonymous: true, + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + "dashboards.dashboard.grafana.app": { + DualWriterMode: 2, + }, + }, + }) + runDashboardTest(t, helper, gvr) + }) + + t.Run("v1alpha1 with dual writer mode 3", func(t *testing.T) { + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + DisableAnonymous: true, + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + "dashboards.dashboard.grafana.app": { + DualWriterMode: 3, + }, + }, + }) + runDashboardTest(t, helper, gvr) + }) + + t.Run("v1alpha1 with dual writer mode 4", func(t *testing.T) { + t.Skip("skipping test because of authorizer issue") + helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ + DisableAnonymous: true, + UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ + "dashboards.dashboard.grafana.app": { + DualWriterMode: 4, + }, + }, + }) + runDashboardTest(t, helper, gvr) }) } From 0fe4b15d0082bd72a1392a7b16ab78d69e6bb0fd Mon Sep 17 00:00:00 2001 From: Brendan O'Handley Date: Wed, 5 Feb 2025 15:11:43 -0600 Subject: [PATCH 29/31] Explore metrics: Add option to use regex (#100146) add option to use regex --- public/app/features/trails/DataTrail.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/public/app/features/trails/DataTrail.tsx b/public/app/features/trails/DataTrail.tsx index 46a854ffdb1..fa85dd32773 100644 --- a/public/app/features/trails/DataTrail.tsx +++ b/public/app/features/trails/DataTrail.tsx @@ -653,6 +653,8 @@ function getVariableSet( layout: 'vertical', defaultKeys: [], applyMode: 'manual', + supportsMultiValueOperators: true, + allowCustomValue: true, }), new AdHocFiltersVariable({ name: VAR_FILTERS, @@ -666,6 +668,7 @@ function getVariableSet( applyMode: 'manual', // since we only support prometheus datasources, this is always true supportsMultiValueOperators: true, + allowCustomValue: true, }), ...getVariablesWithOtelJoinQueryConstant(otelJoinQuery ?? ''), new ConstantVariable({ @@ -689,6 +692,7 @@ function getVariableSet( applyMode: 'manual', // since we only support prometheus datasources, this is always true supportsMultiValueOperators: true, + allowCustomValue: true, // skipUrlSync: true }), // Legacy variable needed for bookmarking which is necessary because From f7d476e408248bc9b2a968f3a2518835a8e0c1b4 Mon Sep 17 00:00:00 2001 From: Yuri Tseretyan Date: Wed, 5 Feb 2025 16:13:22 -0500 Subject: [PATCH 30/31] Alerting: Remove id and org_id from grafana alert rule API model (#100139) --- pkg/services/ngalert/api/api_ruler.go | 2 -- pkg/services/ngalert/api/tooling/api.json | 8 ------- .../api/tooling/definitions/cortex-ruler.go | 2 -- pkg/services/ngalert/api/tooling/post.json | 8 ------- pkg/services/ngalert/api/tooling/spec.json | 8 ------- pkg/tests/api/alerting/api_ruler_test.go | 21 ------------------- .../alerting/test-data/rulegroup-1-get.json | 4 ---- .../alerting/test-data/rulegroup-2-get.json | 2 -- .../alerting/test-data/rulegroup-3-get.json | 4 ---- public/api-merged.json | 8 ------- public/openapi3.json | 8 ------- 11 files changed, 75 deletions(-) diff --git a/pkg/services/ngalert/api/api_ruler.go b/pkg/services/ngalert/api/api_ruler.go index 1f122c04ad1..718922ff8e3 100644 --- a/pkg/services/ngalert/api/api_ruler.go +++ b/pkg/services/ngalert/api/api_ruler.go @@ -585,8 +585,6 @@ func toGettableExtendedRuleNode(r ngmodels.AlertRule, provenanceRecords map[stri gettableExtendedRuleNode := apimodels.GettableExtendedRuleNode{ GrafanaManagedAlert: &apimodels.GettableGrafanaRule{ - ID: r.ID, - OrgID: r.OrgID, Title: r.Title, Condition: r.Condition, Data: ApiAlertQueriesFromAlertQueries(r.Data), diff --git a/pkg/services/ngalert/api/tooling/api.json b/pkg/services/ngalert/api/tooling/api.json index 24d1d9d81c4..25f7b2ff8c3 100644 --- a/pkg/services/ngalert/api/tooling/api.json +++ b/pkg/services/ngalert/api/tooling/api.json @@ -1593,10 +1593,6 @@ ], "type": "string" }, - "id": { - "format": "int64", - "type": "integer" - }, "intervalSeconds": { "format": "int64", "type": "integer" @@ -1621,10 +1617,6 @@ "notification_settings": { "$ref": "#/definitions/AlertRuleNotificationSettings" }, - "orgId": { - "format": "int64", - "type": "integer" - }, "provenance": { "$ref": "#/definitions/Provenance" }, diff --git a/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go b/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go index d97b99512bf..60fbd2518dd 100644 --- a/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go +++ b/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go @@ -550,8 +550,6 @@ type PostableGrafanaRule struct { // swagger:model type GettableGrafanaRule struct { - ID int64 `json:"id" yaml:"id"` - OrgID int64 `json:"orgId" yaml:"orgId"` Title string `json:"title" yaml:"title"` Condition string `json:"condition" yaml:"condition"` Data []AlertQuery `json:"data" yaml:"data"` diff --git a/pkg/services/ngalert/api/tooling/post.json b/pkg/services/ngalert/api/tooling/post.json index 0e02ed6f68a..e8c4c20d008 100644 --- a/pkg/services/ngalert/api/tooling/post.json +++ b/pkg/services/ngalert/api/tooling/post.json @@ -1593,10 +1593,6 @@ ], "type": "string" }, - "id": { - "format": "int64", - "type": "integer" - }, "intervalSeconds": { "format": "int64", "type": "integer" @@ -1621,10 +1617,6 @@ "notification_settings": { "$ref": "#/definitions/AlertRuleNotificationSettings" }, - "orgId": { - "format": "int64", - "type": "integer" - }, "provenance": { "$ref": "#/definitions/Provenance" }, diff --git a/pkg/services/ngalert/api/tooling/spec.json b/pkg/services/ngalert/api/tooling/spec.json index 960fb35a5fe..abe3364c501 100644 --- a/pkg/services/ngalert/api/tooling/spec.json +++ b/pkg/services/ngalert/api/tooling/spec.json @@ -5281,10 +5281,6 @@ "Error" ] }, - "id": { - "type": "integer", - "format": "int64" - }, "intervalSeconds": { "type": "integer", "format": "int64" @@ -5309,10 +5305,6 @@ "notification_settings": { "$ref": "#/definitions/AlertRuleNotificationSettings" }, - "orgId": { - "type": "integer", - "format": "int64" - }, "provenance": { "$ref": "#/definitions/Provenance" }, diff --git a/pkg/tests/api/alerting/api_ruler_test.go b/pkg/tests/api/alerting/api_ruler_test.go index 6da7f57c963..4a5ae0cc6fa 100644 --- a/pkg/tests/api/alerting/api_ruler_test.go +++ b/pkg/tests/api/alerting/api_ruler_test.go @@ -1143,8 +1143,6 @@ func TestIntegrationRulerRulesFilterByDashboard(t *testing.T) { "__panelId__": "1" }, "grafana_alert": { - "id": 1, - "orgId": 1, "title": "AlwaysFiring", "condition": "A", "data": [{ @@ -1186,8 +1184,6 @@ func TestIntegrationRulerRulesFilterByDashboard(t *testing.T) { "expr": "", "for":"0s", "grafana_alert": { - "id": 2, - "orgId": 1, "title": "AlwaysFiringButSilenced", "condition": "A", "data": [{ @@ -1241,8 +1237,6 @@ func TestIntegrationRulerRulesFilterByDashboard(t *testing.T) { "__panelId__": "1" }, "grafana_alert": { - "id": 1, - "orgId": 1, "title": "AlwaysFiring", "condition": "A", "data": [{ @@ -1583,7 +1577,6 @@ func TestIntegrationRuleCreate(t *testing.T) { }, }, GrafanaManagedAlert: &apimodels.GettableGrafanaRule{ - OrgID: 1, Title: "test1 rule1", Condition: "A", Data: []apimodels.AlertQuery{ @@ -2526,8 +2519,6 @@ func TestIntegrationQuota(t *testing.T) { "expr":"", "for": "2m", "grafana_alert":{ - "id":1, - "orgId":1, "title":"Updated alert rule", "condition":"A", "data":[ @@ -2641,8 +2632,6 @@ func TestIntegrationDeleteFolderWithRules(t *testing.T) { "annotation1": "val1" }, "grafana_alert": { - "id": 1, - "orgId": 1, "title": "rule under folder default", "condition": "A", "data": [ @@ -3124,8 +3113,6 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) { "label1": "val1" }, "grafana_alert":{ - "id":1, - "orgId":1, "title":"AlwaysFiring", "condition":"A", "data":[ @@ -3170,8 +3157,6 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) { "expr":"", "for": "0s", "grafana_alert":{ - "id":2, - "orgId":1, "title":"AlwaysFiringButSilenced", "condition":"A", "data":[ @@ -3488,8 +3473,6 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) { "label2": "val2" }, "grafana_alert":{ - "id":1, - "orgId":1, "title":"AlwaysNormal", "condition":"A", "data":[ @@ -3607,8 +3590,6 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) { "expr":"", "for": "30s", "grafana_alert":{ - "id":1, - "orgId":1, "title":"AlwaysNormal", "condition":"A", "data":[ @@ -3705,8 +3686,6 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) { "expr":"", "for": "30s", "grafana_alert":{ - "id":1, - "orgId":1, "title":"AlwaysNormal", "condition":"A", "data":[ diff --git a/pkg/tests/api/alerting/test-data/rulegroup-1-get.json b/pkg/tests/api/alerting/test-data/rulegroup-1-get.json index cb2b3df8345..d9b66b010a5 100644 --- a/pkg/tests/api/alerting/test-data/rulegroup-1-get.json +++ b/pkg/tests/api/alerting/test-data/rulegroup-1-get.json @@ -12,8 +12,6 @@ "annotation": "test-annotation" }, "grafana_alert": { - "id": 1, - "orgId": 1, "title": "Rule1", "condition": "A", "data": [ @@ -60,8 +58,6 @@ "annotation": "test-annotation" }, "grafana_alert": { - "id": 2, - "orgId": 1, "title": "Rule2", "condition": "A", "data": [ diff --git a/pkg/tests/api/alerting/test-data/rulegroup-2-get.json b/pkg/tests/api/alerting/test-data/rulegroup-2-get.json index 572454ad1e7..90adbac6265 100644 --- a/pkg/tests/api/alerting/test-data/rulegroup-2-get.json +++ b/pkg/tests/api/alerting/test-data/rulegroup-2-get.json @@ -12,8 +12,6 @@ "annotation": "test-annotation" }, "grafana_alert": { - "id": 3, - "orgId": 1, "title": "Rule3", "condition": "A", "data": [ diff --git a/pkg/tests/api/alerting/test-data/rulegroup-3-get.json b/pkg/tests/api/alerting/test-data/rulegroup-3-get.json index 5e454967b5d..efcf5822ea5 100644 --- a/pkg/tests/api/alerting/test-data/rulegroup-3-get.json +++ b/pkg/tests/api/alerting/test-data/rulegroup-3-get.json @@ -12,8 +12,6 @@ "annotation": "test-annotation" }, "grafana_alert": { - "id": 1, - "orgId": 1, "title": "Rule1", "condition": "A", "data": [ @@ -60,8 +58,6 @@ "annotation": "test-annotation" }, "grafana_alert": { - "id": 2, - "orgId": 1, "title": "Rule2", "condition": "A", "data": [ diff --git a/public/api-merged.json b/public/api-merged.json index e6dd277974a..e063c8c959c 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -16013,10 +16013,6 @@ "Error" ] }, - "id": { - "type": "integer", - "format": "int64" - }, "intervalSeconds": { "type": "integer", "format": "int64" @@ -16041,10 +16037,6 @@ "notification_settings": { "$ref": "#/definitions/AlertRuleNotificationSettings" }, - "orgId": { - "type": "integer", - "format": "int64" - }, "provenance": { "$ref": "#/definitions/Provenance" }, diff --git a/public/openapi3.json b/public/openapi3.json index bea198b06a2..f326616d62d 100644 --- a/public/openapi3.json +++ b/public/openapi3.json @@ -6087,10 +6087,6 @@ ], "type": "string" }, - "id": { - "format": "int64", - "type": "integer" - }, "intervalSeconds": { "format": "int64", "type": "integer" @@ -6115,10 +6111,6 @@ "notification_settings": { "$ref": "#/components/schemas/AlertRuleNotificationSettings" }, - "orgId": { - "format": "int64", - "type": "integer" - }, "provenance": { "$ref": "#/components/schemas/Provenance" }, From 495aa65c6ef8028fd10386459fc529c9aed1e71d Mon Sep 17 00:00:00 2001 From: Stephanie Hingtgen Date: Wed, 5 Feb 2025 17:57:26 -0700 Subject: [PATCH 31/31] FindDashboards: filter by dashboard type (#100160) --- .../dashboards/service/dashboard_service.go | 31 ++++- .../service/dashboard_service_test.go | 128 ++++++++++++++++++ pkg/services/folder/folderimpl/folder.go | 8 +- .../folderimpl/folder_unifiedstorage.go | 8 +- pkg/services/libraryelements/database.go | 2 + .../publicdashboards/service/service.go | 9 +- 6 files changed, 176 insertions(+), 10 deletions(-) diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go index 6b3af7f67bf..d4abb3627fc 100644 --- a/pkg/services/dashboards/service/dashboard_service.go +++ b/pkg/services/dashboards/service/dashboard_service.go @@ -46,6 +46,7 @@ import ( "github.com/grafana/grafana/pkg/services/publicdashboards" "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/search/model" + "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" "github.com/grafana/grafana/pkg/services/store/entity" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -232,7 +233,7 @@ func (dr *DashboardServiceImpl) GetProvisionedDashboardData(ctx context.Context, for _, org := range orgs { func(orgID int64) { g.Go(func() error { - res, err := dr.searchProvisionedDashboardsThroughK8s(ctx, dashboards.FindPersistedDashboardsQuery{ + res, err := dr.searchProvisionedDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{ ProvisionedRepo: name, OrgId: orgID, }) @@ -273,7 +274,7 @@ func (dr *DashboardServiceImpl) GetProvisionedDashboardDataByDashboardID(ctx con } for _, org := range orgs { - res, err := dr.searchProvisionedDashboardsThroughK8s(ctx, dashboards.FindPersistedDashboardsQuery{ + res, err := dr.searchProvisionedDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{ OrgId: org.ID, DashboardIds: []int64{dashboardID}, }) @@ -300,7 +301,7 @@ func (dr *DashboardServiceImpl) GetProvisionedDashboardDataByDashboardUID(ctx co return nil, nil } - res, err := dr.searchProvisionedDashboardsThroughK8s(ctx, dashboards.FindPersistedDashboardsQuery{ + res, err := dr.searchProvisionedDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{ OrgId: orgID, DashboardUIDs: []string{dashboardUID}, }) @@ -559,7 +560,7 @@ func (dr *DashboardServiceImpl) DeleteOrphanedProvisionedDashboards(ctx context. for _, org := range orgs { ctx, _ := identity.WithServiceIdentity(ctx, org.ID) // find all dashboards in the org that have a file repo set that is not in the given readers list - foundDashs, err := dr.searchProvisionedDashboardsThroughK8s(ctx, dashboards.FindPersistedDashboardsQuery{ + foundDashs, err := dr.searchProvisionedDashboardsThroughK8s(ctx, &dashboards.FindPersistedDashboardsQuery{ ProvisionedReposNotIn: cmd.ReaderNames, OrgId: org.ID, }) @@ -1436,7 +1437,12 @@ func (dr *DashboardServiceImpl) DeleteInFolders(ctx context.Context, orgID int64 } // We need a list of dashboard uids inside the folder to delete related public dashboards - dashes, err := dr.dashboardStore.FindDashboards(ctx, &dashboards.FindPersistedDashboardsQuery{SignedInUser: u, FolderUIDs: folderUIDs, OrgId: orgID}) + dashes, err := dr.dashboardStore.FindDashboards(ctx, &dashboards.FindPersistedDashboardsQuery{ + SignedInUser: u, + FolderUIDs: folderUIDs, + OrgId: orgID, + Type: searchstore.TypeDashboard, + }) if err != nil { return folder.ErrInternal.Errorf("failed to fetch dashboards: %w", err) } @@ -1729,7 +1735,11 @@ type dashboardProvisioningWithUID struct { DashboardUID string } -func (dr *DashboardServiceImpl) searchProvisionedDashboardsThroughK8s(ctx context.Context, query dashboards.FindPersistedDashboardsQuery) ([]*dashboardProvisioningWithUID, error) { +func (dr *DashboardServiceImpl) searchProvisionedDashboardsThroughK8s(ctx context.Context, query *dashboards.FindPersistedDashboardsQuery) ([]*dashboardProvisioningWithUID, error) { + if query == nil { + return nil, errors.New("query cannot be nil") + } + ctx, _ = identity.WithServiceIdentity(ctx, query.OrgId) if query.ProvisionedRepo != "" { @@ -1744,7 +1754,9 @@ func (dr *DashboardServiceImpl) searchProvisionedDashboardsThroughK8s(ctx contex query.ProvisionedReposNotIn = repos } - searchResults, err := dr.searchDashboardsThroughK8sRaw(ctx, &query) + query.Type = searchstore.TypeDashboard + + searchResults, err := dr.searchDashboardsThroughK8sRaw(ctx, query) if err != nil { return nil, err } @@ -1807,6 +1819,11 @@ func (dr *DashboardServiceImpl) searchProvisionedDashboardsThroughK8s(ctx contex } func (dr *DashboardServiceImpl) searchDashboardsThroughK8s(ctx context.Context, query *dashboards.FindPersistedDashboardsQuery) ([]*dashboards.Dashboard, error) { + if query == nil { + return nil, errors.New("query cannot be nil") + } + query.Type = searchstore.TypeDashboard + response, err := dr.searchDashboardsThroughK8sRaw(ctx, query) if err != nil { return nil, err diff --git a/pkg/services/dashboards/service/dashboard_service_test.go b/pkg/services/dashboards/service/dashboard_service_test.go index 1ae6c4f5b90..58cc555bfd3 100644 --- a/pkg/services/dashboards/service/dashboard_service_test.go +++ b/pkg/services/dashboards/service/dashboard_service_test.go @@ -1899,6 +1899,134 @@ func TestCountInFolders(t *testing.T) { }) } +func TestSearchDashboardsThroughK8sRaw(t *testing.T) { + ctx := context.Background() + k8sCliMock := new(client.MockK8sHandler) + service := &DashboardServiceImpl{k8sclient: k8sCliMock} + query := &dashboards.FindPersistedDashboardsQuery{ + OrgId: 1, + } + k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.Anything).Return(&resource.ResourceSearchResponse{ + Results: &resource.ResourceTable{ + Columns: []*resource.ResourceTableColumnDefinition{ + { + Name: "title", + Type: resource.ResourceTableColumnDefinition_STRING, + }, + { + Name: "folder", + Type: resource.ResourceTableColumnDefinition_STRING, + }, + }, + Rows: []*resource.ResourceTableRow{ + { + Key: &resource.ResourceKey{ + Name: "uid", + Resource: "dashboard", + }, + Cells: [][]byte{ + []byte("Dashboard 1"), + []byte("folder1"), + }, + }, + }, + }, + TotalHits: 1, + }, nil) + res, err := service.searchDashboardsThroughK8s(ctx, query) + require.NoError(t, err) + assert.Equal(t, []*dashboards.Dashboard{ + { + UID: "uid", + OrgID: 1, + FolderUID: "folder1", + Title: "Dashboard 1", + Slug: "dashboard-1", // should be slugified + }, + }, res) + assert.Equal(t, "dash-db", query.Type) // query type should be added +} + +func TestSearchProvisionedDashboardsThroughK8sRaw(t *testing.T) { + ctx := context.Background() + k8sCliMock := new(client.MockK8sHandler) + service := &DashboardServiceImpl{k8sclient: k8sCliMock} + query := &dashboards.FindPersistedDashboardsQuery{ + OrgId: 1, + } + dashboardUnstructuredProvisioned := unstructured.Unstructured{Object: map[string]any{ + "metadata": map[string]any{ + "name": "uid", + "annotations": map[string]any{ + utils.AnnoKeyRepoName: fileProvisionedRepoPrefix + "test", + utils.AnnoKeyRepoHash: "hash", + utils.AnnoKeyRepoPath: "path/to/file", + utils.AnnoKeyRepoTimestamp: "2025-01-01T00:00:00Z", + }, + }, + "spec": map[string]any{}, + }} + dashboardUnstructuredNotProvisioned := unstructured.Unstructured{Object: map[string]any{ + "metadata": map[string]any{ + "name": "uid2", + }, + "spec": map[string]any{}, + }} + k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.Anything).Return(&resource.ResourceSearchResponse{ + Results: &resource.ResourceTable{ + Columns: []*resource.ResourceTableColumnDefinition{ + { + Name: "title", + Type: resource.ResourceTableColumnDefinition_STRING, + }, + { + Name: "folder", + Type: resource.ResourceTableColumnDefinition_STRING, + }, + }, + Rows: []*resource.ResourceTableRow{ + { + Key: &resource.ResourceKey{ + Name: "uid", + Resource: "dashboard", + }, + Cells: [][]byte{ + []byte("Dashboard 1"), + []byte("folder1"), + }, + }, + { + Key: &resource.ResourceKey{ + Name: "uid2", + Resource: "dashboard", + }, + Cells: [][]byte{ + []byte("Dashboard 2"), + []byte("folder2"), + }, + }, + }, + }, + TotalHits: 1, + }, nil) + k8sCliMock.On("Get", mock.Anything, "uid", mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructuredProvisioned, nil).Once() + k8sCliMock.On("Get", mock.Anything, "uid2", mock.Anything, mock.Anything, mock.Anything).Return(&dashboardUnstructuredNotProvisioned, nil).Once() + res, err := service.searchProvisionedDashboardsThroughK8s(ctx, query) + require.NoError(t, err) + assert.Equal(t, []*dashboardProvisioningWithUID{ + { + DashboardUID: "uid", + DashboardProvisioning: dashboards.DashboardProvisioning{ + Name: "test", + ExternalID: "path/to/file", + CheckSum: "hash", + Updated: 1735689600, + }, + }, + }, res) // only should return the one provisioned dashboard + assert.Equal(t, "dash-db", query.Type) // query type should be added as dashboards only +} + func TestLegacySaveCommandToUnstructured(t *testing.T) { namespace := "test-namespace" t.Run("successfully converts save command to unstructured", func(t *testing.T) { diff --git a/pkg/services/folder/folderimpl/folder.go b/pkg/services/folder/folderimpl/folder.go index b97ca614cc5..9ac7c8a2e1e 100644 --- a/pkg/services/folder/folderimpl/folder.go +++ b/pkg/services/folder/folderimpl/folder.go @@ -37,6 +37,7 @@ import ( "github.com/grafana/grafana/pkg/services/search/model" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" "github.com/grafana/grafana/pkg/services/store/entity" "github.com/grafana/grafana/pkg/services/supportbundles" "github.com/grafana/grafana/pkg/services/user" @@ -1028,7 +1029,12 @@ func (s *Service) legacyDelete(ctx context.Context, cmd *folder.DeleteFolderComm // if dashboard restore is on we don't delete public dashboards, the hard delete will take care of it later if !s.features.IsEnabledGlobally(featuremgmt.FlagDashboardRestore) { // We need a list of dashboard uids inside the folder to delete related public dashboards - dashes, err := s.dashboardStore.FindDashboards(ctx, &dashboards.FindPersistedDashboardsQuery{SignedInUser: cmd.SignedInUser, FolderUIDs: folderUIDs, OrgId: cmd.OrgID}) + dashes, err := s.dashboardStore.FindDashboards(ctx, &dashboards.FindPersistedDashboardsQuery{ + SignedInUser: cmd.SignedInUser, + FolderUIDs: folderUIDs, + OrgId: cmd.OrgID, + Type: searchstore.TypeDashboard, + }) if err != nil { return folder.ErrInternal.Errorf("failed to fetch dashboards: %w", err) } diff --git a/pkg/services/folder/folderimpl/folder_unifiedstorage.go b/pkg/services/folder/folderimpl/folder_unifiedstorage.go index 6d4ae13da06..203aa4f37ab 100644 --- a/pkg/services/folder/folderimpl/folder_unifiedstorage.go +++ b/pkg/services/folder/folderimpl/folder_unifiedstorage.go @@ -31,6 +31,7 @@ import ( "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/search/model" + "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" "github.com/grafana/grafana/pkg/services/store/entity" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/unified/resource" @@ -737,7 +738,12 @@ func (s *Service) deleteFromApiServer(ctx context.Context, cmd *folder.DeleteFol } } } else { - dashes, err := s.dashboardStore.FindDashboards(ctx, &dashboards.FindPersistedDashboardsQuery{SignedInUser: cmd.SignedInUser, FolderUIDs: folders, OrgId: cmd.OrgID}) + dashes, err := s.dashboardStore.FindDashboards(ctx, &dashboards.FindPersistedDashboardsQuery{ + SignedInUser: cmd.SignedInUser, + FolderUIDs: folders, + OrgId: cmd.OrgID, + Type: searchstore.TypeDashboard, + }) if err != nil { return folder.ErrInternal.Errorf("failed to fetch dashboards: %w", err) } diff --git a/pkg/services/libraryelements/database.go b/pkg/services/libraryelements/database.go index fb946bafb77..306352fce84 100644 --- a/pkg/services/libraryelements/database.go +++ b/pkg/services/libraryelements/database.go @@ -21,6 +21,7 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/search" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) @@ -252,6 +253,7 @@ func (l *LibraryElementService) deleteLibraryElement(c context.Context, signedIn // then find the dashboards that were supposed to be connected to this element _, requester := identity.WithServiceIdentity(c, signedInUser.GetOrgID()) dashs, err := l.dashboardsService.FindDashboards(c, &dashboards.FindPersistedDashboardsQuery{ + Type: searchstore.TypeDashboard, OrgId: signedInUser.GetOrgID(), DashboardIds: dashboardIDs, SignedInUser: requester, // a user may be able to delete a library element but not read all dashboards. We still need to run this check, so we don't allow deleting elements if dashboards are connected diff --git a/pkg/services/publicdashboards/service/service.go b/pkg/services/publicdashboards/service/service.go index b5459992780..d3239e2857d 100644 --- a/pkg/services/publicdashboards/service/service.go +++ b/pkg/services/publicdashboards/service/service.go @@ -25,6 +25,7 @@ import ( "github.com/grafana/grafana/pkg/services/publicdashboards/service/intervalv2" "github.com/grafana/grafana/pkg/services/publicdashboards/validation" "github.com/grafana/grafana/pkg/services/query" + "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" @@ -375,7 +376,13 @@ func (pd *PublicDashboardServiceImpl) FindAllWithPagination(ctx context.Context, dashUIDs[i] = pubdash.DashboardUid } - dashboardsFound, err := pd.dashboardService.FindDashboards(ctx, &dashboards.FindPersistedDashboardsQuery{OrgId: query.OrgID, DashboardUIDs: dashUIDs, SignedInUser: query.User, Limit: int64(len(dashUIDs))}) + dashboardsFound, err := pd.dashboardService.FindDashboards(ctx, &dashboards.FindPersistedDashboardsQuery{ + OrgId: query.OrgID, + DashboardUIDs: dashUIDs, + SignedInUser: query.User, + Limit: int64(len(dashUIDs)), + Type: searchstore.TypeDashboard, + }) if err != nil { return nil, ErrInternalServerError.Errorf("FindAllWithPagination: GetDashboards: %w", err) }