diff --git a/docs/sources/alerting/fundamentals/notifications/group-alert-notifications.md b/docs/sources/alerting/fundamentals/notifications/group-alert-notifications.md index eecbad7f530..5c812545f5e 100644 --- a/docs/sources/alerting/fundamentals/notifications/group-alert-notifications.md +++ b/docs/sources/alerting/fundamentals/notifications/group-alert-notifications.md @@ -39,7 +39,7 @@ Grouping in Grafana Alerting allows you to batch relevant alerts together into a Grouping combines similar alert instances within a specific period into a single notification, reducing alert noise. -In the notification policy, you can configure how to group multiple alerts into a single notification: +In the [notification policy](ref:notification-policies), you can configure how to group multiple alerts into a single notification: - The `Group by` option specifies the criteria for grouping incoming alerts within the policy. The default is by alert rule. - [Timing options](#timing-options) determine when and how often to send the notification. diff --git a/docs/sources/alerting/manage-notifications/view-alert-groups.md b/docs/sources/alerting/manage-notifications/view-alert-groups.md index f050af7373f..47d3f7bee5f 100644 --- a/docs/sources/alerting/manage-notifications/view-alert-groups.md +++ b/docs/sources/alerting/manage-notifications/view-alert-groups.md @@ -4,49 +4,93 @@ aliases: - ../../alerting/alert-groups/filter-alerts/ # /docs/grafana//alerting/alert-groups/filter-alerts/ - ../../alerting/alert-groups/view-alert-grouping/ # /docs/grafana//alerting/alert-groups/view-alert-grouping/ - ../../alerting/unified-alerting/alert-groups/ # /docs/grafana//alerting/unified-alerting/alert-groups/ + - ../../alerting/manage-notifications/view-notification-errors/ # /docs/grafana//alerting/manage-notifications/view-notification-errors/ canonical: https://grafana.com/docs/grafana/latest/alerting/manage-notifications/view-alert-groups/ -description: Alert groups +description: The Groups view lists grouped alerts that are actively triggering notifications. keywords: - grafana - alerting - alerts + - errors + - notifications - groups labels: products: - cloud - enterprise - oss -title: View and filter by alert groups +title: View the status of notifications weight: 800 +refs: + alertmanager: + - pattern: /docs/grafana/ + destination: /docs/grafana//alerting/set-up/configure-alertmanager/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/alerting/set-up/configure-alertmanager/ + grouping: + - pattern: /docs/grafana/ + destination: /docs/grafana//alerting/fundamentals/notifications/group-alert-notifications/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/alerting-and-irm/alerting/fundamentals/notifications/group-alert-notifications/ --- -# View and filter by alert groups +# View the status of notifications -Alert groups show grouped alerts from an Alertmanager instance. By default, alert rules are grouped by the label keys for the default policy in notification policies. Grouping common alert rules into a single alert group prevents duplicate alert rules from being fired. +The Groups view page lists grouped alerts that are actively triggering notifications. -You can view alert groups and also filter for alert rules that match specific criteria. +By default, Grafana Alerting groups similar firing alerts (or alert instances) to prevent notification overload. For details on how notification grouping works, refer to [Group alert notifications](ref:grouping). -## View alert groups +In the Groups view, you can see alert groups, check the state of their notifications, and also filter for alert instances that match specific criteria. This view is useful for debugging and verifying your grouping settings of notification policies. + +## View alert groups and notification state To view alert groups, complete the following steps. -1. In the left-side menu, click **Alerts & IRM** and then **Alerting**. -1. Click **Groups** to view the list of existing groups. -1. From the **Alertmanager** dropdown, select an external Alertmanager as your data source. By default, the `Grafana` Alertmanager is selected. -1. From **Custom group by** dropdown, select a combination of labels to view a grouping other than the default. This is useful for debugging and verifying your grouping of notification policies. +1. Click **Alerts & IRM** -> **Alerting**. +1. Click **Groups** to view the list of groups firing notifications. + + By default, alert groups are grouped by the notification policies grouping. + + Each group displays its label set, contact point, and the number of alert instances (or alerts). + + Then, click on a group to access its alert instances. You can find alert instances by their label set and view their notification state. + +### Notification states + +The notification state of an alert instance can be in one of the following states: + +- **Unprocessed**: The alert is received but its notification has not been processed yet. +- **Suppressed**: The alert has been silenced. +- **Active**: The alert notification has been handled. The alert is still firing and continues to be managed. + +### Filter alerts + +You can filter by label, state, or Alertmanager: + +- **By label**: In **Search**, enter an existing label to view alerts matching the label. For example, `environment=production,region=~US|EU,severity!=warning`. + +- **By state**: In **States**, select from Active, Suppressed, or Unprocessed states to view alerts matching your selected state. All other alerts are hidden. + +- **By Alertmanager**: In the **Alertmanager** dropdown, select an [external Alertmanager](ref:alertmanager) to view only alert groups for that specific Alertmanager. By default, the `Grafana` Alertmanager is selected. + +### Custom group + +From **Custom group by** dropdown, select a combination of labels to view a grouping other than the default. This helps validate the [grouping settings of your notification policies](ref:grouping). If an alert does not contain labels specified either in the grouping of the default policy or the custom grouping, then the alert is added to a catch all group with a header of `No grouping`. -## Filter alerts +## View notification errors + +{{% admonition type="note" %}} -You can filter by label or state. +Notification errors are only available with [pre-configured Grafana Alertmanagers](ref:alertmanager). -### Search by label +{{% /admonition %}} -In **Search**, enter an existing label to view alerts matching the label. +Notification errors provide information about why they failed to be sent or were not received. -For example, `environment=production,region=~US|EU,severity!=warning`. +To view notification errors, navigate to **Alerts & IRM** -> **Alerting** -> **Contact points**. -### Filter by state +Each contact point displays a message about the status of their latest notification deliveries. -In **States**, select from Active, Suppressed, or Unprocessed states to view alerts matching your selected state. All other alerts are hidden. +If a contact point is failing, a red message indicates that there are errors delivering notifications. Hover over the error message to see the notification error details. diff --git a/docs/sources/alerting/manage-notifications/view-notification-errors.md b/docs/sources/alerting/manage-notifications/view-notification-errors.md deleted file mode 100644 index 9caf6d6e186..00000000000 --- a/docs/sources/alerting/manage-notifications/view-notification-errors.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -canonical: https://grafana.com/docs/grafana/latest/alerting/manage-notifications/view-notification-errors/ -description: View notification errors and understand why they failed to be sent or were not received -keywords: - - grafana - - alerting - - notification - - errors - - contact points -labels: - products: - - cloud - - enterprise - - oss -title: View notification errors -weight: 900 ---- - -# View notification errors - -View notification errors and understand why they failed to be sent or were not received. - -**Note:** -This feature only works if you are using Grafana Alertmanager. - -To view notification errors, complete the following steps. - -1. Navigate to Alerting -> Contact points. - - If any contact points are failing, a message at the right-hand corner of the screen alerts the user to the fact that there are errors and how many. - -2. Click on the contact point to view the details of errors for each contact point. - - Error details are displayed if you hover over the Error icon. - - If a contact point has more than one integration, you see all errors for each of the integrations listed. - -3. In the Health column, check the status of the notification. - - This can be either OK, No attempts, or Error. - -## Useful links - -[Receivers API](https://editor.swagger.io/?url=https://raw.githubusercontent.com/grafana/grafana/main/pkg/services/ngalert/api/tooling/post.json) diff --git a/docs/sources/developers/plugins/plugin.schema.json b/docs/sources/developers/plugins/plugin.schema.json index 647a3fe485d..71e3b963214 100644 --- a/docs/sources/developers/plugins/plugin.schema.json +++ b/docs/sources/developers/plugins/plugin.schema.json @@ -582,11 +582,6 @@ }, "required": ["extensionPointId", "title", "type"] } - }, - "apiVersion": { - "type": "string", - "description": "[internal only] The API version for the plugin. Used for Datasource API servers. This metadata is temporary and will be removed in the future.", - "pattern": "^v([\\d]+)(?:(alpha|beta)([\\d]+))?$" } } } diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 2b56f82d2bb..2726e598147 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -199,4 +199,5 @@ export interface FeatureToggles { alertingApiServer?: boolean; dashboardRestoreUI?: boolean; cloudWatchRoundUpEndTime?: boolean; + bodyScrolling?: boolean; } diff --git a/packages/grafana-data/src/types/legacyEvents.ts b/packages/grafana-data/src/types/legacyEvents.ts index 8154a322159..0ba9e15a932 100644 --- a/packages/grafana-data/src/types/legacyEvents.ts +++ b/packages/grafana-data/src/types/legacyEvents.ts @@ -13,6 +13,7 @@ export const AppEvents = { alertSuccess: eventFactory('alert-success'), alertWarning: eventFactory('alert-warning'), alertError: eventFactory('alert-error'), + alertInfo: eventFactory('alert-info'), }; export const PanelEvents = { diff --git a/pkg/api/dtos/plugins.go b/pkg/api/dtos/plugins.go index 1d377476174..7d768e059e4 100644 --- a/pkg/api/dtos/plugins.go +++ b/pkg/api/dtos/plugins.go @@ -28,7 +28,6 @@ type PluginSetting struct { SignatureType plugins.SignatureType `json:"signatureType"` SignatureOrg string `json:"signatureOrg"` AngularDetected bool `json:"angularDetected"` - APIVersion string `json:"apiVersion"` } type PluginListItem struct { @@ -50,7 +49,6 @@ type PluginListItem struct { AccessControl accesscontrol.Metadata `json:"accessControl,omitempty"` AngularDetected bool `json:"angularDetected"` IAM *pfs.IAM `json:"iam,omitempty"` - APIVersion string `json:"apiVersion"` } type PluginList []PluginListItem diff --git a/pkg/api/plugins.go b/pkg/api/plugins.go index 739141e78a3..ffe15c59357 100644 --- a/pkg/api/plugins.go +++ b/pkg/api/plugins.go @@ -142,7 +142,6 @@ func (hs *HTTPServer) GetPluginList(c *contextmodel.ReqContext) response.Respons SignatureOrg: pluginDef.SignatureOrg, AccessControl: pluginsMetadata[pluginDef.ID], AngularDetected: pluginDef.Angular.Detected, - APIVersion: pluginDef.APIVersion, } if hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagExternalServiceAccounts) { @@ -209,7 +208,6 @@ func (hs *HTTPServer) GetPluginSettingByID(c *contextmodel.ReqContext) response. SignatureOrg: plugin.SignatureOrg, SecureJsonFields: map[string]bool{}, AngularDetected: plugin.Angular.Detected, - APIVersion: plugin.APIVersion, } if plugin.IsApp() { diff --git a/pkg/plugins/manager/pipeline/validation/steps.go b/pkg/plugins/manager/pipeline/validation/steps.go index faf436c8798..10af0cce5e4 100644 --- a/pkg/plugins/manager/pipeline/validation/steps.go +++ b/pkg/plugins/manager/pipeline/validation/steps.go @@ -3,8 +3,6 @@ package validation import ( "context" "errors" - "fmt" - "regexp" "slices" "time" @@ -117,36 +115,3 @@ func (a *AngularDetector) Validate(ctx context.Context, p *plugins.Plugin) error p.Angular.HideDeprecation = slices.Contains(a.cfg.HideAngularDeprecation, p.ID) return nil } - -// APIVersionValidation implements a ValidateFunc for validating plugin API versions. -type APIVersionValidation struct { -} - -// APIVersionValidationStep returns a new ValidateFunc for validating plugin signatures. -func APIVersionValidationStep() ValidateFunc { - sv := &APIVersionValidation{} - return sv.Validate -} - -// Validate validates the plugin signature. If a signature error is encountered, the error is recorded with the -// pluginerrs.ErrorTracker. -func (v *APIVersionValidation) Validate(ctx context.Context, p *plugins.Plugin) error { - if p.APIVersion != "" { - if !p.Backend { - return fmt.Errorf("plugin %s has an API version but is not a backend plugin", p.ID) - } - // Eventually, all backend plugins will be supported - if p.Type != plugins.TypeDataSource { - return fmt.Errorf("plugin %s has an API version but is not a datasource plugin", p.ID) - } - m, err := regexp.MatchString(`^v([\d]+)(?:(alpha|beta)([\d]+))?$`, p.APIVersion) - if err != nil { - return fmt.Errorf("failed to verify apiVersion %s: %v", p.APIVersion, err) - } - if !m { - return fmt.Errorf("plugin %s has an invalid API version %s", p.ID, p.APIVersion) - } - } - - return nil -} diff --git a/pkg/plugins/manager/pipeline/validation/steps_test.go b/pkg/plugins/manager/pipeline/validation/steps_test.go deleted file mode 100644 index 0eaabda84ee..00000000000 --- a/pkg/plugins/manager/pipeline/validation/steps_test.go +++ /dev/null @@ -1,75 +0,0 @@ -package validation - -import ( - "context" - "testing" - - "github.com/grafana/grafana/pkg/plugins" - "github.com/stretchr/testify/require" -) - -func TestAPIVersionValidation(t *testing.T) { - s := APIVersionValidationStep() - - tests := []struct { - name string - plugin *plugins.Plugin - err bool - }{ - { - name: "valid plugin", - plugin: &plugins.Plugin{ - JSONData: plugins.JSONData{ - Backend: true, - Type: plugins.TypeDataSource, - APIVersion: "v0alpha1", - }, - }, - err: false, - }, - { - name: "invalid plugin - not backend", - plugin: &plugins.Plugin{ - JSONData: plugins.JSONData{ - Backend: false, - Type: plugins.TypeDataSource, - APIVersion: "v0alpha1", - }, - }, - err: true, - }, - { - name: "invalid plugin - not datasource", - plugin: &plugins.Plugin{ - JSONData: plugins.JSONData{ - Backend: true, - Type: plugins.TypeApp, - APIVersion: "v0alpha1", - }, - }, - err: true, - }, - { - name: "invalid plugin - invalid API version", - plugin: &plugins.Plugin{ - JSONData: plugins.JSONData{ - Backend: true, - Type: plugins.TypeDataSource, - APIVersion: "invalid", - }, - }, - err: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := s(context.Background(), tt.plugin) - if tt.err { - require.Error(t, err) - } else { - require.NoError(t, err) - } - }) - } -} diff --git a/pkg/plugins/plugins.go b/pkg/plugins/plugins.go index d96a09e0963..33c001ca626 100644 --- a/pkg/plugins/plugins.go +++ b/pkg/plugins/plugins.go @@ -128,9 +128,6 @@ type JSONData struct { // App Service Auth Registration IAM *pfs.IAM `json:"iam,omitempty"` - - // API Version: Temporary field while plugins don't expose a OpenAPI schema - APIVersion string `json:"apiVersion,omitempty"` } func ReadPluginJSON(reader io.Reader) (JSONData, error) { diff --git a/pkg/services/datasources/service/datasource.go b/pkg/services/datasources/service/datasource.go index 3e4fd62fea7..e731776656c 100644 --- a/pkg/services/datasources/service/datasource.go +++ b/pkg/services/datasources/service/datasource.go @@ -322,10 +322,7 @@ func (s *Service) prepareInstanceSettings(ctx context.Context, settings *backend } // When the APIVersion is set, the client must also implement AdmissionHandler - if p.APIVersion == "" { - if settings.APIVersion != "" { - return nil, fmt.Errorf("invalid request apiVersion (datasource does not have one configured)") - } + if settings.APIVersion == "" { return settings, nil // NOOP } @@ -367,7 +364,7 @@ func (s *Service) prepareInstanceSettings(ctx context.Context, settings *backend if err != nil { if errors.Is(err, plugins.ErrMethodNotImplemented) { return nil, errutil.Internal("plugin.unimplemented"). - Errorf("plugin (%s) with apiVersion=%s must implement ValidateAdmission", p.ID, p.APIVersion) + Errorf("plugin (%s) with apiVersion=%s must implement ValidateAdmission", p.ID, settings.APIVersion) } return nil, err } @@ -388,7 +385,7 @@ func (s *Service) prepareInstanceSettings(ctx context.Context, settings *backend if err != nil { if errors.Is(err, plugins.ErrMethodNotImplemented) { return nil, errutil.Internal("plugin.unimplemented"). - Errorf("plugin (%s) with apiVersion=%s must implement MutateAdmission", p.ID, p.APIVersion) + Errorf("plugin (%s) with apiVersion=%s must implement MutateAdmission", p.ID, settings.APIVersion) } return nil, err } diff --git a/pkg/services/datasources/service/datasource_test.go b/pkg/services/datasources/service/datasource_test.go index 0fa7457351b..a4c6afdc2db 100644 --- a/pkg/services/datasources/service/datasource_test.go +++ b/pkg/services/datasources/service/datasource_test.go @@ -110,10 +110,9 @@ func TestService_AddDataSource(t *testing.T) { dsService.pluginStore = &pluginstore.FakePluginStore{ PluginList: []pluginstore.Plugin{{ JSONData: plugins.JSONData{ - ID: "test", - Type: plugins.TypeDataSource, - Name: "test", - APIVersion: "v0alpha1", // When a value exists in plugin.json, the callbacks will be executed + ID: "test", + Type: plugins.TypeDataSource, + Name: "test", }, }}, } @@ -150,10 +149,9 @@ func TestService_AddDataSource(t *testing.T) { dsService.pluginStore = &pluginstore.FakePluginStore{ PluginList: []pluginstore.Plugin{{ JSONData: plugins.JSONData{ - ID: "test", - Type: plugins.TypeDataSource, - Name: "test", - APIVersion: "v0alpha1", // When a value exists in plugin.json, the callbacks will be executed + ID: "test", + Type: plugins.TypeDataSource, + Name: "test", }, }}, } @@ -200,10 +198,9 @@ func TestService_AddDataSource(t *testing.T) { dsService.pluginStore = &pluginstore.FakePluginStore{ PluginList: []pluginstore.Plugin{{ JSONData: plugins.JSONData{ - ID: "test", - Type: plugins.TypeDataSource, - Name: "test", - APIVersion: "v0alpha1", // When a value exists in plugin.json, the callbacks will be executed + ID: "test", + Type: plugins.TypeDataSource, + Name: "test", }, }}, } @@ -491,10 +488,9 @@ func TestService_UpdateDataSource(t *testing.T) { dsService.pluginStore = &pluginstore.FakePluginStore{ PluginList: []pluginstore.Plugin{{ JSONData: plugins.JSONData{ - ID: "test", - Type: plugins.TypeDataSource, - Name: "test", - APIVersion: "v0alpha1", // When a value exists in plugin.json, the callbacks will be executed + ID: "test", + Type: plugins.TypeDataSource, + Name: "test", }, }}, } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 059cceee86b..3a0764fbb83 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1360,6 +1360,17 @@ var ( Owner: awsDatasourcesSquad, Expression: "true", }, + { + Name: "bodyScrolling", + Description: "Adjusts Page to make body the scrollable element", + Stage: FeatureStageExperimental, + Owner: grafanaFrontendPlatformSquad, + Expression: "false", // enabled by default + FrontendOnly: true, + AllowSelfServe: false, + HideFromDocs: true, + HideFromAdminPage: true, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 3bb0bf05cac..71699a4e6ec 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -180,3 +180,4 @@ passScopeToDashboardApi,experimental,@grafana/dashboards-squad,false,false,false alertingApiServer,experimental,@grafana/alerting-squad,false,true,false dashboardRestoreUI,experimental,@grafana/grafana-frontend-platform,false,false,false cloudWatchRoundUpEndTime,GA,@grafana/aws-datasources,false,false,false +bodyScrolling,experimental,@grafana/grafana-frontend-platform,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 75ecaa053b5..ea33898217c 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -730,4 +730,8 @@ const ( // FlagCloudWatchRoundUpEndTime // Round up end time for metric queries to the next minute to avoid missing data FlagCloudWatchRoundUpEndTime = "cloudWatchRoundUpEndTime" + + // FlagBodyScrolling + // Adjusts Page to make body the scrollable element + FlagBodyScrolling = "bodyScrolling" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 10c6014ea06..99143386068 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -473,6 +473,21 @@ "codeowner": "@grafana/partner-datasources" } }, + { + "metadata": { + "name": "bodyScrolling", + "resourceVersion": "1719825052257", + "creationTimestamp": "2024-07-01T09:10:52Z" + }, + "spec": { + "description": "Adjusts Page to make body the scrollable element", + "stage": "experimental", + "codeowner": "@grafana/grafana-frontend-platform", + "frontend": true, + "hideFromAdminPage": true, + "hideFromDocs": true + } + }, { "metadata": { "name": "cachingOptimizeSerializationMemoryUsage", diff --git a/pkg/services/ngalert/state/historian/loki_test.go b/pkg/services/ngalert/state/historian/loki_test.go index 97c37b3dd02..e2ef7b44f80 100644 --- a/pkg/services/ngalert/state/historian/loki_test.go +++ b/pkg/services/ngalert/state/historian/loki_test.go @@ -856,16 +856,21 @@ func TestGetFolderUIDsForFilter(t *testing.T) { result, err := loki.getFolderUIDsForFilter(context.Background(), models.HistoryQuery{OrgID: orgID, SignedInUser: usr}) assert.NoError(t, err) - assert.EqualValues(t, folders, result) + assert.ElementsMatch(t, folders, result) assert.Len(t, ac.Calls, len(folders)+1) assert.Equal(t, "CanReadAllRules", ac.Calls[0].MethodName) assert.Equal(t, usr, ac.Calls[0].Arguments[1]) - for i, folderUID := range folders { - assert.Equal(t, "HasAccessInFolder", ac.Calls[i+1].MethodName) - assert.Equal(t, usr, ac.Calls[i+1].Arguments[1]) - assert.Equal(t, folderUID, ac.Calls[i+1].Arguments[2].(models.Namespaced).GetNamespaceUID()) + + var called []string + for _, call := range ac.Calls[1:] { + if !assert.Equal(t, "HasAccessInFolder", call.MethodName) { + continue + } + assert.Equal(t, usr, call.Arguments[1]) + called = append(called, call.Arguments[2].(models.Namespaced).GetNamespaceUID()) } + assert.ElementsMatch(t, folders, called) t.Run("should fail if no folders to read", func(t *testing.T) { loki := createLoki(ac) diff --git a/pkg/services/pluginsintegration/pipeline/pipeline.go b/pkg/services/pluginsintegration/pipeline/pipeline.go index 749ccd3d0c1..f45cb3bfd76 100644 --- a/pkg/services/pluginsintegration/pipeline/pipeline.go +++ b/pkg/services/pluginsintegration/pipeline/pipeline.go @@ -54,7 +54,6 @@ func ProvideValidationStage(cfg *config.PluginManagementCfg, sv signature.Valida SignatureValidationStep(sv), validation.ModuleJSValidationStep(), validation.AngularDetectionStep(cfg, ai), - validation.APIVersionValidationStep(), }, }) } diff --git a/pkg/services/pluginsintegration/plugincontext/base_plugincontext.go b/pkg/services/pluginsintegration/plugincontext/base_plugincontext.go index 48c179bb29d..3abe9d92a9b 100644 --- a/pkg/services/pluginsintegration/plugincontext/base_plugincontext.go +++ b/pkg/services/pluginsintegration/plugincontext/base_plugincontext.go @@ -43,7 +43,6 @@ func (p *BaseProvider) GetBasePluginContext(ctx context.Context, plugin pluginst pCtx := backend.PluginContext{ PluginID: plugin.ID, PluginVersion: plugin.Info.Version, - APIVersion: plugin.APIVersion, } if user != nil && !user.IsNil() { pCtx.OrgID = user.GetOrgID() diff --git a/pkg/services/pluginsintegration/plugincontext/plugincontext_test.go b/pkg/services/pluginsintegration/plugincontext/plugincontext_test.go index 3aa3709295f..feec2e53db0 100644 --- a/pkg/services/pluginsintegration/plugincontext/plugincontext_test.go +++ b/pkg/services/pluginsintegration/plugincontext/plugincontext_test.go @@ -26,17 +26,15 @@ import ( func TestGet(t *testing.T) { const ( - pluginID = "plugin-id" - alias = "alias" - apiVersion = "v0alpha1" + pluginID = "plugin-id" + alias = "alias" ) preg := registry.NewInMemory() require.NoError(t, preg.Add(context.Background(), &plugins.Plugin{ JSONData: plugins.JSONData{ - ID: pluginID, - AliasIDs: []string{alias}, - APIVersion: apiVersion, + ID: pluginID, + AliasIDs: []string{alias}, }, })) @@ -61,7 +59,6 @@ func TestGet(t *testing.T) { pCtx, err := pcp.Get(context.Background(), tc.input, identity, identity.OrgID) require.NoError(t, err) require.Equal(t, pluginID, pCtx.PluginID) - require.Equal(t, apiVersion, pCtx.APIVersion) require.NotNil(t, pCtx.GrafanaConfig) }) @@ -75,7 +72,6 @@ func TestGet(t *testing.T) { }) require.NoError(t, err) require.Equal(t, pluginID, pCtx.PluginID) - require.Equal(t, apiVersion, pCtx.APIVersion) require.NotNil(t, pCtx.GrafanaConfig) }) }) diff --git a/pkg/tests/api/plugins/data/expectedListResp.json b/pkg/tests/api/plugins/data/expectedListResp.json index 3985342cb8c..26e4bb233a9 100644 --- a/pkg/tests/api/plugins/data/expectedListResp.json +++ b/pkg/tests/api/plugins/data/expectedListResp.json @@ -1671,8 +1671,7 @@ "signature": "internal", "signatureType": "", "signatureOrg": "", - "angularDetected": false, - "apiVersion": "v0alpha1" + "angularDetected": false }, { "name": "Text", diff --git a/public/app/core/components/AppChrome/AppChromeService.tsx b/public/app/core/components/AppChrome/AppChromeService.tsx index 7a062e1152f..5c1d88ebb41 100644 --- a/public/app/core/components/AppChrome/AppChromeService.tsx +++ b/public/app/core/components/AppChrome/AppChromeService.tsx @@ -214,7 +214,7 @@ export class AppChromeService { const { kioskMode, searchBarHidden } = this.state.getValue(); if (searchBarHidden || kioskMode === KioskMode.TV) { - appEvents.emit(AppEvents.alertSuccess, [t('navigation.kiosk.tv-alert', 'Press ESC to exit kiosk mode')]); + appEvents.emit(AppEvents.alertInfo, [t('navigation.kiosk.tv-alert', 'Press ESC to exit kiosk mode')]); return KioskMode.Full; } diff --git a/public/app/core/components/AppNotifications/AppNotificationList.tsx b/public/app/core/components/AppNotifications/AppNotificationList.tsx index 7b989214d45..edb686f0d55 100644 --- a/public/app/core/components/AppNotifications/AppNotificationList.tsx +++ b/public/app/core/components/AppNotifications/AppNotificationList.tsx @@ -10,6 +10,7 @@ import { useSelector, useDispatch } from 'app/types'; import { createErrorNotification, + createInfoNotification, createSuccessNotification, createWarningNotification, } from '../../copy/appNotification'; @@ -25,6 +26,7 @@ export function AppNotificationList() { appEvents.on(AppEvents.alertWarning, (payload) => dispatch(notifyApp(createWarningNotification(...payload)))); appEvents.on(AppEvents.alertSuccess, (payload) => dispatch(notifyApp(createSuccessNotification(...payload)))); appEvents.on(AppEvents.alertError, (payload) => dispatch(notifyApp(createErrorNotification(...payload)))); + appEvents.on(AppEvents.alertInfo, (payload) => dispatch(notifyApp(createInfoNotification(...payload)))); }, [dispatch]); const onClearAppNotification = (id: string) => { diff --git a/public/app/core/copy/appNotification.ts b/public/app/core/copy/appNotification.ts index 485cdc6d4c6..60e766d48a2 100644 --- a/public/app/core/copy/appNotification.ts +++ b/public/app/core/copy/appNotification.ts @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useMemo, ReactElement } from 'react'; import { v4 as uuidv4 } from 'uuid'; import { getMessageFromError } from 'app/core/utils/errors'; @@ -40,7 +40,7 @@ export const createErrorNotification = ( title: string, text: string | Error = '', traceId?: string, - component?: React.ReactElement + component?: ReactElement ): AppNotification => { return { ...defaultErrorNotification, @@ -64,12 +64,23 @@ export const createWarningNotification = (title: string, text = '', traceId?: st showing: true, }); -/** Hook for showing toast notifications with varying severity (success, warning error). +export const createInfoNotification = (title: string, text = '', traceId?: string): AppNotification => ({ + severity: AppNotificationSeverity.Info, + icon: 'info-circle', + title, + text, + id: uuidv4(), + timestamp: Date.now(), + showing: true, +}); + +/** Hook for showing toast notifications with varying severity (success, warning, error, info). * @example * const notifyApp = useAppNotification(); * notifyApp.success('Success!', 'Some additional text'); * notifyApp.warning('Warning!'); * notifyApp.error('Error!'); + * notifyApp.info('Info text'); */ export function useAppNotification() { const dispatch = useDispatch(); @@ -84,6 +95,9 @@ export function useAppNotification() { error: (title: string, text = '', traceId?: string) => { dispatch(notifyApp(createErrorNotification(title, text, traceId))); }, + info: (title: string, text = '') => { + dispatch(notifyApp(createInfoNotification(title, text))); + }, }), [dispatch] ); diff --git a/public/app/features/dashboard-scene/scene/Scopes/ScopesFiltersScene.tsx b/public/app/features/dashboard-scene/scene/Scopes/ScopesFiltersScene.tsx index 7af747fc9d8..c693b626299 100644 --- a/public/app/features/dashboard-scene/scene/Scopes/ScopesFiltersScene.tsx +++ b/public/app/features/dashboard-scene/scene/Scopes/ScopesFiltersScene.tsx @@ -17,9 +17,9 @@ import { t, Trans } from 'app/core/internationalization'; import { ScopesInput } from './ScopesInput'; import { ScopesScene } from './ScopesScene'; -import { ScopesTreeLevel } from './ScopesTreeLevel'; +import { ScopesTree } from './ScopesTree'; import { fetchNodes, fetchScope, fetchSelectedScopes } from './api'; -import { NodesMap, SelectedScope, TreeScope } from './types'; +import { NodeReason, NodesMap, SelectedScope, TreeScope } from './types'; import { getBasicScope } from './utils'; export interface ScopesFiltersSceneState extends SceneObjectState { @@ -47,6 +47,7 @@ export class ScopesFiltersScene extends SceneObjectBase nodes: { '': { name: '', + reason: NodeReason.Result, nodeType: 'container', title: '', isExpandable: true, @@ -119,7 +120,19 @@ export class ScopesFiltersScene extends SceneObjectBase }) ) .subscribe((childNodes) => { - currentNode.nodes = childNodes; + const persistedNodes = this.state.treeScopes + .map(({ path }) => path[path.length - 1]) + .filter((nodeName) => nodeName in currentNode.nodes && !(nodeName in childNodes)) + .reduce((acc, nodeName) => { + acc[nodeName] = { + ...currentNode.nodes[nodeName], + reason: NodeReason.Persisted, + }; + + return acc; + }, {}); + + currentNode.nodes = { ...persistedNodes, ...childNodes }; this.setState({ nodes }); @@ -284,7 +297,7 @@ export function ScopesFiltersSceneRenderer({ model }: SceneComponentProps ) : ( - { + setIsTooltipVisible(false); + }, [scopes]); + const scopesPaths = useMemo(() => { const pathsTitles = scopes.map(({ scope, path }) => { let currentLevel = nodes; @@ -64,7 +69,7 @@ export function ScopesInput({ const groupedByPath = groupBy(pathsTitles, ([path]) => path); - return Object.entries(groupedByPath) + const scopesPaths = Object.entries(groupedByPath) .map(([path, pathScopes]) => { const scopesTitles = pathScopes.map(([, scopeTitle]) => scopeTitle).join(', '); @@ -75,41 +80,44 @@ export function ScopesInput({ {path}

)); + + return <>{scopesPaths}; }, [nodes, scopes, styles]); const scopesTitles = useMemo(() => scopes.map(({ scope }) => scope.spec.title).join(', '), [scopes]); - const input = ( - 0 && !isDisabled ? ( - onRemoveAllClick()} - /> - ) : undefined - } - onClick={() => { - if (!isDisabled) { - onInputClick(); + const input = useMemo( + () => ( + 0 && !isDisabled ? ( + onRemoveAllClick()} + /> + ) : undefined } - }} - /> + onMouseOver={() => setIsTooltipVisible(true)} + onMouseOut={() => setIsTooltipVisible(false)} + onClick={() => { + if (!isDisabled) { + onInputClick(); + } + }} + /> + ), + [isDisabled, isLoading, onInputClick, onRemoveAllClick, scopes, scopesTitles] ); - if (scopes.length === 0) { - return input; - } - return ( - {scopesPaths}} interactive={true}> + {input} ); diff --git a/public/app/features/dashboard-scene/scene/Scopes/ScopesScene.test.tsx b/public/app/features/dashboard-scene/scene/Scopes/ScopesScene.test.tsx index 84cea5de184..0f4fb4d188e 100644 --- a/public/app/features/dashboard-scene/scene/Scopes/ScopesScene.test.tsx +++ b/public/app/features/dashboard-scene/scene/Scopes/ScopesScene.test.tsx @@ -10,47 +10,54 @@ import { ScopesFiltersScene } from './ScopesFiltersScene'; import { ScopesScene } from './ScopesScene'; import { buildTestScene, - fetchSuggestedDashboardsSpy, fetchNodesSpy, fetchScopeSpy, fetchSelectedScopesSpy, - getApplicationsClustersExpand, - getApplicationsClustersSelect, - getApplicationsClustersSlothClusterNorthSelect, - getApplicationsClustersSlothClusterSouthSelect, - getApplicationsExpand, - getApplicationsSearch, - getApplicationsSlothPictureFactorySelect, - getApplicationsSlothPictureFactoryTitle, - getApplicationsSlothVoteTrackerSelect, - getFiltersApply, - getFiltersCancel, - getFiltersInput, - getClustersExpand, - getClustersSelect, - getClustersSlothClusterNorthRadio, - getClustersSlothClusterSouthRadio, + fetchSuggestedDashboardsSpy, getDashboard, getDashboardsContainer, getDashboardsExpand, getDashboardsSearch, + getFiltersApply, + getFiltersCancel, + getFiltersInput, getMock, + getNotFoundForFilter, + getNotFoundForFilterClear, + getNotFoundForScope, + getNotFoundNoScopes, + getPersistedApplicationsSlothPictureFactorySelect, + getPersistedApplicationsSlothPictureFactoryTitle, + getPersistedApplicationsSlothVoteTrackerTitle, + getResultApplicationsClustersExpand, + getResultApplicationsClustersSelect, + getResultApplicationsClustersSlothClusterNorthSelect, + getResultApplicationsClustersSlothClusterSouthSelect, + getResultApplicationsExpand, + getResultApplicationsSlothPictureFactorySelect, + getResultApplicationsSlothPictureFactoryTitle, + getResultApplicationsSlothVoteTrackerSelect, + getResultApplicationsSlothVoteTrackerTitle, + getResultClustersExpand, + getResultClustersSelect, + getResultClustersSlothClusterEastRadio, + getResultClustersSlothClusterNorthRadio, + getResultClustersSlothClusterSouthRadio, + getTreeHeadline, + getTreeSearch, mocksScopes, queryAllDashboard, - queryFiltersApply, - queryApplicationsClustersTitle, - queryApplicationsSlothPictureFactoryTitle, - queryApplicationsSlothVoteTrackerTitle, queryDashboard, queryDashboardsContainer, queryDashboardsExpand, - renderDashboard, - getNotFoundForScope, queryDashboardsSearch, - getNotFoundForFilter, - getClustersSlothClusterEastRadio, - getNotFoundForFilterClear, - getNotFoundNoScopes, + queryFiltersApply, + queryPersistedApplicationsSlothPictureFactoryTitle, + queryPersistedApplicationsSlothVoteTrackerTitle, + queryResultApplicationsClustersTitle, + queryResultApplicationsSlothPictureFactoryTitle, + queryResultApplicationsSlothVoteTrackerTitle, + renderDashboard, } from './testUtils'; jest.mock('@grafana/runtime', () => ({ @@ -106,15 +113,15 @@ describe('ScopesScene', () => { describe('Tree', () => { it('Navigates through scopes nodes', async () => { await userEvents.click(getFiltersInput()); - await userEvents.click(getApplicationsExpand()); - await userEvents.click(getApplicationsClustersExpand()); - await userEvents.click(getApplicationsExpand()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultApplicationsClustersExpand()); + await userEvents.click(getResultApplicationsExpand()); }); it('Fetches scope details on select', async () => { await userEvents.click(getFiltersInput()); - await userEvents.click(getApplicationsExpand()); - await userEvents.click(getApplicationsSlothVoteTrackerSelect()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultApplicationsSlothVoteTrackerSelect()); await waitFor(() => expect(fetchScopeSpy).toHaveBeenCalledTimes(1)); }); @@ -126,77 +133,167 @@ describe('ScopesScene', () => { ]) ); await userEvents.click(getFiltersInput()); - await userEvents.click(getApplicationsExpand()); - expect(getApplicationsSlothVoteTrackerSelect()).toBeChecked(); - expect(getApplicationsSlothPictureFactorySelect()).toBeChecked(); + await userEvents.click(getResultApplicationsExpand()); + expect(getResultApplicationsSlothVoteTrackerSelect()).toBeChecked(); + expect(getResultApplicationsSlothPictureFactorySelect()).toBeChecked(); }); it('Can select scopes from same level', async () => { await userEvents.click(getFiltersInput()); - await userEvents.click(getApplicationsExpand()); - await userEvents.click(getApplicationsSlothVoteTrackerSelect()); - await userEvents.click(getApplicationsSlothPictureFactorySelect()); - await userEvents.click(getApplicationsClustersSelect()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultApplicationsSlothVoteTrackerSelect()); + await userEvents.click(getResultApplicationsSlothPictureFactorySelect()); + await userEvents.click(getResultApplicationsClustersSelect()); await userEvents.click(getFiltersApply()); expect(getFiltersInput().value).toBe('slothVoteTracker, slothPictureFactory, Cluster Index Helper'); }); it('Can select a node from an inner level', async () => { await userEvents.click(getFiltersInput()); - await userEvents.click(getApplicationsExpand()); - await userEvents.click(getApplicationsSlothVoteTrackerSelect()); - await userEvents.click(getApplicationsClustersExpand()); - await userEvents.click(getApplicationsClustersSlothClusterNorthSelect()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultApplicationsSlothVoteTrackerSelect()); + await userEvents.click(getResultApplicationsClustersExpand()); + await userEvents.click(getResultApplicationsClustersSlothClusterNorthSelect()); await userEvents.click(getFiltersApply()); expect(getFiltersInput().value).toBe('slothClusterNorth'); }); it('Can select a node from an upper level', async () => { await userEvents.click(getFiltersInput()); - await userEvents.click(getApplicationsExpand()); - await userEvents.click(getApplicationsSlothVoteTrackerSelect()); - await userEvents.click(getApplicationsExpand()); - await userEvents.click(getClustersSelect()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultApplicationsSlothVoteTrackerSelect()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultClustersSelect()); await userEvents.click(getFiltersApply()); expect(getFiltersInput().value).toBe('Cluster Index Helper'); }); it('Respects only one select per container', async () => { await userEvents.click(getFiltersInput()); - await userEvents.click(getClustersExpand()); - await userEvents.click(getClustersSlothClusterNorthRadio()); - expect(getClustersSlothClusterNorthRadio().checked).toBe(true); - expect(getClustersSlothClusterSouthRadio().checked).toBe(false); - await userEvents.click(getClustersSlothClusterSouthRadio()); - expect(getClustersSlothClusterNorthRadio().checked).toBe(false); - expect(getClustersSlothClusterSouthRadio().checked).toBe(true); + await userEvents.click(getResultClustersExpand()); + await userEvents.click(getResultClustersSlothClusterNorthRadio()); + expect(getResultClustersSlothClusterNorthRadio().checked).toBe(true); + expect(getResultClustersSlothClusterSouthRadio().checked).toBe(false); + await userEvents.click(getResultClustersSlothClusterSouthRadio()); + expect(getResultClustersSlothClusterNorthRadio().checked).toBe(false); + expect(getResultClustersSlothClusterSouthRadio().checked).toBe(true); }); it('Search works', async () => { await userEvents.click(getFiltersInput()); - await userEvents.click(getApplicationsExpand()); - await userEvents.type(getApplicationsSearch(), 'Clusters'); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.type(getTreeSearch(), 'Clusters'); await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3)); - expect(queryApplicationsSlothPictureFactoryTitle()).not.toBeInTheDocument(); - expect(queryApplicationsSlothVoteTrackerTitle()).not.toBeInTheDocument(); - expect(getApplicationsClustersSelect()).toBeInTheDocument(); - await userEvents.clear(getApplicationsSearch()); - await userEvents.type(getApplicationsSearch(), 'sloth'); + expect(queryResultApplicationsSlothPictureFactoryTitle()).not.toBeInTheDocument(); + expect(queryResultApplicationsSlothVoteTrackerTitle()).not.toBeInTheDocument(); + expect(getResultApplicationsClustersSelect()).toBeInTheDocument(); + await userEvents.clear(getTreeSearch()); + await userEvents.type(getTreeSearch(), 'sloth'); await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(4)); - expect(getApplicationsSlothPictureFactoryTitle()).toBeInTheDocument(); - expect(getApplicationsSlothVoteTrackerSelect()).toBeInTheDocument(); - expect(queryApplicationsClustersTitle()).not.toBeInTheDocument(); + expect(getResultApplicationsSlothPictureFactoryTitle()).toBeInTheDocument(); + expect(getResultApplicationsSlothVoteTrackerSelect()).toBeInTheDocument(); + expect(queryResultApplicationsClustersTitle()).not.toBeInTheDocument(); }); it('Opens to a selected scope', async () => { await userEvents.click(getFiltersInput()); - await userEvents.click(getApplicationsExpand()); - await userEvents.click(getApplicationsSlothPictureFactorySelect()); - await userEvents.click(getApplicationsExpand()); - await userEvents.click(getClustersExpand()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultApplicationsSlothPictureFactorySelect()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultClustersExpand()); + await userEvents.click(getFiltersApply()); + await userEvents.click(getFiltersInput()); + expect(queryResultApplicationsSlothPictureFactoryTitle()).toBeInTheDocument(); + }); + + it('Persists a scope', async () => { + await userEvents.click(getFiltersInput()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultApplicationsSlothPictureFactorySelect()); + await userEvents.type(getTreeSearch(), 'slothVoteTracker'); + await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3)); + expect(getPersistedApplicationsSlothPictureFactoryTitle()).toBeInTheDocument(); + expect(queryPersistedApplicationsSlothVoteTrackerTitle()).not.toBeInTheDocument(); + expect(queryResultApplicationsSlothPictureFactoryTitle()).not.toBeInTheDocument(); + expect(getResultApplicationsSlothVoteTrackerTitle()).toBeInTheDocument(); + }); + + it('Does not persist a retrieved scope', async () => { + await userEvents.click(getFiltersInput()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultApplicationsSlothPictureFactorySelect()); + await userEvents.type(getTreeSearch(), 'slothPictureFactory'); + await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3)); + expect(queryPersistedApplicationsSlothPictureFactoryTitle()).not.toBeInTheDocument(); + expect(getResultApplicationsSlothPictureFactoryTitle()).toBeInTheDocument(); + }); + + it('Removes persisted nodes', async () => { + await userEvents.click(getFiltersInput()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultApplicationsSlothPictureFactorySelect()); + await userEvents.type(getTreeSearch(), 'slothVoteTracker'); + await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3)); + await userEvents.clear(getTreeSearch()); + await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(4)); + expect(queryPersistedApplicationsSlothPictureFactoryTitle()).not.toBeInTheDocument(); + expect(queryPersistedApplicationsSlothVoteTrackerTitle()).not.toBeInTheDocument(); + expect(getResultApplicationsSlothPictureFactoryTitle()).toBeInTheDocument(); + expect(getResultApplicationsSlothVoteTrackerTitle()).toBeInTheDocument(); + }); + + it('Persists nodes from search', async () => { + await userEvents.click(getFiltersInput()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.type(getTreeSearch(), 'sloth'); + await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3)); + await userEvents.click(getResultApplicationsSlothPictureFactorySelect()); + await userEvents.click(getResultApplicationsSlothVoteTrackerSelect()); + await userEvents.type(getTreeSearch(), 'slothunknown'); + await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(4)); + expect(getPersistedApplicationsSlothPictureFactoryTitle()).toBeInTheDocument(); + expect(getPersistedApplicationsSlothVoteTrackerTitle()).toBeInTheDocument(); + await userEvents.clear(getTreeSearch()); + await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(5)); + expect(getResultApplicationsSlothPictureFactoryTitle()).toBeInTheDocument(); + expect(getResultApplicationsSlothVoteTrackerTitle()).toBeInTheDocument(); + }); + + it('Selects a persisted scope', async () => { + await userEvents.click(getFiltersInput()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultApplicationsSlothPictureFactorySelect()); + await userEvents.type(getTreeSearch(), 'slothVoteTracker'); + await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3)); + await userEvents.click(getResultApplicationsSlothVoteTrackerSelect()); await userEvents.click(getFiltersApply()); + expect(getFiltersInput().value).toBe('slothPictureFactory, slothVoteTracker'); + }); + + it('Deselects a persisted scope', async () => { await userEvents.click(getFiltersInput()); - expect(queryApplicationsSlothPictureFactoryTitle()).toBeInTheDocument(); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultApplicationsSlothPictureFactorySelect()); + await userEvents.type(getTreeSearch(), 'slothVoteTracker'); + await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3)); + await userEvents.click(getResultApplicationsSlothVoteTrackerSelect()); + await userEvents.click(getFiltersApply()); + expect(getFiltersInput().value).toBe('slothPictureFactory, slothVoteTracker'); + await userEvents.click(getFiltersInput()); + await userEvents.click(getPersistedApplicationsSlothPictureFactorySelect()); + await userEvents.click(getFiltersApply()); + expect(getFiltersInput().value).toBe('slothVoteTracker'); + }); + + it('Shows the proper headline', async () => { + await userEvents.click(getFiltersInput()); + expect(getTreeHeadline()).toHaveTextContent('Recommended'); + await userEvents.type(getTreeSearch(), 'Applications'); + await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(2)); + expect(getTreeHeadline()).toHaveTextContent('Results'); + await userEvents.type(getTreeSearch(), 'unknown'); + await waitFor(() => expect(fetchNodesSpy).toHaveBeenCalledTimes(3)); + expect(getTreeHeadline()).toHaveTextContent('No results found for your query'); }); }); @@ -208,7 +305,7 @@ describe('ScopesScene', () => { it('Fetches scope details on save', async () => { await userEvents.click(getFiltersInput()); - await userEvents.click(getClustersSelect()); + await userEvents.click(getResultClustersSelect()); await userEvents.click(getFiltersApply()); await waitFor(() => expect(fetchSelectedScopesSpy).toHaveBeenCalled()); expect(filtersScene.getSelectedScopes()).toEqual( @@ -218,7 +315,7 @@ describe('ScopesScene', () => { it("Doesn't save the scopes on close", async () => { await userEvents.click(getFiltersInput()); - await userEvents.click(getClustersSelect()); + await userEvents.click(getResultClustersSelect()); await userEvents.click(getFiltersCancel()); await waitFor(() => expect(fetchSelectedScopesSpy).not.toHaveBeenCalled()); expect(filtersScene.getSelectedScopes()).toEqual([]); @@ -226,7 +323,7 @@ describe('ScopesScene', () => { it('Shows selected scopes', async () => { await userEvents.click(getFiltersInput()); - await userEvents.click(getClustersSelect()); + await userEvents.click(getResultClustersSelect()); await userEvents.click(getFiltersApply()); expect(getFiltersInput().value).toEqual('Cluster Index Helper'); }); @@ -240,8 +337,8 @@ describe('ScopesScene', () => { it('Does not fetch dashboards list when the list is not expanded', async () => { await userEvents.click(getFiltersInput()); - await userEvents.click(getApplicationsExpand()); - await userEvents.click(getApplicationsSlothPictureFactorySelect()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultApplicationsSlothPictureFactorySelect()); await userEvents.click(getFiltersApply()); await waitFor(() => expect(fetchSuggestedDashboardsSpy).not.toHaveBeenCalled()); }); @@ -249,16 +346,16 @@ describe('ScopesScene', () => { it('Fetches dashboards list when the list is expanded', async () => { await userEvents.click(getDashboardsExpand()); await userEvents.click(getFiltersInput()); - await userEvents.click(getApplicationsExpand()); - await userEvents.click(getApplicationsSlothPictureFactorySelect()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultApplicationsSlothPictureFactorySelect()); await userEvents.click(getFiltersApply()); await waitFor(() => expect(fetchSuggestedDashboardsSpy).toHaveBeenCalled()); }); it('Fetches dashboards list when the list is expanded after scope selection', async () => { await userEvents.click(getFiltersInput()); - await userEvents.click(getApplicationsExpand()); - await userEvents.click(getApplicationsSlothPictureFactorySelect()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultApplicationsSlothPictureFactorySelect()); await userEvents.click(getFiltersApply()); await userEvents.click(getDashboardsExpand()); await waitFor(() => expect(fetchSuggestedDashboardsSpy).toHaveBeenCalled()); @@ -267,22 +364,22 @@ describe('ScopesScene', () => { it('Shows dashboards for multiple scopes', async () => { await userEvents.click(getDashboardsExpand()); await userEvents.click(getFiltersInput()); - await userEvents.click(getApplicationsExpand()); - await userEvents.click(getApplicationsSlothPictureFactorySelect()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultApplicationsSlothPictureFactorySelect()); await userEvents.click(getFiltersApply()); expect(getDashboard('1')).toBeInTheDocument(); expect(getDashboard('2')).toBeInTheDocument(); expect(queryDashboard('3')).not.toBeInTheDocument(); expect(queryDashboard('4')).not.toBeInTheDocument(); await userEvents.click(getFiltersInput()); - await userEvents.click(getApplicationsSlothVoteTrackerSelect()); + await userEvents.click(getResultApplicationsSlothVoteTrackerSelect()); await userEvents.click(getFiltersApply()); expect(getDashboard('1')).toBeInTheDocument(); expect(getDashboard('2')).toBeInTheDocument(); expect(getDashboard('3')).toBeInTheDocument(); expect(getDashboard('4')).toBeInTheDocument(); await userEvents.click(getFiltersInput()); - await userEvents.click(getApplicationsSlothPictureFactorySelect()); + await userEvents.click(getResultApplicationsSlothPictureFactorySelect()); await userEvents.click(getFiltersApply()); expect(queryDashboard('1')).not.toBeInTheDocument(); expect(queryDashboard('2')).not.toBeInTheDocument(); @@ -293,8 +390,8 @@ describe('ScopesScene', () => { it('Filters the dashboards list', async () => { await userEvents.click(getDashboardsExpand()); await userEvents.click(getFiltersInput()); - await userEvents.click(getApplicationsExpand()); - await userEvents.click(getApplicationsSlothPictureFactorySelect()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultApplicationsSlothPictureFactorySelect()); await userEvents.click(getFiltersApply()); expect(getDashboard('1')).toBeInTheDocument(); expect(getDashboard('2')).toBeInTheDocument(); @@ -305,10 +402,10 @@ describe('ScopesScene', () => { it('Deduplicates the dashboards list', async () => { await userEvents.click(getDashboardsExpand()); await userEvents.click(getFiltersInput()); - await userEvents.click(getApplicationsExpand()); - await userEvents.click(getApplicationsClustersExpand()); - await userEvents.click(getApplicationsClustersSlothClusterNorthSelect()); - await userEvents.click(getApplicationsClustersSlothClusterSouthSelect()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultApplicationsClustersExpand()); + await userEvents.click(getResultApplicationsClustersSlothClusterNorthSelect()); + await userEvents.click(getResultApplicationsClustersSlothClusterSouthSelect()); await userEvents.click(getFiltersApply()); expect(queryAllDashboard('5')).toHaveLength(1); expect(queryAllDashboard('6')).toHaveLength(1); @@ -325,8 +422,8 @@ describe('ScopesScene', () => { it('Does not show the input when there are no dashboards found for scope', async () => { await userEvents.click(getDashboardsExpand()); await userEvents.click(getFiltersInput()); - await userEvents.click(getClustersExpand()); - await userEvents.click(getClustersSlothClusterEastRadio()); + await userEvents.click(getResultClustersExpand()); + await userEvents.click(getResultClustersSlothClusterEastRadio()); await userEvents.click(getFiltersApply()); expect(getNotFoundForScope()).toBeInTheDocument(); expect(queryDashboardsSearch()).not.toBeInTheDocument(); @@ -335,8 +432,8 @@ describe('ScopesScene', () => { it('Does show the input and a message when there are no dashboards found for filter', async () => { await userEvents.click(getDashboardsExpand()); await userEvents.click(getFiltersInput()); - await userEvents.click(getApplicationsExpand()); - await userEvents.click(getApplicationsSlothPictureFactorySelect()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultApplicationsSlothPictureFactorySelect()); await userEvents.click(getFiltersApply()); await userEvents.type(getDashboardsSearch(), 'unknown'); expect(queryDashboardsSearch()).toBeInTheDocument(); @@ -380,8 +477,8 @@ describe('ScopesScene', () => { describe('Enrichers', () => { it('Data requests', async () => { await userEvents.click(getFiltersInput()); - await userEvents.click(getApplicationsExpand()); - await userEvents.click(getApplicationsSlothPictureFactorySelect()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultApplicationsSlothPictureFactorySelect()); await userEvents.click(getFiltersApply()); await waitFor(() => { const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!; @@ -391,7 +488,7 @@ describe('ScopesScene', () => { }); await userEvents.click(getFiltersInput()); - await userEvents.click(getApplicationsSlothVoteTrackerSelect()); + await userEvents.click(getResultApplicationsSlothVoteTrackerSelect()); await userEvents.click(getFiltersApply()); await waitFor(() => { const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!; @@ -403,7 +500,7 @@ describe('ScopesScene', () => { }); await userEvents.click(getFiltersInput()); - await userEvents.click(getApplicationsSlothPictureFactorySelect()); + await userEvents.click(getResultApplicationsSlothPictureFactorySelect()); await userEvents.click(getFiltersApply()); await waitFor(() => { const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!; @@ -415,8 +512,8 @@ describe('ScopesScene', () => { it('Filters requests', async () => { await userEvents.click(getFiltersInput()); - await userEvents.click(getApplicationsExpand()); - await userEvents.click(getApplicationsSlothPictureFactorySelect()); + await userEvents.click(getResultApplicationsExpand()); + await userEvents.click(getResultApplicationsSlothPictureFactorySelect()); await userEvents.click(getFiltersApply()); await waitFor(() => { expect(dashboardScene.enrichFiltersRequest().scopes).toEqual( @@ -425,7 +522,7 @@ describe('ScopesScene', () => { }); await userEvents.click(getFiltersInput()); - await userEvents.click(getApplicationsSlothVoteTrackerSelect()); + await userEvents.click(getResultApplicationsSlothVoteTrackerSelect()); await userEvents.click(getFiltersApply()); await waitFor(() => { expect(dashboardScene.enrichFiltersRequest().scopes).toEqual( @@ -436,7 +533,7 @@ describe('ScopesScene', () => { }); await userEvents.click(getFiltersInput()); - await userEvents.click(getApplicationsSlothPictureFactorySelect()); + await userEvents.click(getResultApplicationsSlothPictureFactorySelect()); await userEvents.click(getFiltersApply()); await waitFor(() => { expect(dashboardScene.enrichFiltersRequest().scopes).toEqual( diff --git a/public/app/features/dashboard-scene/scene/Scopes/ScopesTree.tsx b/public/app/features/dashboard-scene/scene/Scopes/ScopesTree.tsx new file mode 100644 index 00000000000..6f97a7e9b83 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/Scopes/ScopesTree.tsx @@ -0,0 +1,81 @@ +import { groupBy } from 'lodash'; +import { useMemo } from 'react'; + +import { ScopesTreeHeadline } from './ScopesTreeHeadline'; +import { ScopesTreeItem } from './ScopesTreeItem'; +import { ScopesTreeLoading } from './ScopesTreeLoading'; +import { ScopesTreeSearch } from './ScopesTreeSearch'; +import { NodeReason, NodesMap, OnNodeSelectToggle, OnNodeUpdate, TreeScope } from './types'; + +export interface ScopesTreeProps { + nodes: NodesMap; + nodePath: string[]; + loadingNodeName: string | undefined; + scopes: TreeScope[]; + onNodeUpdate: OnNodeUpdate; + onNodeSelectToggle: OnNodeSelectToggle; +} + +export function ScopesTree({ + nodes, + nodePath, + loadingNodeName, + scopes, + onNodeUpdate, + onNodeSelectToggle, +}: ScopesTreeProps) { + const nodeId = nodePath[nodePath.length - 1]; + const node = nodes[nodeId]; + const childNodes = Object.values(node.nodes); + const isNodeLoading = loadingNodeName === nodeId; + const scopeNames = scopes.map(({ scopeName }) => scopeName); + const anyChildExpanded = childNodes.some(({ isExpanded }) => isExpanded); + const groupedNodes = useMemo(() => groupBy(childNodes, 'reason'), [childNodes]); + + return ( + <> + + + + + + + + + + + ); +} diff --git a/public/app/features/dashboard-scene/scene/Scopes/ScopesTreeHeadline.tsx b/public/app/features/dashboard-scene/scene/Scopes/ScopesTreeHeadline.tsx new file mode 100644 index 00000000000..8dda39062b8 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/Scopes/ScopesTreeHeadline.tsx @@ -0,0 +1,42 @@ +import { css } from '@emotion/css'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; +import { Trans } from 'app/core/internationalization'; + +import { Node } from './types'; + +export interface ScopesTreeHeadlineProps { + anyChildExpanded: boolean; + query: string; + resultsNodes: Node[]; +} + +export function ScopesTreeHeadline({ anyChildExpanded, query, resultsNodes }: ScopesTreeHeadlineProps) { + const styles = useStyles2(getStyles); + + if (anyChildExpanded) { + return null; + } + + return ( +
+ {!query ? ( + Recommended + ) : resultsNodes.length === 0 ? ( + No results found for your query + ) : ( + Results + )} +
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + container: css({ + color: theme.colors.text.secondary, + margin: theme.spacing(1, 0), + }), + }; +}; diff --git a/public/app/features/dashboard-scene/scene/Scopes/ScopesTreeItem.tsx b/public/app/features/dashboard-scene/scene/Scopes/ScopesTreeItem.tsx new file mode 100644 index 00000000000..611e0851a68 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/Scopes/ScopesTreeItem.tsx @@ -0,0 +1,143 @@ +import { css } from '@emotion/css'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Checkbox, Icon, RadioButtonDot, useStyles2 } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; + +import { ScopesTree } from './ScopesTree'; +import { Node, OnNodeSelectToggle, OnNodeUpdate, TreeScope } from './types'; + +export interface ScopesTreeItemProps { + anyChildExpanded: boolean; + isNodeLoading: boolean; + loadingNodeName: string | undefined; + node: Node; + nodePath: string[]; + nodes: Node[]; + scopeNames: string[]; + scopes: TreeScope[]; + type: 'persisted' | 'result'; + onNodeUpdate: OnNodeUpdate; + onNodeSelectToggle: OnNodeSelectToggle; +} + +export function ScopesTreeItem({ + anyChildExpanded, + loadingNodeName, + node, + nodePath, + nodes, + scopeNames, + scopes, + type, + onNodeSelectToggle, + onNodeUpdate, +}: ScopesTreeItemProps) { + const styles = useStyles2(getStyles); + + return ( +
+ {nodes.map((childNode) => { + const isSelected = childNode.isSelectable && scopeNames.includes(childNode.linkId!); + + if (anyChildExpanded && !childNode.isExpanded) { + return null; + } + + const childNodePath = [...nodePath, childNode.name]; + + const radioName = childNodePath.join('.'); + + return ( +
+
+ {childNode.isSelectable && !childNode.isExpanded ? ( + node.disableMultiSelect ? ( + { + onNodeSelectToggle(childNodePath); + }} + /> + ) : ( + { + onNodeSelectToggle(childNodePath); + }} + /> + ) + ) : null} + + {childNode.isExpandable ? ( + + ) : ( + {childNode.title} + )} +
+ +
+ {childNode.isExpanded && ( + + )} +
+
+ ); + })} +
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + title: css({ + alignItems: 'center', + display: 'flex', + gap: theme.spacing(1), + fontSize: theme.typography.pxToRem(14), + lineHeight: theme.typography.pxToRem(22), + padding: theme.spacing(0.5, 0), + + '& > label': css({ + gap: 0, + }), + }), + expand: css({ + alignItems: 'center', + background: 'none', + border: 0, + display: 'flex', + gap: theme.spacing(1), + margin: 0, + padding: 0, + }), + children: css({ + paddingLeft: theme.spacing(4), + }), + }; +}; diff --git a/public/app/features/dashboard-scene/scene/Scopes/ScopesTreeLevel.tsx b/public/app/features/dashboard-scene/scene/Scopes/ScopesTreeLevel.tsx deleted file mode 100644 index 8d57b74d825..00000000000 --- a/public/app/features/dashboard-scene/scene/Scopes/ScopesTreeLevel.tsx +++ /dev/null @@ -1,185 +0,0 @@ -import { css } from '@emotion/css'; -import { debounce } from 'lodash'; -import { useEffect, useMemo, useState } from 'react'; -import Skeleton from 'react-loading-skeleton'; - -import { GrafanaTheme2 } from '@grafana/data'; -import { Checkbox, FilterInput, Icon, RadioButtonDot, useStyles2 } from '@grafana/ui'; -import { t, Trans } from 'app/core/internationalization'; - -import { NodesMap, TreeScope } from './types'; - -export interface ScopesTreeLevelProps { - nodes: NodesMap; - nodePath: string[]; - loadingNodeName: string | undefined; - scopes: TreeScope[]; - onNodeUpdate: (path: string[], isExpanded: boolean, query: string) => void; - onNodeSelectToggle: (path: string[]) => void; -} - -export function ScopesTreeLevel({ - nodes, - nodePath, - loadingNodeName, - scopes, - onNodeUpdate, - onNodeSelectToggle, -}: ScopesTreeLevelProps) { - const styles = useStyles2(getStyles); - - const nodeId = nodePath[nodePath.length - 1]; - const node = nodes[nodeId]; - const childNodes = node.nodes; - const childNodesArr = Object.values(childNodes); - const isNodeLoading = loadingNodeName === nodeId; - - const scopeNames = scopes.map(({ scopeName }) => scopeName); - const anyChildExpanded = childNodesArr.some(({ isExpanded }) => isExpanded); - - const [queryValue, setQueryValue] = useState(node.query); - useEffect(() => { - setQueryValue(node.query); - }, [node.query]); - const onQueryUpdate = useMemo(() => debounce(onNodeUpdate, 500), [onNodeUpdate]); - - return ( - <> - {!anyChildExpanded && ( - { - setQueryValue(value); - onQueryUpdate(nodePath, true, value); - }} - /> - )} - - {!anyChildExpanded && !node.query && ( -
- Recommended -
- )} - -
- {isNodeLoading && } - - {!isNodeLoading && - childNodesArr.map((childNode) => { - const isSelected = childNode.isSelectable && scopeNames.includes(childNode.linkId!); - - if (anyChildExpanded && !childNode.isExpanded && !isSelected) { - return null; - } - - const childNodePath = [...nodePath, childNode.name]; - - const radioName = childNodePath.join('.'); - - return ( -
-
- {childNode.isSelectable && !childNode.isExpanded ? ( - node.disableMultiSelect ? ( - { - onNodeSelectToggle(childNodePath); - }} - /> - ) : ( - { - onNodeSelectToggle(childNodePath); - }} - /> - ) - ) : null} - - {childNode.isExpandable ? ( - - ) : ( - {childNode.title} - )} -
- -
- {childNode.isExpanded && ( - - )} -
-
- ); - })} -
- - ); -} - -const getStyles = (theme: GrafanaTheme2) => { - return { - searchInput: css({ - margin: theme.spacing(1, 0), - }), - headline: css({ - color: theme.colors.text.secondary, - margin: theme.spacing(1, 0), - }), - loader: css({ - margin: theme.spacing(0.5, 0), - }), - itemTitle: css({ - alignItems: 'center', - display: 'flex', - gap: theme.spacing(1), - fontSize: theme.typography.pxToRem(14), - lineHeight: theme.typography.pxToRem(22), - padding: theme.spacing(0.5, 0), - - '& > label': css({ - gap: 0, - }), - }), - itemExpand: css({ - alignItems: 'center', - background: 'none', - border: 0, - display: 'flex', - gap: theme.spacing(1), - margin: 0, - padding: 0, - }), - itemChildren: css({ - paddingLeft: theme.spacing(4), - }), - }; -}; diff --git a/public/app/features/dashboard-scene/scene/Scopes/ScopesTreeLoading.tsx b/public/app/features/dashboard-scene/scene/Scopes/ScopesTreeLoading.tsx new file mode 100644 index 00000000000..9a27b6a2311 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/Scopes/ScopesTreeLoading.tsx @@ -0,0 +1,29 @@ +import { css } from '@emotion/css'; +import { ReactNode } from 'react'; +import Skeleton from 'react-loading-skeleton'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; + +export interface ScopesTreeLoadingProps { + children: ReactNode; + isNodeLoading: boolean; +} + +export function ScopesTreeLoading({ children, isNodeLoading }: ScopesTreeLoadingProps) { + const styles = useStyles2(getStyles); + + if (isNodeLoading) { + return ; + } + + return children; +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + loader: css({ + margin: theme.spacing(0.5, 0), + }), + }; +}; diff --git a/public/app/features/dashboard-scene/scene/Scopes/ScopesTreeSearch.tsx b/public/app/features/dashboard-scene/scene/Scopes/ScopesTreeSearch.tsx new file mode 100644 index 00000000000..cc9f4a61d8c --- /dev/null +++ b/public/app/features/dashboard-scene/scene/Scopes/ScopesTreeSearch.tsx @@ -0,0 +1,53 @@ +import { css } from '@emotion/css'; +import { debounce } from 'lodash'; +import { useEffect, useMemo, useState } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { FilterInput, useStyles2 } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; + +import { OnNodeUpdate } from './types'; + +export interface ScopesTreeSearchProps { + anyChildExpanded: boolean; + nodePath: string[]; + query: string; + onNodeUpdate: OnNodeUpdate; +} + +export function ScopesTreeSearch({ anyChildExpanded, nodePath, query, onNodeUpdate }: ScopesTreeSearchProps) { + const styles = useStyles2(getStyles); + + const [queryValue, setQueryValue] = useState(query); + + useEffect(() => { + setQueryValue(query); + }, [query]); + + const onQueryUpdate = useMemo(() => debounce(onNodeUpdate, 500), [onNodeUpdate]); + + if (anyChildExpanded) { + return null; + } + + return ( + { + setQueryValue(value); + onQueryUpdate(nodePath, true, value); + }} + /> + ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + input: css({ + margin: theme.spacing(1, 0), + }), + }; +}; diff --git a/public/app/features/dashboard-scene/scene/Scopes/api.ts b/public/app/features/dashboard-scene/scene/Scopes/api.ts index 07df4877a54..d1deda21d73 100644 --- a/public/app/features/dashboard-scene/scene/Scopes/api.ts +++ b/public/app/features/dashboard-scene/scene/Scopes/api.ts @@ -1,8 +1,8 @@ -import { Scope, ScopeSpec, ScopeNode, ScopeDashboardBinding } from '@grafana/data'; +import { Scope, ScopeDashboardBinding, ScopeNode, ScopeSpec } from '@grafana/data'; import { config, getBackendSrv } from '@grafana/runtime'; import { ScopedResourceClient } from 'app/features/apiserver/client'; -import { NodesMap, SelectedScope, SuggestedDashboard, TreeScope } from './types'; +import { NodeReason, NodesMap, SelectedScope, SuggestedDashboard, TreeScope } from './types'; import { getBasicScope, mergeScopes } from './utils'; const group = 'scope.grafana.app'; @@ -37,6 +37,7 @@ export async function fetchNodes(parent: string, query: string): Promise `scopes-tree-${nodeId}-search`, - select: (nodeId: string) => `scopes-tree-${nodeId}-checkbox`, - radio: (nodeId: string) => `scopes-tree-${nodeId}-radio`, - expand: (nodeId: string) => `scopes-tree-${nodeId}-expand`, - title: (nodeId: string) => `scopes-tree-${nodeId}-title`, + search: 'scopes-tree-search', + headline: 'scopes-tree-headline', + select: (nodeId: string, type: 'result' | 'persisted') => `scopes-tree-${type}-${nodeId}-checkbox`, + radio: (nodeId: string, type: 'result' | 'persisted') => `scopes-tree-${type}-${nodeId}-radio`, + expand: (nodeId: string, type: 'result' | 'persisted') => `scopes-tree-${type}-${nodeId}-expand`, + title: (nodeId: string, type: 'result' | 'persisted') => `scopes-tree-${type}-${nodeId}-title`, }, filters: { input: 'scopes-filters-input', @@ -359,36 +360,50 @@ export const getNotFoundForScope = () => screen.getByTestId(selectors.dashboards export const getNotFoundForFilter = () => screen.getByTestId(selectors.dashboards.notFoundForFilter); export const getNotFoundForFilterClear = () => screen.getByTestId(selectors.dashboards.notFoundForFilterClear); -export const getApplicationsExpand = () => screen.getByTestId(selectors.tree.expand('applications')); -export const getApplicationsSearch = () => screen.getByTestId(selectors.tree.search('applications')); -export const queryApplicationsSlothPictureFactoryTitle = () => - screen.queryByTestId(selectors.tree.title('applications-slothPictureFactory')); -export const getApplicationsSlothPictureFactoryTitle = () => - screen.getByTestId(selectors.tree.title('applications-slothPictureFactory')); -export const getApplicationsSlothPictureFactorySelect = () => - screen.getByTestId(selectors.tree.select('applications-slothPictureFactory')); -export const queryApplicationsSlothVoteTrackerTitle = () => - screen.queryByTestId(selectors.tree.title('applications-slothVoteTracker')); -export const getApplicationsSlothVoteTrackerSelect = () => - screen.getByTestId(selectors.tree.select('applications-slothVoteTracker')); -export const queryApplicationsClustersTitle = () => screen.queryByTestId(selectors.tree.title('applications.clusters')); -export const getApplicationsClustersSelect = () => screen.getByTestId(selectors.tree.select('applications.clusters')); -export const getApplicationsClustersExpand = () => screen.getByTestId(selectors.tree.expand('applications.clusters')); -export const queryApplicationsClustersSlothClusterNorthTitle = () => - screen.queryByTestId(selectors.tree.title('applications.clusters-slothClusterNorth')); -export const getApplicationsClustersSlothClusterNorthSelect = () => - screen.getByTestId(selectors.tree.select('applications.clusters-slothClusterNorth')); -export const getApplicationsClustersSlothClusterSouthSelect = () => - screen.getByTestId(selectors.tree.select('applications.clusters-slothClusterSouth')); +export const getTreeSearch = () => screen.getByTestId(selectors.tree.search); +export const getTreeHeadline = () => screen.getByTestId(selectors.tree.headline); +export const getResultApplicationsExpand = () => screen.getByTestId(selectors.tree.expand('applications', 'result')); +export const queryResultApplicationsSlothPictureFactoryTitle = () => + screen.queryByTestId(selectors.tree.title('applications-slothPictureFactory', 'result')); +export const getResultApplicationsSlothPictureFactoryTitle = () => + screen.getByTestId(selectors.tree.title('applications-slothPictureFactory', 'result')); +export const getResultApplicationsSlothPictureFactorySelect = () => + screen.getByTestId(selectors.tree.select('applications-slothPictureFactory', 'result')); +export const queryPersistedApplicationsSlothPictureFactoryTitle = () => + screen.queryByTestId(selectors.tree.title('applications-slothPictureFactory', 'persisted')); +export const getPersistedApplicationsSlothPictureFactoryTitle = () => + screen.getByTestId(selectors.tree.title('applications-slothPictureFactory', 'persisted')); +export const getPersistedApplicationsSlothPictureFactorySelect = () => + screen.getByTestId(selectors.tree.select('applications-slothPictureFactory', 'persisted')); +export const queryResultApplicationsSlothVoteTrackerTitle = () => + screen.queryByTestId(selectors.tree.title('applications-slothVoteTracker', 'result')); +export const getResultApplicationsSlothVoteTrackerTitle = () => + screen.getByTestId(selectors.tree.title('applications-slothVoteTracker', 'result')); +export const getResultApplicationsSlothVoteTrackerSelect = () => + screen.getByTestId(selectors.tree.select('applications-slothVoteTracker', 'result')); +export const queryPersistedApplicationsSlothVoteTrackerTitle = () => + screen.queryByTestId(selectors.tree.title('applications-slothVoteTracker', 'persisted')); +export const getPersistedApplicationsSlothVoteTrackerTitle = () => + screen.getByTestId(selectors.tree.title('applications-slothVoteTracker', 'persisted')); +export const queryResultApplicationsClustersTitle = () => + screen.queryByTestId(selectors.tree.title('applications.clusters', 'result')); +export const getResultApplicationsClustersSelect = () => + screen.getByTestId(selectors.tree.select('applications.clusters', 'result')); +export const getResultApplicationsClustersExpand = () => + screen.getByTestId(selectors.tree.expand('applications.clusters', 'result')); +export const getResultApplicationsClustersSlothClusterNorthSelect = () => + screen.getByTestId(selectors.tree.select('applications.clusters-slothClusterNorth', 'result')); +export const getResultApplicationsClustersSlothClusterSouthSelect = () => + screen.getByTestId(selectors.tree.select('applications.clusters-slothClusterSouth', 'result')); -export const getClustersSelect = () => screen.getByTestId(selectors.tree.select('clusters')); -export const getClustersExpand = () => screen.getByTestId(selectors.tree.expand('clusters')); -export const getClustersSlothClusterNorthRadio = () => - screen.getByTestId(selectors.tree.radio('clusters-slothClusterNorth')); -export const getClustersSlothClusterSouthRadio = () => - screen.getByTestId(selectors.tree.radio('clusters-slothClusterSouth')); -export const getClustersSlothClusterEastRadio = () => - screen.getByTestId(selectors.tree.radio('clusters-slothClusterEast')); +export const getResultClustersSelect = () => screen.getByTestId(selectors.tree.select('clusters', 'result')); +export const getResultClustersExpand = () => screen.getByTestId(selectors.tree.expand('clusters', 'result')); +export const getResultClustersSlothClusterNorthRadio = () => + screen.getByTestId(selectors.tree.radio('clusters-slothClusterNorth', 'result')); +export const getResultClustersSlothClusterSouthRadio = () => + screen.getByTestId(selectors.tree.radio('clusters-slothClusterSouth', 'result')); +export const getResultClustersSlothClusterEastRadio = () => + screen.getByTestId(selectors.tree.radio('clusters-slothClusterEast', 'result')); export function buildTestScene(overrides: Partial = {}) { return new DashboardScene({ diff --git a/public/app/features/dashboard-scene/scene/Scopes/types.ts b/public/app/features/dashboard-scene/scene/Scopes/types.ts index 5dc7cfc1e5d..40d3cbbefae 100644 --- a/public/app/features/dashboard-scene/scene/Scopes/types.ts +++ b/public/app/features/dashboard-scene/scene/Scopes/types.ts @@ -1,7 +1,13 @@ import { Scope, ScopeDashboardBinding, ScopeNodeSpec } from '@grafana/data'; +export enum NodeReason { + Persisted, + Result, +} + export interface Node extends ScopeNodeSpec { name: string; + reason: NodeReason; isExpandable: boolean; isSelectable: boolean; isExpanded: boolean; @@ -26,3 +32,6 @@ export interface SuggestedDashboard { dashboardTitle: string; items: ScopeDashboardBinding[]; } + +export type OnNodeUpdate = (path: string[], isExpanded: boolean, query: string) => void; +export type OnNodeSelectToggle = (path: string[]) => void; diff --git a/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.tsx b/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.tsx index 250f723d41f..c5a88434302 100644 --- a/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareButton/ShareMenu.tsx @@ -8,7 +8,9 @@ import { contextSrv } from 'app/core/core'; import { t } from 'app/core/internationalization'; import { isPublicDashboardsEnabled } from '../../../dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils'; +import { getTrackingSource, shareDashboardType } from '../../../dashboard/components/ShareModal/utils'; import { DashboardScene } from '../../scene/DashboardScene'; +import { DashboardInteractions } from '../../utils/interactions'; import { ShareDrawer } from '../ShareDrawer/ShareDrawer'; import { SceneShareDrawerState } from '../types'; @@ -21,6 +23,7 @@ const newShareButtonSelector = e2eSelectors.pages.Dashboard.DashNav.newShareButt type CustomDashboardDrawer = new (...args: SceneShareDrawerState[]) => SceneObject; export interface ShareDrawerMenuItem { + shareId: string; testId: string; label: string; description?: string; @@ -52,6 +55,7 @@ export default function ShareMenu({ dashboard, panel }: { dashboard: DashboardSc const menuItems: ShareDrawerMenuItem[] = []; menuItems.push({ + shareId: shareDashboardType.link, testId: newShareButtonSelector.shareInternally, icon: 'building', label: t('share-dashboard.menu.share-internally-title', 'Share internally'), @@ -62,6 +66,7 @@ export default function ShareMenu({ dashboard, panel }: { dashboard: DashboardSc }); menuItems.push({ + shareId: shareDashboardType.publicDashboard, testId: newShareButtonSelector.shareExternally, icon: 'share-alt', label: t('share-dashboard.menu.share-externally-title', 'Share externally'), @@ -74,6 +79,7 @@ export default function ShareMenu({ dashboard, panel }: { dashboard: DashboardSc customShareDrawerItem.forEach((d) => menuItems.push(d)); menuItems.push({ + shareId: shareDashboardType.snapshot, testId: newShareButtonSelector.shareSnapshot, icon: 'camera', label: t('share-dashboard.menu.share-snapshot-title', 'Share snapshot'), @@ -86,6 +92,15 @@ export default function ShareMenu({ dashboard, panel }: { dashboard: DashboardSc return menuItems.filter((item) => item.renderCondition); }, [onMenuItemClick, dashboard, panel]); + const onClick = (item: ShareDrawerMenuItem) => { + DashboardInteractions.sharingCategoryClicked({ + item: item.shareId, + shareResource: getTrackingSource(panel?.getRef()), + }); + + item.onClick(dashboard); + }; + return ( {buildMenuItems().map((item) => ( @@ -95,7 +110,7 @@ export default function ShareMenu({ dashboard, panel }: { dashboard: DashboardSc label={item.label} icon={item.icon} description={item.description} - onClick={() => item.onClick(dashboard)} + onClick={() => onClick(item)} /> ))} diff --git a/public/app/features/dashboard-scene/sharing/ShareModal.tsx b/public/app/features/dashboard-scene/sharing/ShareModal.tsx index d015501e143..916eddc9dde 100644 --- a/public/app/features/dashboard-scene/sharing/ShareModal.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareModal.tsx @@ -91,7 +91,10 @@ export class ShareModal extends SceneObjectBase implements Moda }; onChangeTab: ComponentProps['onChangeTab'] = (tab) => { - DashboardInteractions.sharingTabChanged({ item: tab.value, shareResource: getTrackingSource(this.state.panelRef) }); + DashboardInteractions.sharingCategoryClicked({ + item: tab.value, + shareResource: getTrackingSource(this.state.panelRef), + }); this.setState({ activeTab: tab.value }); }; } diff --git a/public/app/features/dashboard-scene/utils/interactions.ts b/public/app/features/dashboard-scene/utils/interactions.ts index 85c4b332f82..3086ec1defb 100644 --- a/public/app/features/dashboard-scene/utils/interactions.ts +++ b/public/app/features/dashboard-scene/utils/interactions.ts @@ -44,7 +44,7 @@ export const DashboardInteractions = { }, // Sharing interactions: - sharingTabChanged: (properties?: Record) => { + sharingCategoryClicked: (properties?: Record) => { reportDashboardInteraction('sharing_category_clicked', properties); }, shareLinkCopied: (properties?: Record) => { diff --git a/public/app/features/dashboard/components/ShareModal/ShareModal.tsx b/public/app/features/dashboard/components/ShareModal/ShareModal.tsx index 937ae1af519..9e3bc7117c8 100644 --- a/public/app/features/dashboard/components/ShareModal/ShareModal.tsx +++ b/public/app/features/dashboard/components/ShareModal/ShareModal.tsx @@ -102,7 +102,7 @@ class UnthemedShareModal extends React.Component { onSelectTab: React.ComponentProps['onChangeTab'] = (t) => { this.setState((prevState) => ({ ...prevState, activeTab: t.value })); - DashboardInteractions.sharingTabChanged({ + DashboardInteractions.sharingCategoryClicked({ item: t.value, shareResource: getTrackingSource(this.props.panel), }); diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx index faff169478a..c19af9d767f 100644 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboard.test.tsx @@ -353,11 +353,11 @@ describe('SharePublic - Report interactions', () => { }); it('reports interaction when public dashboard tab is clicked', async () => { + jest.spyOn(DashboardInteractions, 'sharingCategoryClicked'); await renderSharePublicDashboard(); await waitFor(() => { - expect(DashboardInteractions.sharingTabChanged).toHaveBeenCalledTimes(1); - expect(DashboardInteractions.sharingTabChanged).lastCalledWith({ + expect(DashboardInteractions.sharingCategoryClicked).lastCalledWith({ item: shareDashboardType.publicDashboard, shareResource: 'dashboard', }); @@ -374,7 +374,6 @@ describe('SharePublic - Report interactions', () => { await userEvent.click(screen.getByTestId(selectors.EnableTimeRangeSwitch)); await waitFor(() => { - expect(reportInteraction).toHaveBeenCalledTimes(1); expect(reportInteraction).toHaveBeenLastCalledWith('dashboards_sharing_public_time_picker_clicked', { enabled: !pubdashResponse.timeSelectionEnabled, }); @@ -391,7 +390,6 @@ describe('SharePublic - Report interactions', () => { await userEvent.click(screen.getByTestId(selectors.EnableAnnotationsSwitch)); await waitFor(() => { - expect(reportInteraction).toHaveBeenCalledTimes(1); expect(reportInteraction).toHaveBeenLastCalledWith('dashboards_sharing_public_annotations_clicked', { enabled: !pubdashResponse.annotationsEnabled, }); @@ -405,7 +403,6 @@ describe('SharePublic - Report interactions', () => { await userEvent.click(screen.getByTestId(selectors.PauseSwitch)); await waitFor(() => { - expect(reportInteraction).toHaveBeenCalledTimes(1); expect(reportInteraction).toHaveBeenLastCalledWith('dashboards_sharing_public_pause_clicked', { paused: pubdashResponse.isEnabled, }); diff --git a/public/app/features/gops/configuration-tracker/irmHooks.ts b/public/app/features/gops/configuration-tracker/irmHooks.ts index f182633940f..14fe5246d52 100644 --- a/public/app/features/gops/configuration-tracker/irmHooks.ts +++ b/public/app/features/gops/configuration-tracker/irmHooks.ts @@ -340,7 +340,7 @@ export const useGetConfigurationForUI = ({ function getConnectDataSourceConfiguration() { const description = dataSourceCompatibleWithAlerting ? 'You have connected a datasource.' - : 'Connect at least one data source to start receiving data.'; + : 'Connect at least one data source to start receiving data'; const actionButtonTitle = dataSourceCompatibleWithAlerting ? 'View' : 'Connect'; return { id: ConfigurationStepsEnum.CONNECT_DATASOURCE, @@ -356,8 +356,8 @@ export const useGetConfigurationForUI = ({ id: ConfigurationStepsEnum.ESSENTIALS, title: 'Essentials', titleIcon: 'star', - description: 'Configure the features you need to start using Grafana IRM workflows', - actionButtonTitle: 'Start', + description: 'Set up the necessary features to start using Grafana IRM workflows', + actionButtonTitle: stepsDone === totalStepsToDo ? 'View' : 'Configure', stepsDone, totalStepsToDo, }, diff --git a/public/app/plugins/datasource/grafana-testdata-datasource/plugin.json b/public/app/plugins/datasource/grafana-testdata-datasource/plugin.json index 55807a26991..903222c9fef 100644 --- a/public/app/plugins/datasource/grafana-testdata-datasource/plugin.json +++ b/public/app/plugins/datasource/grafana-testdata-datasource/plugin.json @@ -10,7 +10,6 @@ "alerting": true, "annotations": true, "backend": true, - "apiVersion": "v0alpha1", "queryOptions": { "minInterval": true, diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 70a894d96fd..92d54e9c3ae 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -1698,7 +1698,11 @@ "tree": { "collapse": "Collapse", "expand": "Expand", - "headline": "Recommended", + "headline": { + "noResults": "No results found for your query", + "recommended": "Recommended", + "results": "Results" + }, "search": "Search" } }, diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 3d992a19570..9a3df4cb747 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -1698,7 +1698,11 @@ "tree": { "collapse": "Cőľľäpşę", "expand": "Ēχpäʼnđ", - "headline": "Ŗęčőmmęʼnđęđ", + "headline": { + "noResults": "Ńő řęşūľŧş ƒőūʼnđ ƒőř yőūř qūęřy", + "recommended": "Ŗęčőmmęʼnđęđ", + "results": "Ŗęşūľŧş" + }, "search": "Ŝęäřčĥ" } },