diff --git a/apps/scope/pkg/apis/scope/v0alpha1/types.go b/apps/scope/pkg/apis/scope/v0alpha1/types.go index b0f58fdfd44..0c323d8d14b 100644 --- a/apps/scope/pkg/apis/scope/v0alpha1/types.go +++ b/apps/scope/pkg/apis/scope/v0alpha1/types.go @@ -209,6 +209,8 @@ type FindScopeNavigationsResults struct { type ScopeNavigationSpec struct { URL string `json:"url"` Scope string `json:"scope"` + // Used to navigate to a sub-scope of the main scope. URL will not be used if this is set. + SubScope string `json:"subScope,omitempty"` } // Type of the item. diff --git a/apps/scope/pkg/apis/scope/v0alpha1/zz_generated.openapi.go b/apps/scope/pkg/apis/scope/v0alpha1/zz_generated.openapi.go index a0b0e90b557..50015307139 100644 --- a/apps/scope/pkg/apis/scope/v0alpha1/zz_generated.openapi.go +++ b/apps/scope/pkg/apis/scope/v0alpha1/zz_generated.openapi.go @@ -635,6 +635,13 @@ func schema_pkg_apis_scope_v0alpha1_ScopeNavigationSpec(ref common.ReferenceCall Format: "", }, }, + "subScope": { + SchemaProps: spec.SchemaProps{ + Description: "Used to navigate to a sub-scope of the main scope. URL will not be used if this is set.", + Type: []string{"string"}, + Format: "", + }, + }, }, Required: []string{"url", "scope"}, }, diff --git a/devenv/scopes/scopes-config.yaml b/devenv/scopes/scopes-config.yaml index 46592742bbd..54842e5818e 100644 --- a/devenv/scopes/scopes-config.yaml +++ b/devenv/scopes/scopes-config.yaml @@ -1,29 +1,58 @@ scopes: - app1: - title: Application 1 + shoe-org: + title: Shoe organization + filters: + - key: organization + operator: equals + value: shoe + + shoes: + title: Shoes + filters: + - key: product + operator: equals + value: shoes + + apparel: + title: Apparel + filters: + - key: product + operator: equals + value: apparel + + frontend: + title: Frontend + filters: + - key: team + operator: equals + value: frontend + + database: + title: Database + filters: + - key: team + operator: equals + value: database + + main-app: + title: Main App filters: - key: app operator: equals - value: app1 + value: main - app2: - title: Application 2 + kids-app: + title: Kids App filters: - key: app operator: equals - value: app2 - - cluster1: - title: Cluster 1 - filters: - - key: cluster - operator: equals - value: cluster1 + value: kids tree: gdev-scopes: title: gdev-scopes nodeType: container + disableMultiSelect: true children: production: title: Production @@ -54,7 +83,6 @@ tree: nodeType: leaf linkId: test-case-2 linkType: scope - clusters: title: Clusters nodeType: container @@ -66,19 +94,139 @@ tree: nodeType: leaf linkId: cluster1 linkType: scope + shoe-org-root: + title: Shoe organization + subTitle: Nested scopes navigation + nodeType: leaf + linkId: shoe-org + linkType: scope -navigations: - # Example: Link to a dashboard - app1-nav: - url: /d/86Js1xRmk - scope: app1 +navigationTree: + - name: global-overview + title: Global overview + url: /d/TkZXxlNG3 + scope: shoe-org - # Example: Link to a dashboard with full URL (already has /d/) - app2-nav: - url: /d/GlAqcPgmz - scope: app2 + - name: reliability-placeholder + title: Reliability + url: /d/dcb9f5e9-8066-4397-889e-864b99555dbb + scope: shoe-org + groups: + - Reliability - # Example: Custom URL path - custom-nav: - url: /explore - scope: app1 + - name: shoes + title: Shoes + url: /d/_5rDmaQiz + scope: shoe-org + subScope: shoes + children: + - name: shoes-overview + title: Overview + url: /d/5SdHCadmz + scope: shoes + + - name: shoes-team-overview + title: Team Overview + url: /d/EJ8_d9jZk + scope: shoes + + - name: shoes-frontend + title: Frontend + url: /d/edediimbjhdz4b + scope: shoes + subScope: frontend + children: + - name: frontend-api + title: API Metrics + url: /d/5Y0jv6pVz + scope: frontend + + - name: frontend-ui + title: UI Performance + url: /d/2xuwrgV7z + scope: frontend + - name: frontend-shoes + title: Nested shoes + url: /d/5SdHCadmz + scope: frontend + subScope: shoes # This should be filtered out since 'shoes' is already in the path + + - name: shoes-database + title: Database + url: /d/lVE-2YFMz + scope: shoes + subScope: database + children: + - name: database-connections + title: Database Connections + url: /d/mIJjFy8Kz + scope: database + + - name: database-replication + title: Database Replication + url: /d/fdn48fmz8f94wc + scope: database + + - name: shoes-main-app + title: Main App + url: /d/WZ7AhQiVz + scope: shoes + subScope: main-app + children: + - name: main-app-users + title: User Analytics + url: /d/a6801696-cc53-4196-b1f9-2403e3909185 + scope: main-app + + - name: main-app-revenue + title: Revenue Metrics + url: /d/imQX6j-Gz + scope: main-app + + - name: shoes-kids-app + title: Kids App + url: /d/bds35fot3cv7kb + scope: shoes + subScope: kids-app + children: + - name: kids-app-features + title: Feature Usage + url: /d/16f11TZWk + scope: kids-app + + - name: kids-app-engagement + title: User Engagement + url: /d/c01bf42b-b783-4447-a304-8554cee1843b + scope: kids-app + + - name: apparel + title: Apparel + url: /d/UTv--wqMk + scope: shoe-org + subScope: apparel + children: + - name: apparel-product-overview + title: Product Overview + url: /d/Kp9Z0hTik + scope: apparel + + - name: all-teams-placeholder + title: All Teams + url: /d/b36b5576-2e3d-4b0c-8dce-e79514d99345 + scope: shoe-org + groups: + - Discovered dashboards + children: + - name: others-placeholder + title: Others + url: /d/xtY_uCAiz + scope: shoe-org + groups: + - All Teams + children: + - name: latency-and-errors + title: Latency and Errors + url: /d/ZqZnVvFZz + scope: shoe-org + groups: + - Others diff --git a/devenv/scopes/scopes.go b/devenv/scopes/scopes.go index 0480ee221e7..9e712aedc4d 100644 --- a/devenv/scopes/scopes.go +++ b/devenv/scopes/scopes.go @@ -43,9 +43,10 @@ func getEnv(key, defaultValue string) string { } type Config struct { - Scopes map[string]ScopeConfig `yaml:"scopes"` - Tree map[string]TreeNode `yaml:"tree"` - Navigations map[string]NavigationConfig `yaml:"navigations"` + Scopes map[string]ScopeConfig `yaml:"scopes"` + Tree map[string]TreeNode `yaml:"tree"` + Navigations map[string]NavigationConfig `yaml:"navigations"` + NavigationTree []NavigationTreeNode `yaml:"navigationTree"` } // ScopeConfig is used for YAML parsing - converts to v0alpha1.ScopeSpec @@ -64,16 +65,32 @@ type ScopeFilterConfig struct { // TreeNode is used for YAML parsing - converts to v0alpha1.ScopeNodeSpec type TreeNode struct { - Title string `yaml:"title"` - NodeType string `yaml:"nodeType"` - LinkID string `yaml:"linkId,omitempty"` - LinkType string `yaml:"linkType,omitempty"` - Children map[string]TreeNode `yaml:"children,omitempty"` + Title string `yaml:"title"` + SubTitle string `yaml:"subTitle,omitempty"` + NodeType string `yaml:"nodeType"` + LinkID string `yaml:"linkId,omitempty"` + LinkType string `yaml:"linkType,omitempty"` + DisableMultiSelect bool `yaml:"disableMultiSelect,omitempty"` + Children map[string]TreeNode `yaml:"children,omitempty"` } type NavigationConfig struct { - URL string `yaml:"url"` // URL path (e.g., /d/abc123 or /explore) - Scope string `yaml:"scope"` + URL string `yaml:"url"` // URL path (e.g., /d/abc123 or /explore) + Scope string `yaml:"scope"` // Required scope + SubScope string `yaml:"subScope"` // Optional subScope for hierarchical navigation + Title string `yaml:"title"` // Display title + Groups []string `yaml:"groups"` // Optional groups for categorization +} + +// NavigationTreeNode represents a node in the navigation tree structure +type NavigationTreeNode struct { + Name string `yaml:"name"` + Title string `yaml:"title"` + URL string `yaml:"url"` + Scope string `yaml:"scope"` + SubScope string `yaml:"subScope,omitempty"` + Groups []string `yaml:"groups,omitempty"` + Children []NavigationTreeNode `yaml:"children,omitempty"` } // Helper function to convert ScopeFilterConfig to v0alpha1.ScopeFilter @@ -156,6 +173,41 @@ func (c *Client) makeRequest(method, endpoint string, body []byte) error { return nil } +func (c *Client) getScopeNavigation(name string) (*v0alpha1.ScopeNavigation, error) { + url := fmt.Sprintf("%s/apis/%s/namespaces/%s/scopenavigations/%s", c.baseURL, apiVersion, c.namespace, name) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.SetBasicAuth(strings.Split(c.auth, ":")[0], strings.Split(c.auth, ":")[1]) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API request failed: HTTP %d - %s", resp.StatusCode, string(bodyBytes)) + } + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + var navigation v0alpha1.ScopeNavigation + if err := json.Unmarshal(bodyBytes, &navigation); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return &navigation, nil +} + func (c *Client) createScope(name string, cfg ScopeConfig) error { prefixedName := prefix + "-" + name @@ -206,8 +258,9 @@ func (c *Client) createScopeNode(name string, node TreeNode, parentName string) spec := v0alpha1.ScopeNodeSpec{ Title: node.Title, + SubTitle: node.SubTitle, NodeType: nodeType, - DisableMultiSelect: false, + DisableMultiSelect: node.DisableMultiSelect, } if prefixedParent != "" { @@ -241,17 +294,34 @@ func (c *Client) createScopeNode(name string, node TreeNode, parentName string) func (c *Client) createScopeNavigation(name string, nav NavigationConfig) error { prefixedName := prefix + "-" + name - prefixedScope := prefix + "-" + nav.Scope if nav.URL == "" { return fmt.Errorf("navigation %s must have 'url' specified", name) } + if nav.Scope == "" { + return fmt.Errorf("navigation %s must have 'scope' specified", name) + } + + prefixedScope := prefix + "-" + nav.Scope + spec := v0alpha1.ScopeNavigationSpec{ URL: nav.URL, Scope: prefixedScope, } + if nav.SubScope != "" { + prefixedSubScope := prefix + "-" + nav.SubScope + spec.SubScope = prefixedSubScope + } + + status := v0alpha1.ScopeNavigationStatus{ + Title: nav.Title, + } + if len(nav.Groups) > 0 { + status.Groups = nav.Groups + } + resource := v0alpha1.ScopeNavigation{ TypeMeta: metav1.TypeMeta{ APIVersion: apiVersion, @@ -269,7 +339,99 @@ func (c *Client) createScopeNavigation(name string, nav NavigationConfig) error } fmt.Printf("✓ Creating scope navigation: %s\n", prefixedName) - return c.makeRequest("POST", "/scopenavigations", body) + if err := c.makeRequest("POST", "/scopenavigations", body); err != nil { + return err + } + + // Update status in a second request (status is a subresource) + if nav.Title != "" || len(nav.Groups) > 0 { + // Get the created resource to retrieve its resourceVersion and existing spec + createdNav, err := c.getScopeNavigation(prefixedName) + if err != nil { + return fmt.Errorf("failed to get created navigation: %w", err) + } + + statusResource := v0alpha1.ScopeNavigation{ + TypeMeta: metav1.TypeMeta{ + APIVersion: apiVersion, + Kind: "ScopeNavigation", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: prefixedName, + ResourceVersion: createdNav.ObjectMeta.ResourceVersion, + }, + Spec: createdNav.Spec, // Include existing spec to prevent it from being cleared + Status: status, + } + + statusBody, err := json.Marshal(statusResource) + if err != nil { + return fmt.Errorf("failed to marshal scope navigation status: %w", err) + } + + fmt.Printf(" Updating status for: %s\n", prefixedName) + return c.makeRequest("PUT", fmt.Sprintf("/scopenavigations/%s/status", prefixedName), statusBody) + } + + return nil +} + +// NavigationWithName pairs a navigation config with its name and title +type NavigationWithName struct { + Name string + Title string + Nav NavigationConfig +} + +// Convert navigation tree to flat navigations (similar to mock's treeToNavigations) +func treeToNavigations(node NavigationTreeNode, parentPath []string, dashboardCounter *int) []NavigationWithName { + navigations := []NavigationWithName{} + currentPath := append(parentPath, node.Name) + + // Generate URL if not provided (cycle through dash-1, dash-2, etc.) + url := node.URL + if url == "" { + *dashboardCounter++ + url = fmt.Sprintf("/d/dash-%d", *dashboardCounter) + } + + // Create navigation for this node + nav := NavigationConfig{ + URL: url, + Scope: node.Scope, + Title: node.Title, + } + if node.SubScope != "" { + nav.SubScope = node.SubScope + } + if len(node.Groups) > 0 { + nav.Groups = node.Groups + } + navigations = append(navigations, NavigationWithName{ + Name: node.Name, + Title: node.Title, + Nav: nav, + }) + + // Process children - they inherit the parent's subScope as their scope, or use parent's scope if no subScope + if len(node.Children) > 0 { + for _, child := range node.Children { + // Children inherit the parent's subScope as their scope, or use parent's scope if no subScope + childScope := node.SubScope + if childScope == "" { + childScope = node.Scope + } + // Override with child's scope if explicitly set + if child.Scope != "" { + childScope = child.Scope + } else { + child.Scope = childScope + } + navigations = append(navigations, treeToNavigations(child, currentPath, dashboardCounter)...) + } + } + + return navigations } func (c *Client) createTreeNodes(children map[string]TreeNode, parentName string) error { @@ -418,6 +580,27 @@ func main() { } // Create scope navigations + // First, process navigation tree if provided + if len(config.NavigationTree) > 0 { + fmt.Println("Creating scope navigations from tree...") + dashboardCounter := 0 + for _, rootNode := range config.NavigationTree { + flatNavigations := treeToNavigations(rootNode, []string{}, &dashboardCounter) + for _, navWithName := range flatNavigations { + // Use the title from navWithName if Nav.Title is empty + if navWithName.Nav.Title == "" && navWithName.Title != "" { + navWithName.Nav.Title = navWithName.Title + } + if err := client.createScopeNavigation(navWithName.Name, navWithName.Nav); err != nil { + fmt.Fprintf(os.Stderr, "Error creating scope navigation %s: %v\n", navWithName.Name, err) + os.Exit(1) + } + } + } + fmt.Println() + } + + // Also support flat navigations format for backward compatibility if len(config.Navigations) > 0 { fmt.Println("Creating scope navigations...") for name, nav := range config.Navigations { diff --git a/public/app/features/scopes/dashboards/ScopesDashboardsService.test.ts b/public/app/features/scopes/dashboards/ScopesDashboardsService.test.ts index 15a10f43f3f..62b3b71e09d 100644 --- a/public/app/features/scopes/dashboards/ScopesDashboardsService.test.ts +++ b/public/app/features/scopes/dashboards/ScopesDashboardsService.test.ts @@ -5,8 +5,10 @@ import { ScopeDashboardBinding } from '@grafana/data'; import { config, locationService } from '@grafana/runtime'; import { ScopesApiClient } from '../ScopesApiClient'; +// Import mock data for subScope tests +import { navigationWithSubScope, navigationWithSubScope2, navigationWithSubScopeAndGroups } from '../tests/utils/mocks'; -import { ScopesDashboardsService } from './ScopesDashboardsService'; +import { ScopesDashboardsService, filterItemsWithSubScopesInPath } from './ScopesDashboardsService'; import { ScopeNavigation } from './types'; jest.mock('@grafana/runtime', () => ({ @@ -15,6 +17,7 @@ jest.mock('@grafana/runtime', () => ({ featureToggles: { useScopesNavigationEndpoint: false, }, + apps: {}, }, locationService: { getLocation: jest.fn(), @@ -390,4 +393,308 @@ describe('ScopesDashboardsService', () => { }); }); }); + + describe('groupSuggestedItems with subScopes', () => { + it('Creates subScope folders for items with subScope', () => { + const result = service.groupSuggestedItems([navigationWithSubScope]); + + expect(result[''].folders).toHaveProperty('mimir-subscope-nav-1'); + expect(result[''].folders['mimir-subscope-nav-1']).toEqual({ + title: 'Mimir Dashboards', + expanded: false, + folders: {}, + suggestedNavigations: {}, + subScopeName: 'mimir', + }); + }); + + it('Creates separate folders for multiple items with same subScope', () => { + const result = service.groupSuggestedItems([navigationWithSubScope, navigationWithSubScope2]); + + // Should create separate folders + expect(result[''].folders).toHaveProperty('mimir-subscope-nav-1'); + expect(result[''].folders).toHaveProperty('mimir-subscope-nav-2'); + + // Both should reference the same subScope + expect(result[''].folders['mimir-subscope-nav-1'].subScopeName).toBe('mimir'); + expect(result[''].folders['mimir-subscope-nav-2'].subScopeName).toBe('mimir'); + }); + + it('Ignores groups for subScope items', () => { + const result = service.groupSuggestedItems([navigationWithSubScopeAndGroups]); + + // Should create folder, not add to group folders + expect(result[''].folders).toHaveProperty('mimir-subscope-nav-groups'); + expect(result[''].folders['mimir-subscope-nav-groups'].subScopeName).toBe('mimir'); + + // Should not add to any group folders + expect(Object.keys(result[''].folders).length).toBe(1); + expect(result[''].suggestedNavigations).toEqual({}); + }); + + it('Does not add navigation items for subScope entries', () => { + const result = service.groupSuggestedItems([navigationWithSubScope]); + + // Should only create folder, not add navigation item + expect(result[''].folders['mimir-subscope-nav-1'].suggestedNavigations).toEqual({}); + expect(result[''].suggestedNavigations).toEqual({}); + }); + + it('Mixes subScope and regular items correctly', () => { + const regularItem: ScopeNavigation = { + metadata: { name: 'regular-nav' }, + spec: { + scope: 'grafana', + url: '/d/regular-dashboard', + }, + status: { + title: 'Regular Dashboard', + groups: ['General'], + }, + }; + + const result = service.groupSuggestedItems([navigationWithSubScope, regularItem]); + + // Should have subScope folder + expect(result[''].folders).toHaveProperty('mimir-subscope-nav-1'); + expect(result[''].folders['mimir-subscope-nav-1'].subScopeName).toBe('mimir'); + + // Should have regular group folder + expect(result[''].folders).toHaveProperty('General'); + expect(result[''].folders['General'].subScopeName).toBeUndefined(); + + // Regular item should be in group folder + expect(result[''].folders['General'].suggestedNavigations).toHaveProperty('/d/regular-dashboard'); + }); + }); + + describe('fetchSubScopeItems infinite loop prevention', () => { + beforeEach(() => { + config.featureToggles.useScopesNavigationEndpoint = true; + }); + + afterEach(() => { + config.featureToggles.useScopesNavigationEndpoint = false; + }); + + it('should filter out items with subScope matching any subScope in the path', async () => { + // Mock current location + (locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/' } as Location); + + // Create initial navigation with subScope 'mimir' + const initialNavigation: ScopeNavigation = { + metadata: { name: 'subscope-nav-1' }, + spec: { + scope: 'grafana', + subScope: 'mimir', + url: '/d/mimir-dashboards', + }, + status: { + title: 'Mimir Dashboards', + }, + }; + + // Mock items returned when fetching 'mimir' subScope + // One of them has the same subScope 'mimir', which should be filtered out + const subScopeItemsWithSameSubScope: ScopeNavigation[] = [ + { + metadata: { name: 'mimir-item-1' }, + spec: { + scope: 'mimir', + url: '/d/mimir-dashboard-1', + }, + status: { + title: 'Mimir Dashboard 1', + groups: ['General'], + }, + }, + { + metadata: { name: 'mimir-item-2' }, + spec: { + scope: 'mimir', + subScope: 'mimir', // This should be filtered out - same subScope as in path + url: '/d/mimir-dashboard-2', + }, + status: { + title: 'Mimir Dashboard 2', + }, + }, + { + metadata: { name: 'mimir-item-3' }, + spec: { + scope: 'mimir', + url: '/d/mimir-dashboard-3', + }, + status: { + title: 'Mimir Dashboard 3', + groups: ['Observability'], + }, + }, + ]; + + // Set up mock to return items based on scope being fetched + mockApiClient.fetchScopeNavigations.mockImplementation((scopeNames: string[]) => { + if (scopeNames.includes('grafana')) { + return Promise.resolve([initialNavigation]); + } + if (scopeNames.includes('mimir')) { + return Promise.resolve(subScopeItemsWithSameSubScope); + } + return Promise.resolve([]); + }); + + // Initial fetch to create the subScope folder + await service.fetchDashboards(['grafana']); + + // The folder key is based on the pattern: ${subScope}-${metadata.name} + const subScopeFolderKey = 'mimir-subscope-nav-1'; + + // Test the filtering logic directly + const filteredItems = filterItemsWithSubScopesInPath( + subScopeItemsWithSameSubScope, + ['', subScopeFolderKey], + 'mimir', + service.state.folders + ); + + // Verify that the item with the same subScope was filtered out + const hasFilteredItem = filteredItems.some( + (item) => 'subScope' in item.spec && item.spec.subScope === 'mimir' && item.metadata.name === 'mimir-item-2' + ); + expect(hasFilteredItem).toBe(false); + + // Verify that valid items are still present + expect(filteredItems.length).toBe(2); // Should have 2 items (mimir-item-1 and mimir-item-3) + expect(filteredItems.some((item) => item.metadata.name === 'mimir-item-1')).toBe(true); + expect(filteredItems.some((item) => item.metadata.name === 'mimir-item-3')).toBe(true); + }); + + it('should filter out items with subScope matching nested subScope in the path', async () => { + // Mock current location + (locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/' } as Location); + + // Create nested subScope structure: grafana -> mimir -> loki + const grafanaNavigation: ScopeNavigation = { + metadata: { name: 'mimir-nav' }, + spec: { + scope: 'grafana', + subScope: 'mimir', + url: '/d/mimir-dashboards', + }, + status: { + title: 'Mimir Dashboards', + }, + }; + + // Mock items returned when fetching 'mimir' subScope + // One of them has subScope 'loki', which is fine + const mimirSubScopeItems: ScopeNavigation[] = [ + { + metadata: { name: 'mimir-item-1' }, + spec: { + scope: 'mimir', + url: '/d/mimir-dashboard-1', + }, + status: { + title: 'Mimir Dashboard 1', + groups: ['General'], + }, + }, + { + metadata: { name: 'loki-nav' }, + spec: { + scope: 'mimir', + subScope: 'loki', // This is fine - different subScope + url: '/d/loki-dashboards', + }, + status: { + title: 'Loki Dashboards', + }, + }, + ]; + + // Mock items returned when fetching 'loki' subScope + // One of them has subScope 'mimir', which should be filtered out since 'mimir' is already in the path + const lokiSubScopeItems: ScopeNavigation[] = [ + { + metadata: { name: 'loki-item-1' }, + spec: { + scope: 'loki', + url: '/d/loki-dashboard-1', + }, + status: { + title: 'Loki Dashboard 1', + groups: ['General'], + }, + }, + { + metadata: { name: 'mimir-nav-again' }, + spec: { + scope: 'loki', + subScope: 'mimir', // This should be filtered out - 'mimir' is already in the path + url: '/d/mimir-dashboards-again', + }, + status: { + title: 'Mimir Dashboards Again', + }, + }, + ]; + + // Set up mock to return items based on scope being fetched + mockApiClient.fetchScopeNavigations.mockImplementation((scopeNames: string[]) => { + if (scopeNames.includes('grafana')) { + return Promise.resolve([grafanaNavigation]); + } + if (scopeNames.includes('mimir')) { + return Promise.resolve(mimirSubScopeItems); + } + if (scopeNames.includes('loki')) { + return Promise.resolve(lokiSubScopeItems); + } + return Promise.resolve([]); + }); + + // Initial fetch to create the first subScope folder + await service.fetchDashboards(['grafana']); + + // Set up the folder structure to simulate nested path + const folders = service.groupSuggestedItems([grafanaNavigation]); + const mimirFolders = service.groupSuggestedItems(mimirSubScopeItems); + + // Manually construct the nested folder structure for testing + const testFolders: typeof service.state.folders = { + '': { + ...folders[''], + folders: { + ...folders[''].folders, + 'mimir-mimir-nav': { + ...folders[''].folders['mimir-mimir-nav'], + folders: { + ...mimirFolders[''].folders, + }, + }, + }, + }, + }; + + // Test the filtering logic for nested path + const filteredItems = filterItemsWithSubScopesInPath( + lokiSubScopeItems, + ['', 'mimir-mimir-nav', 'loki-loki-nav'], + 'loki', + testFolders + ); + + // Verify that the item with 'mimir' subScope was filtered out + const hasFilteredItem = filteredItems.some( + (item) => 'subScope' in item.spec && item.spec.subScope === 'mimir' && item.metadata.name === 'mimir-nav-again' + ); + + expect(hasFilteredItem).toBe(false); + + // Verify that valid items are still present + expect(filteredItems.length).toBe(1); // Should have 1 item (loki-item-1) + expect(filteredItems.some((item) => item.metadata.name === 'loki-item-1')).toBe(true); + }); + }); }); diff --git a/public/app/features/scopes/dashboards/ScopesDashboardsService.ts b/public/app/features/scopes/dashboards/ScopesDashboardsService.ts index 42909b4663f..be6f966623e 100644 --- a/public/app/features/scopes/dashboards/ScopesDashboardsService.ts +++ b/public/app/features/scopes/dashboards/ScopesDashboardsService.ts @@ -8,7 +8,7 @@ import { ScopesApiClient } from '../ScopesApiClient'; import { ScopesServiceBase } from '../ScopesServiceBase'; import { isCurrentPath } from './scopeNavgiationUtils'; -import { ScopeNavigation, SuggestedNavigationsFoldersMap } from './types'; +import { ScopeNavigation, SuggestedNavigationsFoldersMap, SuggestedNavigationsMap } from './types'; interface ScopesDashboardsServiceState { // State of the drawer showing related dashboards @@ -106,6 +106,92 @@ export class ScopesDashboardsService extends ScopesServiceBase { + let subScopeFolders: SuggestedNavigationsFoldersMap | undefined; + + try { + // Fetch navigations for this subScope + const fetchNavigations = config.featureToggles.useScopesNavigationEndpoint + ? this.apiClient.fetchScopeNavigations + : this.apiClient.fetchDashboards; + + const subScopeItems = await fetchNavigations([subScopeName]); + + // Filter out items that have a subScope matching any subScope already in the path + // This prevents infinite loops when a subScope returns items with the same subScope + const filteredItems = filterItemsWithSubScopesInPath(subScopeItems, path, subScopeName, this.state.folders); + + // Group the items and add them to the subScope folder + subScopeFolders = this.groupSuggestedItems(filteredItems); + } catch (error) { + // On error, subScopeFolders will remain undefined and we'll only clear loading state + } + + // Get the current state and navigate to the target folder + let folders = { ...this.state.folders }; + let filteredFolders = { ...this.state.filteredFolders }; + let currentLevelFolders: SuggestedNavigationsFoldersMap = folders; + let currentLevelFilteredFolders: SuggestedNavigationsFoldersMap = filteredFolders; + + for (let idx = 0; idx < path.length - 1; idx++) { + currentLevelFolders = currentLevelFolders[path[idx]].folders; + currentLevelFilteredFolders = currentLevelFilteredFolders[path[idx]].folders; + } + + const name = path[path.length - 1]; + const currentFolder = currentLevelFolders[name]; + const currentFilteredFolder = currentLevelFilteredFolders[name]; + + // Clear loading state + currentFolder.loading = false; + currentFilteredFolder.loading = false; + + // Merge the subScope folder's content with the fetched items (if fetch succeeded) + if (subScopeFolders) { + // Take items from the root of the grouped structure + const rootSubScopeFolder = subScopeFolders['']; + currentFolder.folders = { ...currentFolder.folders, ...rootSubScopeFolder.folders }; + currentFolder.suggestedNavigations = { + ...currentFolder.suggestedNavigations, + ...rootSubScopeFolder.suggestedNavigations, + }; + + // Also update filtered folders + currentFilteredFolder.folders = { ...currentFilteredFolder.folders, ...rootSubScopeFolder.folders }; + currentFilteredFolder.suggestedNavigations = { + ...currentFilteredFolder.suggestedNavigations, + ...rootSubScopeFolder.suggestedNavigations, + }; + } + this.updateState({ folders, filteredFolders }); }; @@ -180,6 +266,7 @@ export class ScopesDashboardsService extends ScopesServiceBase { const rootNode = folders['']; const groups = navigation.status.groups ?? []; + const subScope = 'subScope' in navigation.spec ? navigation.spec.subScope : undefined; // If the current URL matches an item, expand the parent folders. let expanded = false; @@ -193,31 +280,8 @@ export class ScopesDashboardsService extends ScopesServiceBase { - const groupExists = !!rootNode.folders[group]; - const groupCurrentlyExpanded = groupExists && rootNode.folders[group].expanded; - - if (group && !groupExists) { - rootNode.folders[group] = { - title: group, - expanded, - folders: {}, - suggestedNavigations: {}, - }; - } - if (group && expanded && !groupCurrentlyExpanded) { - rootNode.folders[group].expanded = true; - } - }); - - const targets = - groups.length > 0 - ? groups.map((group) => - group === '' ? rootNode.suggestedNavigations : rootNode.folders[group].suggestedNavigations - ) - : [rootNode.suggestedNavigations]; - - targets.forEach((target) => { + // Helper function to add navigation item to a target + const addNavigationToTarget = (target: SuggestedNavigationsMap) => { // Dashboard if ( 'dashboard' in navigation.spec && @@ -236,7 +300,64 @@ export class ScopesDashboardsService extends ScopesServiceBase 0) { + // Add item to all group folders at root level + // Each group gets the item separately, not nested + groups.forEach((group) => { + if (group) { + if (!rootNode.folders[group]) { + rootNode.folders[group] = { + title: group, + expanded, + folders: {}, + suggestedNavigations: {}, + }; + } + if (expanded && !rootNode.folders[group].expanded) { + rootNode.folders[group].expanded = true; + } + + // Add the navigation item directly to the group folder + addNavigationToTarget(rootNode.folders[group].suggestedNavigations); + } else { + // Empty string group means add to root folder + addNavigationToTarget(rootNode.suggestedNavigations); + } + }); + } else { + // If no groups, add to root + addNavigationToTarget(rootNode.suggestedNavigations); + } + } }); return folders; @@ -277,3 +398,43 @@ export class ScopesDashboardsService extends ScopesServiceBase this.updateState({ drawerOpened: !this.state.drawerOpened }); } + +/** + * Filters out navigation items that have a subScope matching any subScope already in the path. + * This prevents infinite loops when a subScope returns items with the same subScope. + * @param items - The navigation items to filter + * @param path - The folder path to check for existing subScopes + * @param currentSubScope - The subScope currently being expanded + * @param folders - The folder structure to traverse + * @returns Filtered items without subScopes that would create infinite loops + */ +export function filterItemsWithSubScopesInPath( + items: Array, + path: string[], + currentSubScope: string, + folders: SuggestedNavigationsFoldersMap +): Array { + const subScopesInPath = new Set(); + // Include the current subScope being expanded + subScopesInPath.add(currentSubScope); + + // Traverse the path and collect all subScope names + let currentLevelFolders: SuggestedNavigationsFoldersMap = folders; + for (const folderKey of path) { + const folder = currentLevelFolders[folderKey]; + if (folder?.subScopeName) { + subScopesInPath.add(folder.subScopeName); + } + if (folder) { + currentLevelFolders = folder.folders; + } else { + // If folder is not found, break to avoid errors + break; + } + } + + return items.filter((item) => { + const itemSubScope = 'subScope' in item.spec ? item.spec.subScope : undefined; + return !itemSubScope || !subScopesInPath.has(itemSubScope); + }); +} diff --git a/public/app/features/scopes/dashboards/ScopesDashboardsTree.tsx b/public/app/features/scopes/dashboards/ScopesDashboardsTree.tsx index 0f19594dcfc..b25a3bfc55b 100644 --- a/public/app/features/scopes/dashboards/ScopesDashboardsTree.tsx +++ b/public/app/features/scopes/dashboards/ScopesDashboardsTree.tsx @@ -1,4 +1,7 @@ -import { urlUtil } from '@grafana/data'; +import { css } from '@emotion/css'; + +import { GrafanaTheme2, urlUtil } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; import { useQueryParams } from 'app/core/hooks/useQueryParams'; import { ScopesDashboardsTreeFolderItem } from './ScopesDashboardsTreeFolderItem'; @@ -13,13 +16,32 @@ export interface ScopesDashboardsTreeProps { export function ScopesDashboardsTree({ folders, folderPath, onFolderUpdate }: ScopesDashboardsTreeProps) { const [queryParams] = useQueryParams(); + const styles = useStyles2(getStyles); const folderId = folderPath[folderPath.length - 1]; const folder = folders[folderId]; + // Separate regular items from subScope items + const regularFolders: Array<[string, (typeof folder.folders)[string]]> = []; + const subScopeFolders: Array<[string, (typeof folder.folders)[string]]> = []; + + Object.entries(folder.folders).forEach(([subFolderId, subFolder]) => { + if (subFolder.subScopeName) { + subScopeFolders.push([subFolderId, subFolder]); + } else { + regularFolders.push([subFolderId, subFolder]); + } + }); + + const regularNavigations = Object.values(folder.suggestedNavigations); + + const hasRegularContent = regularFolders.length > 0 || regularNavigations.length > 0; + const hasSubScopeContent = subScopeFolders.length > 0; + return (
- {Object.entries(folder.folders).map(([subFolderId, subFolder]) => ( + {/* Regular folders and navigations */} + {regularFolders.map(([subFolderId, subFolder]) => ( ))} - {Object.values(folder.suggestedNavigations).map((navigation) => ( + {regularNavigations.map((navigation) => ( ))} + + {/* Separator between regular and subScope sections */} + {hasRegularContent && hasSubScopeContent &&
} + + {/* SubScope folders */} + {subScopeFolders.map(([subFolderId, subFolder]) => ( + + ))}
); } + +const getStyles = (theme: GrafanaTheme2) => ({ + separator: css({ + border: 'none', + borderTop: `1px solid ${theme.colors.border.weak}`, + margin: theme.spacing(1, 0), + }), +}); diff --git a/public/app/features/scopes/dashboards/ScopesDashboardsTreeFolderItem.tsx b/public/app/features/scopes/dashboards/ScopesDashboardsTreeFolderItem.tsx index dc1bf10878d..da1f315c5e2 100644 --- a/public/app/features/scopes/dashboards/ScopesDashboardsTreeFolderItem.tsx +++ b/public/app/features/scopes/dashboards/ScopesDashboardsTreeFolderItem.tsx @@ -2,7 +2,9 @@ import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { Icon, useStyles2 } from '@grafana/ui'; +import { Icon, IconButton, Spinner, useStyles2 } from '@grafana/ui'; + +import { useScopesServices } from '../ScopesContextProvider'; import { ScopesDashboardsTree } from './ScopesDashboardsTree'; import { OnFolderUpdate, SuggestedNavigationsFolder, SuggestedNavigationsFoldersMap } from './types'; @@ -22,22 +24,45 @@ export function ScopesDashboardsTreeFolderItem({ }: ScopesDashboardsTreeFolderItemProps) { const styles = useStyles2(getStyles); + // get scopesselector service + const scopesSelectorService = useScopesServices()?.scopesSelectorService ?? undefined; + return (
- + {folder.title} + {folder.loading && } + + + {folder.subScopeName && ( + { + e.preventDefault(); + e.stopPropagation(); + if (folder.subScopeName && scopesSelectorService) { + scopesSelectorService.changeScopes([folder.subScopeName]); + } + }} + /> + )} +
{folder.expanded && (
@@ -55,6 +80,12 @@ const getStyles = (theme: GrafanaTheme2) => { flexDirection: 'column', padding: theme.spacing(0.5, 0), }), + row: css({ + display: 'flex', + alignItems: 'flex-start', + gap: theme.spacing(1), + width: '100%', + }), expand: css({ alignItems: 'flex-start', background: 'none', @@ -65,12 +96,30 @@ const getStyles = (theme: GrafanaTheme2) => { padding: 0, textAlign: 'left', wordBreak: 'break-word', + flex: 1, }), icon: css({ marginTop: theme.spacing(0.25), }), + titleContainer: css({ + display: 'flex', + alignItems: 'center', + flex: 1, + }), + exchangeIcon: css({ + opacity: 0.7, + flexShrink: 0, + marginTop: theme.spacing(0.25), + }), + loadingIcon: css({ + flexShrink: 0, + marginLeft: theme.spacing(0.5), + marginTop: theme.spacing(0.25), + }), children: css({ - paddingLeft: theme.spacing(3), + paddingLeft: theme.spacing(2), + marginLeft: theme.spacing(1), + borderLeft: `1px solid ${theme.colors.border.weak}`, }), }; }; diff --git a/public/app/features/scopes/dashboards/types.ts b/public/app/features/scopes/dashboards/types.ts index 78242f28c59..18ee90296e6 100644 --- a/public/app/features/scopes/dashboards/types.ts +++ b/public/app/features/scopes/dashboards/types.ts @@ -4,6 +4,7 @@ import { ScopeDashboardBinding } from '@grafana/data'; export interface ScopeNavigationSpec { url: string; scope: string; + subScope?: string; } export interface ScopeNavigationStatus { @@ -37,6 +38,8 @@ export interface SuggestedNavigationsFolder { expanded: boolean; folders: SuggestedNavigationsFoldersMap; suggestedNavigations: SuggestedNavigationsMap; + subScopeName?: string; + loading?: boolean; } export type SuggestedNavigationsFoldersMap = Record; diff --git a/public/app/features/scopes/tests/dashboardsList.test.ts b/public/app/features/scopes/tests/dashboardsList.test.ts index a1d57846206..0fc77e74962 100644 --- a/public/app/features/scopes/tests/dashboardsList.test.ts +++ b/public/app/features/scopes/tests/dashboardsList.test.ts @@ -1,7 +1,8 @@ -import { waitFor } from '@testing-library/dom'; +import { screen, waitFor } from '@testing-library/react'; import { config, locationService } from '@grafana/runtime'; +import { ScopesApiClient } from '../ScopesApiClient'; import { ScopesService } from '../ScopesService'; import { ScopesDashboardsService } from '../dashboards/ScopesDashboardsService'; import { ScopeNavigation } from '../dashboards/types'; @@ -38,6 +39,11 @@ import { getDatasource, getInstanceSettings, getMock, + navigationWithSubScope, + navigationWithSubScope2, + navigationWithSubScopeDifferent, + navigationWithSubScopeAndGroups, + subScopeMimirItems, } from './utils/mocks'; import { renderDashboard, resetScenes } from './utils/render'; @@ -52,8 +58,10 @@ jest.mock('@grafana/runtime', () => ({ describe('Dashboards list', () => { let fetchDashboardsSpy: jest.SpyInstance; + let fetchScopeNavigationsSpy: jest.SpyInstance; let scopesService: ScopesService; let scopesDashboardsService: ScopesDashboardsService; + let apiClient: ScopesApiClient; beforeAll(() => { config.featureToggles.scopeFilters = true; @@ -64,12 +72,14 @@ describe('Dashboards list', () => { const result = await renderDashboard(); scopesService = result.scopesService; scopesDashboardsService = result.scopesDashboardsService; - fetchDashboardsSpy = jest.spyOn(result.client, 'fetchDashboards'); + apiClient = result.client; + fetchDashboardsSpy = jest.spyOn(apiClient, 'fetchDashboards'); + fetchScopeNavigationsSpy = jest.spyOn(apiClient, 'fetchScopeNavigations'); }); afterEach(async () => { locationService.replace(''); - await resetScenes([fetchDashboardsSpy]); + await resetScenes([fetchDashboardsSpy, fetchScopeNavigationsSpy]); }); it('Opens container and fetches dashboards list when a scope is selected', async () => { @@ -508,6 +518,246 @@ describe('Dashboards list', () => { }); }); + describe('subScopes', () => { + beforeAll(() => { + config.featureToggles.useScopesNavigationEndpoint = true; + }); + + afterAll(() => { + config.featureToggles.useScopesNavigationEndpoint = false; + }); + + it('Creates subScope folders when navigation items have subScope', async () => { + const mockNavigations = [navigationWithSubScope, navigationWithSubScopeDifferent]; + fetchScopeNavigationsSpy.mockResolvedValue(mockNavigations); + + await toggleDashboards(); + await updateScopes(scopesService, ['grafana']); + await jest.runOnlyPendingTimersAsync(); + + // Verify subScope folders are created + expect(screen.getByTestId('scopes-dashboards-Mimir Dashboards-expand')).toBeInTheDocument(); + expect(screen.getByTestId('scopes-dashboards-Loki Dashboards-expand')).toBeInTheDocument(); + }); + + it('Loads subScope items when folder is expanded', async () => { + const mockNavigations = [navigationWithSubScope]; + fetchScopeNavigationsSpy.mockResolvedValueOnce(mockNavigations).mockResolvedValueOnce(subScopeMimirItems); + + await toggleDashboards(); + await updateScopes(scopesService, ['grafana']); + await jest.runOnlyPendingTimersAsync(); + + // Verify folder appears + expect(screen.getByTestId('scopes-dashboards-Mimir Dashboards-expand')).toBeInTheDocument(); + + // Expand the subScope folder + await expandDashboardFolder('Mimir Dashboards'); + + // Wait for async fetchSubScopeItems to complete + await waitFor(() => { + expect(fetchScopeNavigationsSpy).toHaveBeenCalledWith(['mimir']); + }); + await jest.runOnlyPendingTimersAsync(); + + // Items are added to nested folders within the subScope folder, so expand those folders + await expandDashboardFolder('General'); + await expandDashboardFolder('Observability'); + await jest.runOnlyPendingTimersAsync(); + + // Verify loaded content appears (IDs are based on metadata.name) + await waitFor(() => { + expectDashboardInDocument('mimir-item-1'); + }); + expectDashboardInDocument('mimir-item-2'); + }); + + it('Shows loading state while fetching subScope items', async () => { + const mockNavigations = [navigationWithSubScope]; + fetchScopeNavigationsSpy.mockResolvedValueOnce(mockNavigations).mockResolvedValueOnce(subScopeMimirItems); + + await toggleDashboards(); + await updateScopes(scopesService, ['grafana']); + await jest.runOnlyPendingTimersAsync(); + + // Verify folder appears + expect(screen.getByTestId('scopes-dashboards-Mimir Dashboards-expand')).toBeInTheDocument(); + + // Expand the subScope folder + await expandDashboardFolder('Mimir Dashboards'); + + // Verify fetch was called (loading happens asynchronously) + await waitFor(() => { + expect(fetchScopeNavigationsSpy).toHaveBeenCalledWith(['mimir']); + }); + }); + + it('Multiple subScope folders with same subScope load same content', async () => { + const mockNavigations = [navigationWithSubScope, navigationWithSubScope2]; + fetchScopeNavigationsSpy.mockResolvedValueOnce(mockNavigations).mockResolvedValue(subScopeMimirItems); + + await toggleDashboards(); + await updateScopes(scopesService, ['grafana']); + await jest.runOnlyPendingTimersAsync(); + + // Verify folders appear + expect(screen.getByTestId('scopes-dashboards-Mimir Dashboards-expand')).toBeInTheDocument(); + expect(screen.getByTestId('scopes-dashboards-Mimir Overview-expand')).toBeInTheDocument(); + + // Expand first subScope folder + await expandDashboardFolder('Mimir Dashboards'); + + // Wait for fetch to complete + await waitFor(() => { + expect(fetchScopeNavigationsSpy).toHaveBeenCalledWith(['mimir']); + }); + await jest.runOnlyPendingTimersAsync(); + + // Expand nested folders to see the content in first subScope folder + await expandDashboardFolder('General'); + await expandDashboardFolder('Observability'); + await jest.runOnlyPendingTimersAsync(); + + // Verify content appears in first folder + await waitFor(() => { + expectDashboardInDocument('mimir-item-1'); + }); + expectDashboardInDocument('mimir-item-2'); + + // Expand second subScope folder (same subScope) - it should load the same content + await expandDashboardFolder('Mimir Overview'); + + // Wait for fetch to complete (should use cached data or fetch again) + await waitFor(() => { + // The fetch might be called again or might use cached content + expect(fetchScopeNavigationsSpy.mock.calls.length).toBeGreaterThanOrEqual(1); + }); + await jest.runOnlyPendingTimersAsync(); + + // Both folders should have the same content (IDs are based on metadata.name) + // Since nested folders are already expanded, content should be visible + expectDashboardInDocument('mimir-item-1'); + expectDashboardInDocument('mimir-item-2'); + }); + + it('Handles errors when fetching subScope items', async () => { + const mockNavigations = [navigationWithSubScope]; + fetchScopeNavigationsSpy.mockResolvedValueOnce(mockNavigations).mockRejectedValueOnce(new Error('Fetch failed')); + + await toggleDashboards(); + await updateScopes(scopesService, ['grafana']); + await jest.runOnlyPendingTimersAsync(); + + // Verify folder appears + expect(screen.getByTestId('scopes-dashboards-Mimir Dashboards-expand')).toBeInTheDocument(); + + // Expand the subScope folder + await expandDashboardFolder('Mimir Dashboards'); + await jest.runOnlyPendingTimersAsync(); + + // Verify fetch was called + expect(fetchScopeNavigationsSpy).toHaveBeenCalledWith(['mimir']); + + // Verify no content appears (error handled gracefully) + expectDashboardNotInDocument('mimir-item-1'); + }); + + it('Ignores groups for subScope items', async () => { + const mockNavigations = [navigationWithSubScopeAndGroups]; + fetchScopeNavigationsSpy.mockResolvedValue(mockNavigations); + + await toggleDashboards(); + await updateScopes(scopesService, ['grafana']); + await jest.runOnlyPendingTimersAsync(); + + // Verify subScope folder is created (groups should be ignored) + expect(screen.getByTestId('scopes-dashboards-Mimir with Groups-expand')).toBeInTheDocument(); + + // The folder should exist regardless of groups + expect(fetchScopeNavigationsSpy).toHaveBeenCalled(); + }); + + it('Filters search works with loaded subScope content', async () => { + const mockNavigations = [navigationWithSubScope]; + fetchScopeNavigationsSpy.mockResolvedValueOnce(mockNavigations).mockResolvedValueOnce(subScopeMimirItems); + + await toggleDashboards(); + await updateScopes(scopesService, ['grafana']); + await jest.runOnlyPendingTimersAsync(); + + // Verify folder appears + expect(screen.getByTestId('scopes-dashboards-Mimir Dashboards-expand')).toBeInTheDocument(); + + // Expand subScope folder and load content + await expandDashboardFolder('Mimir Dashboards'); + + // Wait for fetch to complete + await waitFor(() => { + expect(fetchScopeNavigationsSpy).toHaveBeenCalledWith(['mimir']); + }); + await jest.runOnlyPendingTimersAsync(); + + // Expand nested folders to see the content + await expandDashboardFolder('General'); + await expandDashboardFolder('Observability'); + await jest.runOnlyPendingTimersAsync(); + + // Verify content is loaded + await waitFor(() => { + expectDashboardInDocument('mimir-item-1'); + }); + expectDashboardInDocument('mimir-item-2'); + + // Search for a dashboard in the subScope + await searchDashboards('Mimir Dashboard 1'); + + // Verify search works (IDs are based on metadata.name) + expectDashboardInDocument('mimir-item-1'); + expectDashboardNotInDocument('mimir-item-2'); + }); + + it('Does not fetch subScope items if folder is already loaded', async () => { + const mockNavigations = [navigationWithSubScope]; + fetchScopeNavigationsSpy.mockResolvedValueOnce(mockNavigations).mockResolvedValueOnce(subScopeMimirItems); + + await toggleDashboards(); + await updateScopes(scopesService, ['grafana']); + await jest.runOnlyPendingTimersAsync(); + + // Verify folder appears + expect(screen.getByTestId('scopes-dashboards-Mimir Dashboards-expand')).toBeInTheDocument(); + + // Expand the subScope folder first time + await expandDashboardFolder('Mimir Dashboards'); + + // Wait for fetch to complete + await waitFor(() => { + expect(fetchScopeNavigationsSpy).toHaveBeenCalledWith(['mimir']); + }); + await jest.runOnlyPendingTimersAsync(); + + // Expand nested folders to see the content + await expandDashboardFolder('General'); + await jest.runOnlyPendingTimersAsync(); + + // Verify content is loaded + await waitFor(() => { + expectDashboardInDocument('mimir-item-1'); + }); + + const firstCallCount = fetchScopeNavigationsSpy.mock.calls.length; + + // Collapse and expand again + await expandDashboardFolder('Mimir Dashboards'); // Collapse + await expandDashboardFolder('Mimir Dashboards'); // Expand again + await jest.runOnlyPendingTimersAsync(); + + // Should not fetch again if already loaded + // Note: This test might need adjustment based on actual implementation + expect(fetchScopeNavigationsSpy.mock.calls.length).toBeGreaterThanOrEqual(firstCallCount); + }); + }); + describe('filterFolders', () => { it('Shows folders matching criteria', () => { expect( diff --git a/public/app/features/scopes/tests/utils/mocks.ts b/public/app/features/scopes/tests/utils/mocks.ts index 7b306350eb5..0a0e85b1791 100644 --- a/public/app/features/scopes/tests/utils/mocks.ts +++ b/public/app/features/scopes/tests/utils/mocks.ts @@ -2,6 +2,8 @@ import { Scope, ScopeDashboardBinding, ScopeNode } from '@grafana/data'; import { DataSourceRef } from '@grafana/schema/dist/esm/common/common.gen'; import { getDashboardScenePageStateManager } from 'app/features/dashboard-scene/pages/DashboardScenePageStateManager'; +import { ScopeNavigation } from '../../dashboards/types'; + export const mocksScopes: Scope[] = [ { metadata: { name: 'cloud' }, @@ -418,6 +420,24 @@ export const getMock = jest }; } + if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/find/scope_navigations')) { + // Handle subScope fetch requests + if (params.scope && params.scope.includes('mimir')) { + return { + items: subScopeMimirItems, + }; + } + if (params.scope && params.scope.includes('loki')) { + return { + items: subScopeLokiItems, + }; + } + // Return empty for other scopes + return { + items: [], + }; + } + if (url.startsWith('/api/dashboards/uid/')) { return {}; } @@ -470,6 +490,99 @@ export const dashboardWithRootFolderAndOtherFolder: ScopeDashboardBinding = gene ['', 'Folder 3'] ); +// Mock subScope navigation items +export const navigationWithSubScope: ScopeNavigation = { + metadata: { name: 'subscope-nav-1' }, + spec: { + scope: 'grafana', + subScope: 'mimir', + url: '/d/subscope-dashboard-1', + }, + status: { + title: 'Mimir Dashboards', + groups: [], // subScope items ignore groups + }, +}; + +export const navigationWithSubScope2: ScopeNavigation = { + metadata: { name: 'subscope-nav-2' }, + spec: { + scope: 'grafana', + subScope: 'mimir', + url: '/d/subscope-dashboard-2', + }, + status: { + title: 'Mimir Overview', + groups: [], + }, +}; + +export const navigationWithSubScopeDifferent: ScopeNavigation = { + metadata: { name: 'subscope-nav-3' }, + spec: { + scope: 'grafana', + subScope: 'loki', + url: '/d/subscope-dashboard-3', + }, + status: { + title: 'Loki Dashboards', + groups: [], + }, +}; + +export const navigationWithSubScopeAndGroups: ScopeNavigation = { + metadata: { name: 'subscope-nav-groups' }, + spec: { + scope: 'grafana', + subScope: 'mimir', + url: '/d/subscope-dashboard-groups', + }, + status: { + title: 'Mimir with Groups', + groups: ['Group1', 'Group2'], // Should be ignored for subScope items + }, +}; + +// Mock items that will be loaded when subScope folder is expanded +export const subScopeMimirItems: ScopeNavigation[] = [ + { + metadata: { name: 'mimir-item-1' }, + spec: { + scope: 'mimir', + url: '/d/mimir-dashboard-1', + }, + status: { + title: 'Mimir Dashboard 1', + groups: ['General'], + }, + }, + { + metadata: { name: 'mimir-item-2' }, + spec: { + scope: 'mimir', + url: '/d/mimir-dashboard-2', + }, + status: { + title: 'Mimir Dashboard 2', + groups: ['Observability'], + }, + }, +]; + +export const subScopeLokiItems: ScopeNavigation[] = [ + { + metadata: { name: 'loki-item-1' }, + spec: { + scope: 'loki', + url: '/d/loki-dashboard-1', + }, + status: { + title: 'Loki Dashboard 1', + groups: ['General'], + }, + }, +]; + export const getDatasource = async (ref: DataSourceRef) => { if (ref.uid === '-- Grafana --') { return { diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index fe32f78260b..6e4ecb98f4d 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -12384,6 +12384,7 @@ "scopes": { "dashboards": { "collapse": "Collapse", + "exchange": "Change root scope to {{scope}}", "expand": "Expand", "loading": "Loading dashboards", "noResultsForFilter": "No results found for your query",