Scopes: Nested scope navigations (#113394)
* Initial nested scope navigation * Seperate sections for scope navigation vs groups * Add ungrouped navigation items * Create tree structure helper for mock scope navigations * Improve generated folder structure and add link to change scope * Update mocks * DevEnv Navigations * Remove mocks * Update button position and add border * Remove subScope from TreeLink * Do unblocking async update * Add loading state * Allow '' for root groups * Formatting * Add unit and integration tests * Update openapi spec * Update tooltip for IconButton * Update i18n * Make code more DRY * Update public/app/dev-utils.ts * update folderUpdate type * Remove isSubScope in facvor of checking subScopeName * Do an early return * Use subScopeName * Remove use of isSubScope * Prevent nested items with the same subScope * Extract filder method to function --------- Co-authored-by: Mariell Hoversholm <mariell@mardroemmar.dev>
This commit is contained in:
co-authored by
Mariell Hoversholm
parent
b6d34acc44
commit
b79ace082c
@@ -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.
|
||||
|
||||
@@ -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"},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
+196
-13
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ScopesDashboardsS
|
||||
currentFolder.expanded = expanded;
|
||||
currentFilteredFolder.expanded = expanded;
|
||||
|
||||
// If expanding a subScope folder, fetch items for that subScope asynchronously
|
||||
if (expanded && currentFolder.subScopeName) {
|
||||
// Only fetch if folder is empty (hasn't been loaded yet)
|
||||
const isEmpty =
|
||||
Object.keys(currentFolder.folders).length === 0 && Object.keys(currentFolder.suggestedNavigations).length === 0;
|
||||
|
||||
if (!isEmpty) {
|
||||
// Folder already has content, skip fetching
|
||||
this.updateState({ folders, filteredFolders });
|
||||
return;
|
||||
}
|
||||
|
||||
// Set loading state for this folder
|
||||
currentFolder.loading = true;
|
||||
currentFilteredFolder.loading = true;
|
||||
// Extract the subScope name from the folder (stored when folder was created)
|
||||
const subScopeName = currentFolder.subScopeName || name;
|
||||
// Fetch asynchronously without blocking the state update
|
||||
this.fetchSubScopeItems(path, subScopeName);
|
||||
} else if (!expanded) {
|
||||
// Clear loading state when collapsing
|
||||
currentFolder.loading = false;
|
||||
currentFilteredFolder.loading = false;
|
||||
}
|
||||
|
||||
this.updateState({ folders, filteredFolders });
|
||||
};
|
||||
|
||||
private fetchSubScopeItems = async (path: string[], subScopeName: string) => {
|
||||
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<ScopesDashboardsS
|
||||
navigationItems.forEach((navigation) => {
|
||||
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<ScopesDashboardsS
|
||||
expanded = currentPath.startsWith(navigation.spec.url);
|
||||
}
|
||||
|
||||
groups.forEach((group) => {
|
||||
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<ScopesDashboardsS
|
||||
id: navigation.metadata.name,
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Items with subScope completely ignore groups and go to a separate section
|
||||
// They only create expandable folders, not navigation items
|
||||
// Multiple items with the same subScope create separate folders that reference the same subScope
|
||||
if (subScope) {
|
||||
const navigationTitle =
|
||||
('title' in navigation.status && navigation.status.title) ||
|
||||
('dashboardTitle' in navigation.status && navigation.status.dashboardTitle) ||
|
||||
navigation.metadata.name;
|
||||
|
||||
// Create a separate folder for each item, using a unique key
|
||||
// All folders with the same subScope will load the same content when expanded
|
||||
const folderKey = `${subScope}-${navigation.metadata.name}`;
|
||||
if (!rootNode.folders[folderKey]) {
|
||||
rootNode.folders[folderKey] = {
|
||||
title: navigationTitle,
|
||||
expanded,
|
||||
folders: {},
|
||||
suggestedNavigations: {},
|
||||
subScopeName: subScope,
|
||||
};
|
||||
}
|
||||
if (expanded && !rootNode.folders[folderKey].expanded) {
|
||||
rootNode.folders[folderKey].expanded = true;
|
||||
}
|
||||
// Don't add the navigation item - it only creates/updates the folder
|
||||
} else {
|
||||
// Items without subScope: add to group folders (if groups exist) or root
|
||||
if (groups.length > 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<ScopesDashboardsS
|
||||
|
||||
public toggleDrawer = () => 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<ScopeDashboardBinding | ScopeNavigation>,
|
||||
path: string[],
|
||||
currentSubScope: string,
|
||||
folders: SuggestedNavigationsFoldersMap
|
||||
): Array<ScopeDashboardBinding | ScopeNavigation> {
|
||||
const subScopesInPath = new Set<string>();
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div role="tree">
|
||||
{Object.entries(folder.folders).map(([subFolderId, subFolder]) => (
|
||||
{/* Regular folders and navigations */}
|
||||
{regularFolders.map(([subFolderId, subFolder]) => (
|
||||
<ScopesDashboardsTreeFolderItem
|
||||
key={subFolderId}
|
||||
folder={subFolder}
|
||||
@@ -28,7 +50,7 @@ export function ScopesDashboardsTree({ folders, folderPath, onFolderUpdate }: Sc
|
||||
onFolderUpdate={onFolderUpdate}
|
||||
/>
|
||||
))}
|
||||
{Object.values(folder.suggestedNavigations).map((navigation) => (
|
||||
{regularNavigations.map((navigation) => (
|
||||
<ScopesNavigationTreeLink
|
||||
key={navigation.id + navigation.title}
|
||||
to={urlUtil.renderUrl(navigation.url, queryParams)}
|
||||
@@ -36,6 +58,28 @@ export function ScopesDashboardsTree({ folders, folderPath, onFolderUpdate }: Sc
|
||||
id={navigation.id}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Separator between regular and subScope sections */}
|
||||
{hasRegularContent && hasSubScopeContent && <hr className={styles.separator} />}
|
||||
|
||||
{/* SubScope folders */}
|
||||
{subScopeFolders.map(([subFolderId, subFolder]) => (
|
||||
<ScopesDashboardsTreeFolderItem
|
||||
key={subFolderId}
|
||||
folder={subFolder}
|
||||
folders={folder.folders}
|
||||
folderPath={[...folderPath, subFolderId]}
|
||||
onFolderUpdate={onFolderUpdate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
separator: css({
|
||||
border: 'none',
|
||||
borderTop: `1px solid ${theme.colors.border.weak}`,
|
||||
margin: theme.spacing(1, 0),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<div className={styles.container} role="treeitem" aria-selected={folder.expanded}>
|
||||
<button
|
||||
className={styles.expand}
|
||||
data-testid={`scopes-dashboards-${folder.title}-expand`}
|
||||
aria-label={
|
||||
folder.expanded ? t('scopes.dashboards.collapse', 'Collapse') : t('scopes.dashboards.expand', 'Expand')
|
||||
}
|
||||
onClick={() => {
|
||||
onFolderUpdate(folderPath, !folder.expanded);
|
||||
}}
|
||||
>
|
||||
<Icon name={!folder.expanded ? 'angle-right' : 'angle-down'} className={styles.icon} />
|
||||
<div className={styles.row}>
|
||||
<button
|
||||
className={styles.expand}
|
||||
data-testid={`scopes-dashboards-${folder.title}-expand`}
|
||||
aria-label={
|
||||
folder.expanded ? t('scopes.dashboards.collapse', 'Collapse') : t('scopes.dashboards.expand', 'Expand')
|
||||
}
|
||||
onClick={() => {
|
||||
onFolderUpdate(folderPath, !folder.expanded);
|
||||
}}
|
||||
>
|
||||
<Icon name={!folder.expanded ? 'angle-right' : 'angle-down'} className={styles.icon} />
|
||||
|
||||
{folder.title}
|
||||
</button>
|
||||
<span className={styles.titleContainer}>{folder.title}</span>
|
||||
{folder.loading && <Spinner inline size="sm" className={styles.loadingIcon} />}
|
||||
</button>
|
||||
|
||||
{folder.subScopeName && (
|
||||
<IconButton
|
||||
className={styles.exchangeIcon}
|
||||
tooltip={t('scopes.dashboards.exchange', 'Change root scope to {{scope}}', {
|
||||
scope: folder.subScopeName || '',
|
||||
})}
|
||||
name="exchange-alt"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (folder.subScopeName && scopesSelectorService) {
|
||||
scopesSelectorService.changeScopes([folder.subScopeName]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{folder.expanded && (
|
||||
<div className={styles.children}>
|
||||
@@ -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}`,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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<string, SuggestedNavigationsFolder>;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user