Scopes: ScopesNavigation preload functionality (#115354)

* Add devenv configs

* Initial preload functionality

* Remove support for expandOnLoad

* Add tests

* Remove unnecessary go code
This commit is contained in:
Tobias Skarhed
2025-12-16 13:01:03 +01:00
committed by GitHub
parent fea972cb11
commit 18837682cc
5 changed files with 492 additions and 8 deletions
+2
View File
@@ -125,6 +125,7 @@ navigationTree:
url: /d/_5rDmaQiz
scope: shoe-org
subScope: shoes
preLoadSubScopeChildren: true
children:
- name: shoes-overview
title: Overview
@@ -141,6 +142,7 @@ navigationTree:
url: /d/edediimbjhdz4b
scope: shoes
subScope: frontend
preLoadSubScopeChildren: true
children:
- name: frontend-api
title: API Metrics
+10 -6
View File
@@ -83,6 +83,7 @@ type NavigationConfig struct {
Title string `yaml:"title"` // Display title
Groups []string `yaml:"groups"` // Optional groups for categorization
DisableSubScopeSelection bool `yaml:"disableSubScopeSelection"` // Makes the subscope not selectable
PreLoadSubScopeChildren bool `yaml:"preLoadSubScopeChildren"` // Preload children of subScope without updating UI
}
// NavigationTreeNode represents a node in the navigation tree structure
@@ -94,6 +95,7 @@ type NavigationTreeNode struct {
SubScope string `yaml:"subScope,omitempty"`
Groups []string `yaml:"groups,omitempty"`
DisableSubScopeSelection bool `yaml:"disableSubScopeSelection,omitempty"`
PreLoadSubScopeChildren bool `yaml:"preLoadSubScopeChildren,omitempty"` // Preload children of subScope without updating UI
Children []NavigationTreeNode `yaml:"children,omitempty"`
}
@@ -318,6 +320,7 @@ func (c *Client) createScopeNavigation(name string, nav NavigationConfig) error
URL: nav.URL,
Scope: prefixedScope,
DisableSubScopeSelection: nav.DisableSubScopeSelection,
PreLoadSubScopeChildren: nav.PreLoadSubScopeChildren,
}
if nav.SubScope != "" {
@@ -353,14 +356,14 @@ func (c *Client) createScopeNavigation(name string, nav NavigationConfig) error
return err
}
// Get the created resource to retrieve its resourceVersion for status update
createdNav, err := c.getScopeNavigation(prefixedName)
if err != nil {
return fmt.Errorf("failed to get created navigation: %w", 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,
@@ -411,6 +414,7 @@ func treeToNavigations(node NavigationTreeNode, parentPath []string, dashboardCo
Scope: node.Scope,
Title: node.Title,
DisableSubScopeSelection: node.DisableSubScopeSelection,
PreLoadSubScopeChildren: node.PreLoadSubScopeChildren,
}
if node.SubScope != "" {
nav.SubScope = node.SubScope
@@ -888,6 +888,454 @@ describe('ScopesDashboardsService', () => {
});
});
describe('preLoadSubScopeChildren', () => {
beforeEach(() => {
config.featureToggles.useScopesNavigationEndpoint = true;
(locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/' } as Location);
});
afterEach(() => {
config.featureToggles.useScopesNavigationEndpoint = false;
});
it('should set preLoadSubScopeChildren on folder when navigation has it set to true', async () => {
const mockNavigations: ScopeNavigation[] = [
{
spec: {
url: '/d/dashboard1',
scope: 'scope1',
subScope: 'subScope1',
preLoadSubScopeChildren: true,
},
status: {
title: 'Test Navigation',
},
metadata: {
name: 'nav1',
},
},
];
mockApiClient.fetchScopeNavigations.mockResolvedValue(mockNavigations);
await service.fetchDashboards(['scope1']);
const folderKey = Object.keys(service.state.folders[''].folders).find((key) => key.includes('subScope1'));
expect(folderKey).toBeDefined();
if (folderKey) {
const folder = service.state.folders[''].folders[folderKey];
expect(folder.preLoadSubScopeChildren).toBe(true);
expect(folder.subScopeName).toBe('subScope1');
}
});
it('should set preLoadSubScopeChildren to false when navigation has it set to false', async () => {
const mockNavigations: ScopeNavigation[] = [
{
spec: {
url: '/d/dashboard1',
scope: 'scope1',
subScope: 'subScope1',
preLoadSubScopeChildren: false,
},
status: {
title: 'Test Navigation',
},
metadata: {
name: 'nav1',
},
},
];
mockApiClient.fetchScopeNavigations.mockResolvedValue(mockNavigations);
await service.fetchDashboards(['scope1']);
const folderKey = Object.keys(service.state.folders[''].folders).find((key) => key.includes('subScope1'));
expect(folderKey).toBeDefined();
if (folderKey) {
const folder = service.state.folders[''].folders[folderKey];
expect(folder.preLoadSubScopeChildren).toBe(false);
}
});
it('should set preLoadSubScopeChildren to undefined when navigation does not have it', async () => {
const mockNavigations: ScopeNavigation[] = [
{
spec: {
url: '/d/dashboard1',
scope: 'scope1',
subScope: 'subScope1',
},
status: {
title: 'Test Navigation',
},
metadata: {
name: 'nav1',
},
},
];
mockApiClient.fetchScopeNavigations.mockResolvedValue(mockNavigations);
await service.fetchDashboards(['scope1']);
const folderKey = Object.keys(service.state.folders[''].folders).find((key) => key.includes('subScope1'));
expect(folderKey).toBeDefined();
if (folderKey) {
const folder = service.state.folders[''].folders[folderKey];
expect(folder.preLoadSubScopeChildren).toBeUndefined();
}
});
it('should automatically fetch subScope items for folders with preLoadSubScopeChildren set to true', async () => {
const mockNavigations: ScopeNavigation[] = [
{
spec: {
url: '/d/dashboard1',
scope: 'scope1',
subScope: 'mimir',
preLoadSubScopeChildren: true,
},
status: {
title: 'Mimir Dashboards',
},
metadata: {
name: 'nav1',
},
},
];
// Mock items returned when fetching 'mimir' subScope
const mimirItems: ScopeNavigation[] = [
{
metadata: { name: 'mimir-item-1' },
spec: {
scope: 'mimir',
url: '/d/mimir-dashboard-1',
},
status: {
title: 'Mimir Dashboard 1',
groups: ['General'],
},
},
];
mockApiClient.fetchScopeNavigations.mockImplementation((scopeNames: string[]) => {
if (scopeNames.includes('scope1')) {
return Promise.resolve(mockNavigations);
}
if (scopeNames.includes('mimir')) {
return Promise.resolve(mimirItems);
}
return Promise.resolve([]);
});
await service.fetchDashboards(['scope1']);
// Wait for the preload to complete
await new Promise((resolve) => setTimeout(resolve, 0));
// Verify that fetchScopeNavigations was called for the subScope
expect(mockApiClient.fetchScopeNavigations).toHaveBeenCalledWith(['mimir']);
// Verify the folder now has content from the preloaded items
const folderKey = Object.keys(service.state.folders[''].folders).find((key) => key.includes('mimir'));
expect(folderKey).toBeDefined();
if (folderKey) {
const folder = service.state.folders[''].folders[folderKey];
// The preloaded items should be in the folder
expect(folder.folders['General']).toBeDefined();
expect(folder.folders['General'].suggestedNavigations['/d/mimir-dashboard-1']).toBeDefined();
}
});
it('should not fetch subScope items for folders without preLoadSubScopeChildren', async () => {
const mockNavigations: ScopeNavigation[] = [
{
spec: {
url: '/d/dashboard1',
scope: 'scope1',
subScope: 'mimir',
// preLoadSubScopeChildren is not set
},
status: {
title: 'Mimir Dashboards',
},
metadata: {
name: 'nav1',
},
},
];
mockApiClient.fetchScopeNavigations.mockResolvedValue(mockNavigations);
await service.fetchDashboards(['scope1']);
// Wait to ensure no additional fetch happens
await new Promise((resolve) => setTimeout(resolve, 0));
// Verify that fetchScopeNavigations was only called once (for the initial fetch)
expect(mockApiClient.fetchScopeNavigations).toHaveBeenCalledTimes(1);
expect(mockApiClient.fetchScopeNavigations).toHaveBeenCalledWith(['scope1']);
});
it('should recursively preload nested folders with preLoadSubScopeChildren', async () => {
const mockNavigations: ScopeNavigation[] = [
{
spec: {
url: '/d/dashboard1',
scope: 'scope1',
subScope: 'level1',
preLoadSubScopeChildren: true,
},
status: {
title: 'Level 1 Folder',
},
metadata: {
name: 'nav1',
},
},
];
// Level 1 items include a folder with preLoadSubScopeChildren
const level1Items: ScopeNavigation[] = [
{
metadata: { name: 'level2-nav' },
spec: {
scope: 'level1',
subScope: 'level2',
url: '/d/level2-dashboard',
preLoadSubScopeChildren: true,
},
status: {
title: 'Level 2 Folder',
},
},
];
// Level 2 items
const level2Items: ScopeNavigation[] = [
{
metadata: { name: 'level2-item-1' },
spec: {
scope: 'level2',
url: '/d/level2-dashboard-1',
},
status: {
title: 'Level 2 Dashboard 1',
groups: ['Deep'],
},
},
];
mockApiClient.fetchScopeNavigations.mockImplementation((scopeNames: string[]) => {
if (scopeNames.includes('scope1')) {
return Promise.resolve(mockNavigations);
}
if (scopeNames.includes('level1')) {
return Promise.resolve(level1Items);
}
if (scopeNames.includes('level2')) {
return Promise.resolve(level2Items);
}
return Promise.resolve([]);
});
await service.fetchDashboards(['scope1']);
// Wait for all preloads to complete (need to wait for both levels)
await new Promise((resolve) => setTimeout(resolve, 10));
await new Promise((resolve) => setTimeout(resolve, 10));
// Verify all levels were fetched
expect(mockApiClient.fetchScopeNavigations).toHaveBeenCalledWith(['level1']);
expect(mockApiClient.fetchScopeNavigations).toHaveBeenCalledWith(['level2']);
});
it('should handle multiple folders with preLoadSubScopeChildren', async () => {
const mockNavigations: ScopeNavigation[] = [
{
spec: {
url: '/d/dashboard1',
scope: 'scope1',
subScope: 'mimir',
preLoadSubScopeChildren: true,
},
status: {
title: 'Mimir Dashboards',
},
metadata: {
name: 'nav1',
},
},
{
spec: {
url: '/d/dashboard2',
scope: 'scope1',
subScope: 'loki',
preLoadSubScopeChildren: true,
},
status: {
title: 'Loki Dashboards',
},
metadata: {
name: 'nav2',
},
},
];
const mimirItems: ScopeNavigation[] = [
{
metadata: { name: 'mimir-item-1' },
spec: { scope: 'mimir', url: '/d/mimir-dashboard-1' },
status: { title: 'Mimir Dashboard 1', groups: ['General'] },
},
];
const lokiItems: ScopeNavigation[] = [
{
metadata: { name: 'loki-item-1' },
spec: { scope: 'loki', url: '/d/loki-dashboard-1' },
status: { title: 'Loki Dashboard 1', groups: ['General'] },
},
];
mockApiClient.fetchScopeNavigations.mockImplementation((scopeNames: string[]) => {
if (scopeNames.includes('scope1')) {
return Promise.resolve(mockNavigations);
}
if (scopeNames.includes('mimir')) {
return Promise.resolve(mimirItems);
}
if (scopeNames.includes('loki')) {
return Promise.resolve(lokiItems);
}
return Promise.resolve([]);
});
await service.fetchDashboards(['scope1']);
// Wait for preloads to complete
await new Promise((resolve) => setTimeout(resolve, 0));
// Verify both subScopes were fetched
expect(mockApiClient.fetchScopeNavigations).toHaveBeenCalledWith(['mimir']);
expect(mockApiClient.fetchScopeNavigations).toHaveBeenCalledWith(['loki']);
});
it('should handle preload errors gracefully', async () => {
const mockNavigations: ScopeNavigation[] = [
{
spec: {
url: '/d/dashboard1',
scope: 'scope1',
subScope: 'failing-scope',
preLoadSubScopeChildren: true,
},
status: {
title: 'Failing Folder',
},
metadata: {
name: 'nav1',
},
},
];
mockApiClient.fetchScopeNavigations.mockImplementation((scopeNames: string[]) => {
if (scopeNames.includes('scope1')) {
return Promise.resolve(mockNavigations);
}
if (scopeNames.includes('failing-scope')) {
return Promise.reject(new Error('Network error'));
}
return Promise.resolve([]);
});
// Should not throw
await service.fetchDashboards(['scope1']);
// Wait for preload to attempt
await new Promise((resolve) => setTimeout(resolve, 0));
// Verify the folder was still created (even though preload failed)
const folderKey = Object.keys(service.state.folders[''].folders).find((key) => key.includes('failing-scope'));
expect(folderKey).toBeDefined();
});
it('should preload children after fetching subScope items when parent folder has items with preLoadSubScopeChildren', async () => {
const mockNavigations: ScopeNavigation[] = [
{
spec: {
url: '/d/dashboard1',
scope: 'scope1',
subScope: 'parent',
preLoadSubScopeChildren: true,
},
status: {
title: 'Parent Folder',
},
metadata: {
name: 'nav1',
},
},
];
// Parent items include a child with preLoadSubScopeChildren
const parentItems: ScopeNavigation[] = [
{
metadata: { name: 'child-nav' },
spec: {
scope: 'parent',
subScope: 'child',
url: '/d/child-dashboard',
preLoadSubScopeChildren: true,
},
status: {
title: 'Child Folder',
},
},
];
const childItems: ScopeNavigation[] = [
{
metadata: { name: 'child-item-1' },
spec: {
scope: 'child',
url: '/d/child-dashboard-1',
},
status: {
title: 'Child Dashboard 1',
groups: ['Nested'],
},
},
];
mockApiClient.fetchScopeNavigations.mockImplementation((scopeNames: string[]) => {
if (scopeNames.includes('scope1')) {
return Promise.resolve(mockNavigations);
}
if (scopeNames.includes('parent')) {
return Promise.resolve(parentItems);
}
if (scopeNames.includes('child')) {
return Promise.resolve(childItems);
}
return Promise.resolve([]);
});
await service.fetchDashboards(['scope1']);
// Wait for cascading preloads
await new Promise((resolve) => setTimeout(resolve, 20));
// Verify the chain of preloads occurred
expect(mockApiClient.fetchScopeNavigations).toHaveBeenCalledWith(['scope1']);
expect(mockApiClient.fetchScopeNavigations).toHaveBeenCalledWith(['parent']);
expect(mockApiClient.fetchScopeNavigations).toHaveBeenCalledWith(['child']);
});
});
describe('disableSubScopeSelection', () => {
it('should set disableSubScopeSelection on folder when navigation has it set to true', async () => {
const mockNavigations: ScopeNavigation[] = [
@@ -253,9 +253,14 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
...currentFilteredFolder.suggestedNavigations,
...rootSubScopeFolder.suggestedNavigations,
};
}
this.updateState({ folders, filteredFolders });
this.updateState({ folders, filteredFolders });
// Preload children for any newly added folders with preLoadSubScopeChildren
this.preloadSubScopeChildren(rootSubScopeFolder.folders, path);
} else {
this.updateState({ folders, filteredFolders });
}
};
// Helper to get a folder at a given path
@@ -316,6 +321,25 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
loading: false,
drawerOpened: res.length > 0,
});
// Preload children for folders with preLoadSubScopeChildren set
this.preloadSubScopeChildren(folders[''].folders, ['']);
}
};
/**
* Preloads children for folders that have preLoadSubScopeChildren set to true.
* This fetches the subScope items immediately when the navigation is first loaded,
* or when a parent subScope folder is fetched.
* @param foldersToCheck - The folders to check for preLoadSubScopeChildren
* @param basePath - The path to prepend when building the full path for each folder
*/
private preloadSubScopeChildren = (foldersToCheck: SuggestedNavigationsFoldersMap, basePath: string[]) => {
for (const [folderKey, folder] of Object.entries(foldersToCheck)) {
if (folder.preLoadSubScopeChildren && folder.subScopeName) {
const path = [...basePath, folderKey];
this.fetchSubScopeItems(path, folder.subScopeName);
}
}
};
@@ -391,6 +415,10 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
if ('disableSubScopeSelection' in navigation.spec) {
disableSubScopeSelection = navigation.spec.disableSubScopeSelection;
}
let preLoadSubScopeChildren: ScopeNavigationSpec['preLoadSubScopeChildren'] = undefined;
if ('preLoadSubScopeChildren' in navigation.spec) {
preLoadSubScopeChildren = navigation.spec.preLoadSubScopeChildren;
}
rootNode.folders[folderKey] = {
title: navigationTitle,
expanded,
@@ -398,6 +426,7 @@ export class ScopesDashboardsService extends ScopesServiceBase<ScopesDashboardsS
suggestedNavigations: {},
subScopeName: subScope,
disableSubScopeSelection,
preLoadSubScopeChildren,
};
}
if (expanded && !rootNode.folders[folderKey].expanded) {
@@ -44,6 +44,7 @@ export interface SuggestedNavigationsFolder {
subScopeName?: string;
loading?: boolean;
disableSubScopeSelection?: boolean;
preLoadSubScopeChildren?: boolean;
}
export type SuggestedNavigationsFoldersMap = Record<string, SuggestedNavigationsFolder>;