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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user