mirror of
https://github.com/grafana/grafana.git
synced 2025-12-21 12:04:45 +08:00
Compare commits
19 Commits
docs/add-a
...
eshields/s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f685893b8 | ||
|
|
2bf5047d37 | ||
|
|
0ac54b2d09 | ||
|
|
3b14940c7f | ||
|
|
2779905296 | ||
|
|
b2f8e1b4d1 | ||
|
|
fde846d8e5 | ||
|
|
43c43c98de | ||
|
|
e568a5859b | ||
|
|
ebc6980a57 | ||
|
|
0969179918 | ||
|
|
2eb3898c71 | ||
|
|
3bf27d926e | ||
|
|
862998a9cb | ||
|
|
4b34d8d8a0 | ||
|
|
e3ff80a38b | ||
|
|
7430af15e6 | ||
|
|
42cdd9a46b | ||
|
|
3cf45edc15 |
@@ -48,6 +48,23 @@ scopes:
|
|||||||
operator: equals
|
operator: equals
|
||||||
value: kids
|
value: kids
|
||||||
|
|
||||||
|
# This scope appears in multiple places in the tree.
|
||||||
|
# The defaultPath determines which path is shown when this scope is selected
|
||||||
|
# (e.g., from a URL or programmatically), even if another path also links to it.
|
||||||
|
shared-service:
|
||||||
|
title: Shared Service
|
||||||
|
# Path from the root node down to the direct scopeNode.
|
||||||
|
# Node names are hierarchical (parent-child), so use the full names.
|
||||||
|
# This points to: gdev-scopes > production > shared-service-prod
|
||||||
|
defaultPath:
|
||||||
|
- gdev-scopes
|
||||||
|
- gdev-scopes-production
|
||||||
|
- gdev-scopes-production-shared-service-prod
|
||||||
|
filters:
|
||||||
|
- key: service
|
||||||
|
operator: equals
|
||||||
|
value: shared
|
||||||
|
|
||||||
tree:
|
tree:
|
||||||
gdev-scopes:
|
gdev-scopes:
|
||||||
title: gdev-scopes
|
title: gdev-scopes
|
||||||
@@ -68,6 +85,13 @@ tree:
|
|||||||
nodeType: leaf
|
nodeType: leaf
|
||||||
linkId: app2
|
linkId: app2
|
||||||
linkType: scope
|
linkType: scope
|
||||||
|
# This node links to 'shared-service' scope.
|
||||||
|
# The scope's defaultPath points here (production > gdev-scopes).
|
||||||
|
shared-service-prod:
|
||||||
|
title: Shared Service
|
||||||
|
nodeType: leaf
|
||||||
|
linkId: shared-service
|
||||||
|
linkType: scope
|
||||||
test-cases:
|
test-cases:
|
||||||
title: Test cases
|
title: Test cases
|
||||||
nodeType: container
|
nodeType: container
|
||||||
@@ -83,6 +107,15 @@ tree:
|
|||||||
nodeType: leaf
|
nodeType: leaf
|
||||||
linkId: test-case-2
|
linkId: test-case-2
|
||||||
linkType: scope
|
linkType: scope
|
||||||
|
# This node also links to the same 'shared-service' scope.
|
||||||
|
# However, the scope's defaultPath points to the production path,
|
||||||
|
# so selecting this scope will expand the tree to production > shared-service-prod.
|
||||||
|
shared-service-test:
|
||||||
|
title: Shared Service (also in Production)
|
||||||
|
subTitle: defaultPath points to Production
|
||||||
|
nodeType: leaf
|
||||||
|
linkId: shared-service
|
||||||
|
linkType: scope
|
||||||
test-case-redirect:
|
test-case-redirect:
|
||||||
title: Test case with redirect
|
title: Test case with redirect
|
||||||
nodeType: leaf
|
nodeType: leaf
|
||||||
|
|||||||
@@ -51,8 +51,9 @@ type Config struct {
|
|||||||
|
|
||||||
// ScopeConfig is used for YAML parsing - converts to v0alpha1.ScopeSpec
|
// ScopeConfig is used for YAML parsing - converts to v0alpha1.ScopeSpec
|
||||||
type ScopeConfig struct {
|
type ScopeConfig struct {
|
||||||
Title string `yaml:"title"`
|
Title string `yaml:"title"`
|
||||||
Filters []ScopeFilterConfig `yaml:"filters"`
|
DefaultPath []string `yaml:"defaultPath,omitempty"`
|
||||||
|
Filters []ScopeFilterConfig `yaml:"filters"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScopeFilterConfig is used for YAML parsing - converts to v0alpha1.ScopeFilter
|
// ScopeFilterConfig is used for YAML parsing - converts to v0alpha1.ScopeFilter
|
||||||
@@ -116,9 +117,20 @@ func convertScopeSpec(cfg ScopeConfig) v0alpha1.ScopeSpec {
|
|||||||
for i, f := range cfg.Filters {
|
for i, f := range cfg.Filters {
|
||||||
filters[i] = convertFilter(f)
|
filters[i] = convertFilter(f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prefix defaultPath elements with the gdev prefix
|
||||||
|
var defaultPath []string
|
||||||
|
if len(cfg.DefaultPath) > 0 {
|
||||||
|
defaultPath = make([]string, len(cfg.DefaultPath))
|
||||||
|
for i, p := range cfg.DefaultPath {
|
||||||
|
defaultPath[i] = prefix + "-" + p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return v0alpha1.ScopeSpec{
|
return v0alpha1.ScopeSpec{
|
||||||
Title: cfg.Title,
|
Title: cfg.Title,
|
||||||
Filters: filters,
|
DefaultPath: defaultPath,
|
||||||
|
Filters: filters,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
362
public/app/features/scopes/ScopesApiClient.test.ts
Normal file
362
public/app/features/scopes/ScopesApiClient.test.ts
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
import { getBackendSrv, config } from '@grafana/runtime';
|
||||||
|
|
||||||
|
import { ScopesApiClient } from './ScopesApiClient';
|
||||||
|
|
||||||
|
// Mock the runtime dependencies
|
||||||
|
jest.mock('@grafana/runtime', () => ({
|
||||||
|
getBackendSrv: jest.fn(),
|
||||||
|
config: {
|
||||||
|
featureToggles: {
|
||||||
|
useMultipleScopeNodesEndpoint: true,
|
||||||
|
useScopeSingleNodeEndpoint: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@grafana/api-clients', () => ({
|
||||||
|
getAPIBaseURL: jest.fn().mockReturnValue('/apis/scope.grafana.app/v0alpha1'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('ScopesApiClient', () => {
|
||||||
|
let apiClient: ScopesApiClient;
|
||||||
|
let mockBackendSrv: jest.Mocked<{ get: jest.Mock }>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockBackendSrv = {
|
||||||
|
get: jest.fn(),
|
||||||
|
};
|
||||||
|
(getBackendSrv as jest.Mock).mockReturnValue(mockBackendSrv);
|
||||||
|
apiClient = new ScopesApiClient();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchMultipleScopeNodes', () => {
|
||||||
|
it('should fetch multiple nodes by names', async () => {
|
||||||
|
const mockNodes = [
|
||||||
|
{
|
||||||
|
metadata: { name: 'node-1' },
|
||||||
|
spec: { nodeType: 'container', title: 'Node 1', parentName: '' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metadata: { name: 'node-2' },
|
||||||
|
spec: { nodeType: 'leaf', title: 'Node 2', parentName: 'node-1' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockBackendSrv.get.mockResolvedValue({ items: mockNodes });
|
||||||
|
|
||||||
|
const result = await apiClient.fetchMultipleScopeNodes(['node-1', 'node-2']);
|
||||||
|
|
||||||
|
expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', {
|
||||||
|
names: ['node-1', 'node-2'],
|
||||||
|
});
|
||||||
|
expect(result).toEqual(mockNodes);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when names array is empty', async () => {
|
||||||
|
const result = await apiClient.fetchMultipleScopeNodes([]);
|
||||||
|
|
||||||
|
expect(mockBackendSrv.get).not.toHaveBeenCalled();
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when feature toggle is disabled', async () => {
|
||||||
|
config.featureToggles.useMultipleScopeNodesEndpoint = false;
|
||||||
|
|
||||||
|
const result = await apiClient.fetchMultipleScopeNodes(['node-1']);
|
||||||
|
|
||||||
|
expect(mockBackendSrv.get).not.toHaveBeenCalled();
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
|
||||||
|
// Restore feature toggle
|
||||||
|
config.featureToggles.useMultipleScopeNodesEndpoint = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle API errors gracefully', async () => {
|
||||||
|
mockBackendSrv.get.mockRejectedValue(new Error('Network error'));
|
||||||
|
|
||||||
|
const result = await apiClient.fetchMultipleScopeNodes(['node-1']);
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle response with no items field', async () => {
|
||||||
|
mockBackendSrv.get.mockResolvedValue({});
|
||||||
|
|
||||||
|
const result = await apiClient.fetchMultipleScopeNodes(['node-1']);
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle response with null items', async () => {
|
||||||
|
mockBackendSrv.get.mockResolvedValue({ items: null });
|
||||||
|
|
||||||
|
const result = await apiClient.fetchMultipleScopeNodes(['node-1']);
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle large arrays of node names', async () => {
|
||||||
|
const names = Array.from({ length: 100 }, (_, i) => `node-${i}`);
|
||||||
|
const mockNodes = names.map((name) => ({
|
||||||
|
metadata: { name },
|
||||||
|
spec: { nodeType: 'leaf', title: name, parentName: '' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
mockBackendSrv.get.mockResolvedValue({ items: mockNodes });
|
||||||
|
|
||||||
|
const result = await apiClient.fetchMultipleScopeNodes(names);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockNodes);
|
||||||
|
expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', {
|
||||||
|
names,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass through node names exactly as provided', async () => {
|
||||||
|
const names = ['node-with-special-chars_123', 'node.with.dots', 'node-with-dashes'];
|
||||||
|
mockBackendSrv.get.mockResolvedValue({ items: [] });
|
||||||
|
|
||||||
|
await apiClient.fetchMultipleScopeNodes(names);
|
||||||
|
|
||||||
|
expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', {
|
||||||
|
names,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchScopeNode', () => {
|
||||||
|
it('should fetch a single scope node by ID', async () => {
|
||||||
|
const mockNode = {
|
||||||
|
metadata: { name: 'test-node' },
|
||||||
|
spec: { nodeType: 'leaf', title: 'Test Node', parentName: 'parent' },
|
||||||
|
};
|
||||||
|
|
||||||
|
mockBackendSrv.get.mockResolvedValue(mockNode);
|
||||||
|
|
||||||
|
const result = await apiClient.fetchScopeNode('test-node');
|
||||||
|
|
||||||
|
expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/scopenodes/test-node');
|
||||||
|
expect(result).toEqual(mockNode);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined when feature toggle is disabled', async () => {
|
||||||
|
config.featureToggles.useScopeSingleNodeEndpoint = false;
|
||||||
|
|
||||||
|
const result = await apiClient.fetchScopeNode('test-node');
|
||||||
|
|
||||||
|
expect(mockBackendSrv.get).not.toHaveBeenCalled();
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
|
||||||
|
// Restore feature toggle
|
||||||
|
config.featureToggles.useScopeSingleNodeEndpoint = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined on API error', async () => {
|
||||||
|
mockBackendSrv.get.mockRejectedValue(new Error('Not found'));
|
||||||
|
|
||||||
|
const result = await apiClient.fetchScopeNode('non-existent');
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchNodes', () => {
|
||||||
|
it('should fetch nodes with parent filter', async () => {
|
||||||
|
const mockNodes = [
|
||||||
|
{
|
||||||
|
metadata: { name: 'child-1' },
|
||||||
|
spec: { nodeType: 'leaf', title: 'Child 1', parentName: 'parent' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockBackendSrv.get.mockResolvedValue({ items: mockNodes });
|
||||||
|
|
||||||
|
const result = await apiClient.fetchNodes({ parent: 'parent' });
|
||||||
|
|
||||||
|
expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', {
|
||||||
|
parent: 'parent',
|
||||||
|
query: undefined,
|
||||||
|
limit: 1000,
|
||||||
|
});
|
||||||
|
expect(result).toEqual(mockNodes);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fetch nodes with query filter', async () => {
|
||||||
|
const mockNodes = [
|
||||||
|
{
|
||||||
|
metadata: { name: 'matching-node' },
|
||||||
|
spec: { nodeType: 'leaf', title: 'Matching Node', parentName: '' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockBackendSrv.get.mockResolvedValue({ items: mockNodes });
|
||||||
|
|
||||||
|
const result = await apiClient.fetchNodes({ query: 'matching' });
|
||||||
|
|
||||||
|
expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', {
|
||||||
|
parent: undefined,
|
||||||
|
query: 'matching',
|
||||||
|
limit: 1000,
|
||||||
|
});
|
||||||
|
expect(result).toEqual(mockNodes);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect custom limit', async () => {
|
||||||
|
mockBackendSrv.get.mockResolvedValue({ items: [] });
|
||||||
|
|
||||||
|
await apiClient.fetchNodes({ limit: 50 });
|
||||||
|
|
||||||
|
expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', {
|
||||||
|
parent: undefined,
|
||||||
|
query: undefined,
|
||||||
|
limit: 50,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for invalid limit (too small)', async () => {
|
||||||
|
await expect(apiClient.fetchNodes({ limit: 0 })).rejects.toThrow('Limit must be between 1 and 10000');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for invalid limit (too large)', async () => {
|
||||||
|
await expect(apiClient.fetchNodes({ limit: 10001 })).rejects.toThrow('Limit must be between 1 and 10000');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use default limit of 1000 when not specified', async () => {
|
||||||
|
mockBackendSrv.get.mockResolvedValue({ items: [] });
|
||||||
|
|
||||||
|
await apiClient.fetchNodes({});
|
||||||
|
|
||||||
|
expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', {
|
||||||
|
parent: undefined,
|
||||||
|
query: undefined,
|
||||||
|
limit: 1000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array on API error', async () => {
|
||||||
|
mockBackendSrv.get.mockRejectedValue(new Error('API Error'));
|
||||||
|
|
||||||
|
const result = await apiClient.fetchNodes({ parent: 'test' });
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchScope', () => {
|
||||||
|
it('should fetch a scope by name', async () => {
|
||||||
|
const mockScope = {
|
||||||
|
metadata: { name: 'test-scope' },
|
||||||
|
spec: {
|
||||||
|
title: 'Test Scope',
|
||||||
|
filters: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockBackendSrv.get.mockResolvedValue(mockScope);
|
||||||
|
|
||||||
|
const result = await apiClient.fetchScope('test-scope');
|
||||||
|
|
||||||
|
expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/scopes/test-scope');
|
||||||
|
expect(result).toEqual(mockScope);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined on error', async () => {
|
||||||
|
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||||
|
mockBackendSrv.get.mockRejectedValue(new Error('Not found'));
|
||||||
|
|
||||||
|
const result = await apiClient.fetchScope('non-existent');
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log error to console', async () => {
|
||||||
|
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||||
|
const error = new Error('Not found');
|
||||||
|
mockBackendSrv.get.mockRejectedValue(error);
|
||||||
|
|
||||||
|
await apiClient.fetchScope('non-existent');
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(error);
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchMultipleScopes', () => {
|
||||||
|
it('should fetch multiple scopes in parallel', async () => {
|
||||||
|
const mockScopes = [
|
||||||
|
{
|
||||||
|
metadata: { name: 'scope-1' },
|
||||||
|
spec: { title: 'Scope 1', filters: [] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
metadata: { name: 'scope-2' },
|
||||||
|
spec: { title: 'Scope 2', filters: [] },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockBackendSrv.get.mockResolvedValueOnce(mockScopes[0]).mockResolvedValueOnce(mockScopes[1]);
|
||||||
|
|
||||||
|
const result = await apiClient.fetchMultipleScopes(['scope-1', 'scope-2']);
|
||||||
|
|
||||||
|
expect(mockBackendSrv.get).toHaveBeenCalledTimes(2);
|
||||||
|
expect(result).toEqual(mockScopes);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter out undefined scopes', async () => {
|
||||||
|
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||||
|
const mockScope = {
|
||||||
|
metadata: { name: 'scope-1' },
|
||||||
|
spec: { title: 'Scope 1', filters: [] },
|
||||||
|
};
|
||||||
|
|
||||||
|
mockBackendSrv.get.mockResolvedValueOnce(mockScope).mockRejectedValueOnce(new Error('Not found'));
|
||||||
|
|
||||||
|
const result = await apiClient.fetchMultipleScopes(['scope-1', 'non-existent']);
|
||||||
|
|
||||||
|
expect(result).toEqual([mockScope]);
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when no scopes provided', async () => {
|
||||||
|
const result = await apiClient.fetchMultipleScopes([]);
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
expect(mockBackendSrv.get).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('performance considerations', () => {
|
||||||
|
it('should make single batched request with fetchMultipleScopeNodes', async () => {
|
||||||
|
mockBackendSrv.get.mockResolvedValue({ items: [] });
|
||||||
|
|
||||||
|
await apiClient.fetchMultipleScopeNodes(['node-1', 'node-2', 'node-3', 'node-4', 'node-5']);
|
||||||
|
|
||||||
|
// Should make exactly 1 API call
|
||||||
|
expect(mockBackendSrv.get).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should make N sequential requests with fetchScopeNode (old pattern)', async () => {
|
||||||
|
mockBackendSrv.get.mockResolvedValue({
|
||||||
|
metadata: { name: 'test' },
|
||||||
|
spec: { nodeType: 'leaf', title: 'Test', parentName: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate old pattern of fetching nodes one by one
|
||||||
|
await Promise.all([
|
||||||
|
apiClient.fetchScopeNode('node-1'),
|
||||||
|
apiClient.fetchScopeNode('node-2'),
|
||||||
|
apiClient.fetchScopeNode('node-3'),
|
||||||
|
apiClient.fetchScopeNode('node-4'),
|
||||||
|
apiClient.fetchScopeNode('node-5'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Should make 5 separate API calls
|
||||||
|
expect(mockBackendSrv.get).toHaveBeenCalledTimes(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
301
public/app/features/scopes/TEST_PLAN.md
Normal file
301
public/app/features/scopes/TEST_PLAN.md
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
# Scopes Selector - Test Plan for defaultPath Implementation
|
||||||
|
|
||||||
|
This document outlines the comprehensive test coverage for implementing the `defaultPath` feature and refactoring path resolution logic.
|
||||||
|
|
||||||
|
## Test Files Created
|
||||||
|
|
||||||
|
### 1. `ScopesSelectorService.defaultPath.test.ts`
|
||||||
|
|
||||||
|
**Purpose**: Tests the core `defaultPath` functionality and integration with the service
|
||||||
|
|
||||||
|
**Coverage**:
|
||||||
|
|
||||||
|
- ✅ `getScopeNodes()` method
|
||||||
|
- Returns cached nodes without API calls
|
||||||
|
- Fetches only non-cached nodes (partial cache hits)
|
||||||
|
- Maintains order of requested nodes
|
||||||
|
- Updates state with fetched nodes
|
||||||
|
- Handles empty arrays and undefined nodes
|
||||||
|
|
||||||
|
- ✅ `resolvePathToRoot()` with `defaultPath`
|
||||||
|
- Uses `defaultPath` when available from scope metadata
|
||||||
|
- Falls back to recursive walking when no `scopeId` provided
|
||||||
|
- Falls back when scope exists but has no `defaultPath`
|
||||||
|
- Inserts path nodes into tree structure
|
||||||
|
- Handles errors gracefully
|
||||||
|
|
||||||
|
- ✅ `applyScopes()` with pre-fetching
|
||||||
|
- Pre-fetches all nodes from `defaultPath` when applying scopes
|
||||||
|
- Handles multiple scopes with different `defaultPath`s
|
||||||
|
- Deduplicates node IDs across multiple paths
|
||||||
|
- Skips fetching when no `defaultPath` defined
|
||||||
|
- Handles empty `defaultPath` arrays
|
||||||
|
|
||||||
|
- ✅ Selector opening with `defaultPath` expansion
|
||||||
|
- Expands tree to `defaultPath` when opening selector
|
||||||
|
- Falls back to `parentNodeId` when no `defaultPath`
|
||||||
|
- Handles cases where scope metadata isn't loaded yet
|
||||||
|
|
||||||
|
- ✅ Performance improvements
|
||||||
|
- Single API call for deep hierarchy with `defaultPath`
|
||||||
|
- Documents N API calls for old recursive behavior (baseline)
|
||||||
|
|
||||||
|
- ✅ Edge cases and error handling
|
||||||
|
- Handles `defaultPath` with missing nodes
|
||||||
|
- Handles API errors during batch fetch
|
||||||
|
- Deduplicates node IDs in `defaultPath`
|
||||||
|
- Handles `defaultPath` with only root node
|
||||||
|
|
||||||
|
- ✅ Backwards compatibility
|
||||||
|
- Works without providing `scopeId` to `resolvePathToRoot`
|
||||||
|
- Handles async scope loading
|
||||||
|
|
||||||
|
### 2. `ScopesSelectorService.pathHelpers.test.ts`
|
||||||
|
|
||||||
|
**Purpose**: Tests the refactored helper methods for path resolution
|
||||||
|
|
||||||
|
**Coverage**:
|
||||||
|
|
||||||
|
- ✅ `getPathForScope()` (new unified method)
|
||||||
|
- Prefers `defaultPath` from scope metadata
|
||||||
|
- Falls back to `scopeNodeId` when no `defaultPath`
|
||||||
|
- Returns empty array when both are undefined
|
||||||
|
- Handles scope not being in cache
|
||||||
|
|
||||||
|
- ✅ `getNodePath()` - optimized implementation
|
||||||
|
- Builds path from cached nodes without API calls
|
||||||
|
- Fetches missing nodes in the path
|
||||||
|
- Handles circular references gracefully
|
||||||
|
- Stops at root node (empty `parentName`)
|
||||||
|
|
||||||
|
- ✅ `expandToSelectedScope()` (new helper method)
|
||||||
|
- Expands tree to show selected scope path
|
||||||
|
- Does not expand when no scopes selected
|
||||||
|
- Loads children of the last node in path
|
||||||
|
- Handles errors gracefully during expansion
|
||||||
|
|
||||||
|
- ✅ Integration tests
|
||||||
|
- Full flow: resolve → insert → expand
|
||||||
|
- Uses cached nodes to avoid unnecessary API calls
|
||||||
|
|
||||||
|
- ✅ `getScopeNode()` caching behavior
|
||||||
|
- Returns cached node without API call
|
||||||
|
- Fetches and caches when not in cache
|
||||||
|
- Handles API errors gracefully
|
||||||
|
|
||||||
|
### 3. `ScopesApiClient.test.ts`
|
||||||
|
|
||||||
|
**Purpose**: Tests the API client methods, especially batch fetching
|
||||||
|
|
||||||
|
**Coverage**:
|
||||||
|
|
||||||
|
- ✅ `fetchMultipleScopeNodes()`
|
||||||
|
- Fetches multiple nodes by names
|
||||||
|
- Returns empty array when names array is empty
|
||||||
|
- Respects feature toggle
|
||||||
|
- Handles API errors gracefully
|
||||||
|
- Handles missing or null items in response
|
||||||
|
- Handles large arrays (100+ nodes)
|
||||||
|
- Passes through special characters in node names
|
||||||
|
|
||||||
|
- ✅ `fetchScopeNode()`
|
||||||
|
- Fetches single node by ID
|
||||||
|
- Respects feature toggle
|
||||||
|
- Returns undefined on error
|
||||||
|
|
||||||
|
- ✅ `fetchNodes()`
|
||||||
|
- Supports parent filter
|
||||||
|
- Supports query filter
|
||||||
|
- Respects custom limit
|
||||||
|
- Validates limit bounds (1-10000)
|
||||||
|
- Uses default limit of 1000
|
||||||
|
- Handles API errors
|
||||||
|
|
||||||
|
- ✅ `fetchScope()` and `fetchMultipleScopes()`
|
||||||
|
- Basic fetch operations
|
||||||
|
- Error handling
|
||||||
|
- Parallel fetching
|
||||||
|
- Filters undefined results
|
||||||
|
|
||||||
|
- ✅ Performance comparison
|
||||||
|
- Single batched request with `fetchMultipleScopeNodes`
|
||||||
|
- N sequential requests with `fetchScopeNode` (old pattern)
|
||||||
|
|
||||||
|
### 4. Existing Tests (Reference)
|
||||||
|
|
||||||
|
**File**: `ScopesSelectorService.test.ts` (existing, not modified)
|
||||||
|
|
||||||
|
- ✅ Select/deselect scope behavior
|
||||||
|
- ✅ Apply scopes and change scopes
|
||||||
|
- ✅ Open/close/apply selector
|
||||||
|
- ✅ Toggle and filter nodes
|
||||||
|
- ✅ Redirect behavior
|
||||||
|
- ✅ Recent scopes handling
|
||||||
|
- ✅ Navigation scope interaction
|
||||||
|
|
||||||
|
**File**: `scopesTreeUtils.test.ts` (existing, not modified)
|
||||||
|
|
||||||
|
- ✅ Tree manipulation utilities
|
||||||
|
- ✅ Path calculation
|
||||||
|
- ✅ Node expansion/collapse
|
||||||
|
|
||||||
|
## Test Scenarios Summary
|
||||||
|
|
||||||
|
### Happy Path Scenarios
|
||||||
|
|
||||||
|
1. **Basic defaultPath usage**: Scope has `defaultPath` → fetches all nodes in one call → expands tree
|
||||||
|
2. **Multiple scopes**: Multiple scopes with `defaultPath` → deduplicates and fetches all unique nodes
|
||||||
|
3. **Cached nodes**: Nodes already in cache → skips fetching → instant expansion
|
||||||
|
4. **Mixed cache state**: Some nodes cached, some not → fetches only missing ones
|
||||||
|
|
||||||
|
### Fallback Scenarios
|
||||||
|
|
||||||
|
1. **No defaultPath**: Scope has no `defaultPath` → falls back to recursive node walking
|
||||||
|
2. **No scope metadata**: Scope not loaded yet → falls back to node-based path
|
||||||
|
3. **Feature toggle disabled**: Toggle off → returns empty results safely
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
1. **Empty arrays**: Empty `defaultPath` or no nodes → handled gracefully
|
||||||
|
2. **Missing nodes**: API returns partial results → continues with what's available
|
||||||
|
3. **Circular references**: Node references itself/parent → detects and prevents infinite loops
|
||||||
|
4. **API failures**: Network errors → returns empty arrays, doesn't crash
|
||||||
|
5. **Large datasets**: 100+ nodes in path → handles efficiently
|
||||||
|
|
||||||
|
### Performance Tests
|
||||||
|
|
||||||
|
1. **Batch vs sequential**: Documents 1 API call (batch) vs N calls (sequential)
|
||||||
|
2. **Cache efficiency**: Verifies cached nodes don't trigger API calls
|
||||||
|
3. **Deduplication**: Multiple paths sharing nodes → fetches each node once
|
||||||
|
|
||||||
|
## Running the Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all scope-related tests
|
||||||
|
yarn test:frontend scopes
|
||||||
|
|
||||||
|
# Run specific test files
|
||||||
|
yarn test:frontend ScopesSelectorService.defaultPath.test.ts
|
||||||
|
yarn test:frontend ScopesSelectorService.pathHelpers.test.ts
|
||||||
|
yarn test:frontend ScopesApiClient.test.ts
|
||||||
|
|
||||||
|
# Run existing tests to ensure no regressions
|
||||||
|
yarn test:frontend ScopesSelectorService.test.ts
|
||||||
|
yarn test:frontend scopesTreeUtils.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coverage Goals
|
||||||
|
|
||||||
|
### Before Implementation
|
||||||
|
|
||||||
|
- [x] Comprehensive test coverage written
|
||||||
|
- [x] Edge cases identified and tested
|
||||||
|
- [x] Performance benchmarks documented
|
||||||
|
- [x] Backwards compatibility validated
|
||||||
|
|
||||||
|
### During Implementation
|
||||||
|
|
||||||
|
- [ ] All new tests passing
|
||||||
|
- [ ] Existing tests still passing (no regressions)
|
||||||
|
- [ ] Code coverage for new methods: >90%
|
||||||
|
|
||||||
|
### After Implementation
|
||||||
|
|
||||||
|
- [ ] Integration tests passing
|
||||||
|
- [ ] Manual testing with real data
|
||||||
|
- [ ] Performance validation (1 call vs N calls)
|
||||||
|
- [ ] Documentation updated
|
||||||
|
|
||||||
|
## Key Test Patterns Used
|
||||||
|
|
||||||
|
### 1. Mock Setup Pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
beforeEach(() => {
|
||||||
|
// Clear all mocks
|
||||||
|
// Setup consistent mock implementations
|
||||||
|
// Initialize service with mocks
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. State Verification Pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Given: Initial state
|
||||||
|
service.updateState({ ... });
|
||||||
|
|
||||||
|
// When: Action performed
|
||||||
|
await service.someMethod();
|
||||||
|
|
||||||
|
// Then: Verify state changes
|
||||||
|
expect(service.state.nodes).toEqual(...);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. API Call Verification Pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// When: Method that should make API call
|
||||||
|
await service.fetchSomething();
|
||||||
|
|
||||||
|
// Then: Verify correct API usage
|
||||||
|
expect(apiClient.method).toHaveBeenCalledWith(expectedParams);
|
||||||
|
expect(apiClient.method).toHaveBeenCalledTimes(1);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Error Handling Pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Given: API that will fail
|
||||||
|
mockApi.method.mockRejectedValue(new Error('...'));
|
||||||
|
|
||||||
|
// When: Method called
|
||||||
|
const result = await service.method();
|
||||||
|
|
||||||
|
// Then: Graceful handling
|
||||||
|
expect(result).toEqual(safeDefault);
|
||||||
|
expect(service.state).toBeConsistent();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes for Implementation
|
||||||
|
|
||||||
|
1. **Method Signatures**: Tests assume these new/modified methods:
|
||||||
|
- `getScopeNodes(names: string[]): Promise<ScopeNode[]>`
|
||||||
|
- `resolvePathToRoot(nodeId: string, tree: TreeNode, scopeId?: string)`
|
||||||
|
- Helper methods: `getPathForScope()`, `expandToSelectedScope()`
|
||||||
|
|
||||||
|
2. **Feature Toggles**: Tests verify feature toggle behavior:
|
||||||
|
- `useMultipleScopeNodesEndpoint` - for batch fetching
|
||||||
|
- `useScopeSingleNodeEndpoint` - for single node fetching
|
||||||
|
|
||||||
|
3. **Error Handling**: All new methods should:
|
||||||
|
- Return safe defaults (empty arrays, undefined)
|
||||||
|
- Log errors to console
|
||||||
|
- Not throw exceptions that crash the UI
|
||||||
|
|
||||||
|
4. **Caching Strategy**: Tests validate:
|
||||||
|
- Check cache before API calls
|
||||||
|
- Update cache after successful fetches
|
||||||
|
- Use stale cache if API fails
|
||||||
|
|
||||||
|
5. **Performance**: Key optimization:
|
||||||
|
- `defaultPath` → 1 API call (O(1))
|
||||||
|
- Recursive → N API calls (O(depth))
|
||||||
|
- For 5-level hierarchy: 5x performance improvement
|
||||||
|
|
||||||
|
## Test Data Hierarchy
|
||||||
|
|
||||||
|
Tests use a realistic hierarchy:
|
||||||
|
|
||||||
|
```
|
||||||
|
Region (region-us-west)
|
||||||
|
└── Country (country-usa)
|
||||||
|
└── City (city-seattle)
|
||||||
|
└── Datacenter (datacenter-sea-1) [scope-sea-1]
|
||||||
|
```
|
||||||
|
|
||||||
|
This represents a typical organizational structure and exercises:
|
||||||
|
|
||||||
|
- Multiple levels of nesting
|
||||||
|
- Container and leaf nodes
|
||||||
|
- Scope linking at leaf level
|
||||||
|
- Real-world naming patterns
|
||||||
@@ -99,16 +99,28 @@ export function ScopesInput({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const getScopesPath = (appliedScopes: SelectedScope[], nodes: NodesMap) => {
|
const getScopesPath = (appliedScopes: SelectedScope[], nodes: NodesMap, scopes: ScopesMap) => {
|
||||||
let nicePath: string[] | undefined;
|
let nicePath: string[] | undefined;
|
||||||
|
|
||||||
if (appliedScopes.length > 0 && appliedScopes[0].scopeNodeId) {
|
if (appliedScopes.length > 0) {
|
||||||
let path = getPathOfNode(appliedScopes[0].scopeNodeId, nodes);
|
const firstScope = appliedScopes[0];
|
||||||
// Get reed of empty root section and the actual scope node
|
const scope = scopes[firstScope.scopeId];
|
||||||
path = path.slice(1, -1);
|
|
||||||
|
|
||||||
// We may not have all the nodes in path loaded
|
// Prefer defaultPath from scope metadata
|
||||||
nicePath = path.map((p) => nodes[p]?.spec.title).filter((p) => p);
|
if (scope?.spec.defaultPath && scope.spec.defaultPath.length > 1) {
|
||||||
|
// Get all nodes except the last one (which is the scope itself)
|
||||||
|
const pathNodeIds = scope.spec.defaultPath.slice(0, -1);
|
||||||
|
nicePath = pathNodeIds.map((nodeId) => nodes[nodeId]?.spec.title).filter((title) => title);
|
||||||
|
}
|
||||||
|
// Fallback to walking the node tree
|
||||||
|
else if (firstScope.scopeNodeId) {
|
||||||
|
let path = getPathOfNode(firstScope.scopeNodeId, nodes);
|
||||||
|
// Get rid of empty root section and the actual scope node
|
||||||
|
path = path.slice(1, -1);
|
||||||
|
|
||||||
|
// We may not have all the nodes in path loaded
|
||||||
|
nicePath = path.map((p) => nodes[p]?.spec.title).filter((p) => p);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nicePath;
|
return nicePath;
|
||||||
@@ -127,7 +139,7 @@ function ScopesTooltip({ nodes, scopes, appliedScopes, onRemoveAllClick, disable
|
|||||||
return t('scopes.selector.input.tooltip', 'Select scope');
|
return t('scopes.selector.input.tooltip', 'Select scope');
|
||||||
}
|
}
|
||||||
|
|
||||||
const nicePath = getScopesPath(appliedScopes, nodes);
|
const nicePath = getScopesPath(appliedScopes, nodes, scopes);
|
||||||
const scopeNames = appliedScopes.map((s) => {
|
const scopeNames = appliedScopes.map((s) => {
|
||||||
if (s.scopeNodeId) {
|
if (s.scopeNodeId) {
|
||||||
return nodes[s.scopeNodeId]?.spec.title || s.scopeNodeId;
|
return nodes[s.scopeNodeId]?.spec.title || s.scopeNodeId;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@ import {
|
|||||||
treeNodeAtPath,
|
treeNodeAtPath,
|
||||||
} from './scopesTreeUtils';
|
} from './scopesTreeUtils';
|
||||||
import { NodesMap, RecentScope, RecentScopeSchema, ScopeSchema, ScopesMap, SelectedScope, TreeNode } from './types';
|
import { NodesMap, RecentScope, RecentScopeSchema, ScopeSchema, ScopesMap, SelectedScope, TreeNode } from './types';
|
||||||
|
|
||||||
export const RECENT_SCOPES_KEY = 'grafana.scopes.recent';
|
export const RECENT_SCOPES_KEY = 'grafana.scopes.recent';
|
||||||
|
|
||||||
export interface ScopesSelectorServiceState {
|
export interface ScopesSelectorServiceState {
|
||||||
@@ -101,22 +102,68 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private getNodePath = async (scopeNodeId: string): Promise<ScopeNode[]> => {
|
private getNodePath = async (scopeNodeId: string, visited: Set<string> = new Set()): Promise<ScopeNode[]> => {
|
||||||
|
// Protect against circular references
|
||||||
|
if (visited.has(scopeNodeId)) {
|
||||||
|
console.error('Circular reference detected in node path', scopeNodeId);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const node = await this.getScopeNode(scopeNodeId);
|
const node = await this.getScopeNode(scopeNodeId);
|
||||||
if (!node) {
|
if (!node) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add current node to visited set
|
||||||
|
const newVisited = new Set(visited);
|
||||||
|
newVisited.add(scopeNodeId);
|
||||||
|
|
||||||
const parentPath =
|
const parentPath =
|
||||||
node.spec.parentName && node.spec.parentName !== '' ? await this.getNodePath(node.spec.parentName) : [];
|
node.spec.parentName && node.spec.parentName !== ''
|
||||||
|
? await this.getNodePath(node.spec.parentName, newVisited)
|
||||||
|
: [];
|
||||||
|
|
||||||
return [...parentPath, node];
|
return [...parentPath, node];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines the path to a scope node, preferring defaultPath from scope metadata.
|
||||||
|
* This is the single source of truth for path resolution.
|
||||||
|
* @param scopeId - The scope ID to get the path for
|
||||||
|
* @param scopeNodeId - Optional scope node ID to fall back to if no defaultPath
|
||||||
|
* @returns Promise resolving to array of ScopeNode objects representing the path
|
||||||
|
*/
|
||||||
|
private async getPathForScope(scopeId: string, scopeNodeId?: string): Promise<ScopeNode[]> {
|
||||||
|
// 1. Check if scope has defaultPath (preferred method)
|
||||||
|
const scope = this.state.scopes[scopeId];
|
||||||
|
if (scope?.spec.defaultPath && scope.spec.defaultPath.length > 0) {
|
||||||
|
// Batch fetch all nodes in defaultPath
|
||||||
|
return await this.getScopeNodes(scope.spec.defaultPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fall back to calculating path from scopeNodeId
|
||||||
|
if (scopeNodeId) {
|
||||||
|
return await this.getNodePath(scopeNodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
public resolvePathToRoot = async (
|
public resolvePathToRoot = async (
|
||||||
scopeNodeId: string,
|
scopeNodeId: string,
|
||||||
tree: TreeNode
|
tree: TreeNode,
|
||||||
|
scopeId?: string
|
||||||
): Promise<{ path: ScopeNode[]; tree: TreeNode }> => {
|
): Promise<{ path: ScopeNode[]; tree: TreeNode }> => {
|
||||||
const nodePath = await this.getNodePath(scopeNodeId);
|
let nodePath: ScopeNode[];
|
||||||
|
|
||||||
|
// Use unified path resolution method
|
||||||
|
if (scopeId) {
|
||||||
|
nodePath = await this.getPathForScope(scopeId, scopeNodeId);
|
||||||
|
} else {
|
||||||
|
// Fall back to node-based path when no scopeId provided
|
||||||
|
nodePath = await this.getNodePath(scopeNodeId);
|
||||||
|
}
|
||||||
|
|
||||||
const newTree = insertPathNodesIntoTree(tree, nodePath);
|
const newTree = insertPathNodesIntoTree(tree, nodePath);
|
||||||
|
|
||||||
this.updateState({ tree: newTree });
|
this.updateState({ tree: newTree });
|
||||||
@@ -207,16 +254,39 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
|
|||||||
}
|
}
|
||||||
|
|
||||||
const newTree = modifyTreeNodeAtPath(this.state.tree, path, (treeNode) => {
|
const newTree = modifyTreeNodeAtPath(this.state.tree, path, (treeNode) => {
|
||||||
// Set parent query only when filtering within existing children
|
// Preserve existing children that have nested structure (from insertPathNodesIntoTree)
|
||||||
treeNode.children = {};
|
const existingChildren = treeNode.children || {};
|
||||||
|
const childrenToPreserve: Record<string, TreeNode> = {};
|
||||||
|
|
||||||
|
// Keep children that have a children property (object, not undefined)
|
||||||
|
// This includes both empty objects {} (from path insertion) and populated ones
|
||||||
|
for (const [key, child] of Object.entries(existingChildren)) {
|
||||||
|
// Preserve if children is an object (not undefined)
|
||||||
|
if (child.children !== undefined && typeof child.children === 'object') {
|
||||||
|
childrenToPreserve[key] = child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start with preserved children, then add/update with fetched children
|
||||||
|
treeNode.children = { ...childrenToPreserve };
|
||||||
|
|
||||||
for (const node of childNodes) {
|
for (const node of childNodes) {
|
||||||
treeNode.children[node.metadata.name] = {
|
// If this child was preserved, merge with fetched data
|
||||||
expanded: false,
|
if (childrenToPreserve[node.metadata.name]) {
|
||||||
scopeNodeId: node.metadata.name,
|
treeNode.children[node.metadata.name] = {
|
||||||
// Only set query on tree nodes if parent already has children (filtering vs first expansion). This is used for saerch highlighting.
|
...childrenToPreserve[node.metadata.name],
|
||||||
query: query || '',
|
// Update query but keep nested children
|
||||||
children: undefined,
|
query: query || '',
|
||||||
};
|
};
|
||||||
|
} else {
|
||||||
|
// New child from API
|
||||||
|
treeNode.children[node.metadata.name] = {
|
||||||
|
expanded: false,
|
||||||
|
scopeNodeId: node.metadata.name,
|
||||||
|
query: query || '',
|
||||||
|
children: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Set loaded to true if node is a container
|
// Set loaded to true if node is a container
|
||||||
treeNode.childrenLoaded = true;
|
treeNode.childrenLoaded = true;
|
||||||
@@ -356,16 +426,52 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
|
|||||||
}
|
}
|
||||||
|
|
||||||
const newScopesState = { ...this.state.scopes };
|
const newScopesState = { ...this.state.scopes };
|
||||||
for (const scope of fetchedScopes) {
|
|
||||||
newScopesState[scope.metadata.name] = scope;
|
// Handle case where API returns non-array
|
||||||
|
if (Array.isArray(fetchedScopes)) {
|
||||||
|
for (const scope of fetchedScopes) {
|
||||||
|
newScopesState[scope.metadata.name] = scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-fetch the first scope's defaultPath to improve performance
|
||||||
|
// This makes the selector open instantly since all nodes are already cached
|
||||||
|
// We only need the first scope since that's what's used for expansion
|
||||||
|
const firstScope = fetchedScopes[0];
|
||||||
|
if (firstScope?.spec.defaultPath && firstScope.spec.defaultPath.length > 0) {
|
||||||
|
// Deduplicate and filter out already cached nodes
|
||||||
|
const uniqueNodeIds = [...new Set(firstScope.spec.defaultPath)];
|
||||||
|
const nodesToFetch = uniqueNodeIds.filter((nodeId) => !this.state.nodes[nodeId]);
|
||||||
|
|
||||||
|
if (nodesToFetch.length > 0) {
|
||||||
|
await this.getScopeNodes(nodesToFetch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get scopeNode and parentNode, preferring defaultPath as the source of truth
|
||||||
|
let scopeNode: ScopeNode | undefined;
|
||||||
|
let parentNode: ScopeNode | undefined;
|
||||||
|
let scopeNodeId: string | undefined;
|
||||||
|
|
||||||
|
if (firstScope?.spec.defaultPath && firstScope.spec.defaultPath.length > 1) {
|
||||||
|
// Extract from defaultPath (most reliable source)
|
||||||
|
// defaultPath format: ['', 'parent-id', 'scope-node-id', ...]
|
||||||
|
scopeNodeId = firstScope.spec.defaultPath[firstScope.spec.defaultPath.length - 1];
|
||||||
|
const parentNodeId = firstScope.spec.defaultPath[firstScope.spec.defaultPath.length - 2];
|
||||||
|
|
||||||
|
scopeNode = scopeNodeId ? this.state.nodes[scopeNodeId] : undefined;
|
||||||
|
parentNode = parentNodeId && parentNodeId !== '' ? this.state.nodes[parentNodeId] : undefined;
|
||||||
|
} else {
|
||||||
|
// Fallback to old approach for backwards compatibility
|
||||||
|
scopeNodeId = scopes[0]?.scopeNodeId;
|
||||||
|
scopeNode = scopeNodeId ? this.state.nodes[scopeNodeId] : undefined;
|
||||||
|
|
||||||
|
const parentNodeId = scopes[0]?.parentNodeId ?? scopeNode?.spec.parentName;
|
||||||
|
parentNode = parentNodeId ? this.state.nodes[parentNodeId] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.addRecentScopes(fetchedScopes, parentNode, scopeNodeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not provided, try to get the parent from the scope node
|
|
||||||
// When selected from recent scopes, we don't have access to the scope node (if it hasn't been loaded), but we do have access to the parent node from local storage.
|
|
||||||
const parentNodeId = scopes[0]?.parentNodeId ?? scopeNode?.spec.parentName;
|
|
||||||
const parentNode = parentNodeId ? this.state.nodes[parentNodeId] : undefined;
|
|
||||||
|
|
||||||
this.addRecentScopes(fetchedScopes, parentNode, scopes[0]?.scopeNodeId);
|
|
||||||
this.updateState({ scopes: newScopesState, loading: false });
|
this.updateState({ scopes: newScopesState, loading: false });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -375,7 +481,7 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
|
|||||||
// Check if we are currently on an active scope navigation
|
// Check if we are currently on an active scope navigation
|
||||||
const currentPath = locationService.getLocation().pathname;
|
const currentPath = locationService.getLocation().pathname;
|
||||||
const activeScopeNavigation = this.dashboardsService.state.scopeNavigations.find((s) => {
|
const activeScopeNavigation = this.dashboardsService.state.scopeNavigations.find((s) => {
|
||||||
if (!('url' in s.spec) || typeof s.spec.url !== 'string') {
|
if (!('url' in s.spec)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return isCurrentPath(currentPath, s.spec.url);
|
return isCurrentPath(currentPath, s.spec.url);
|
||||||
@@ -386,7 +492,6 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
|
|||||||
!activeScopeNavigation &&
|
!activeScopeNavigation &&
|
||||||
scopeNode &&
|
scopeNode &&
|
||||||
scopeNode.spec.redirectPath &&
|
scopeNode.spec.redirectPath &&
|
||||||
typeof scopeNode.spec.redirectPath === 'string' &&
|
|
||||||
// Don't redirect if we're already on the target path
|
// Don't redirect if we're already on the target path
|
||||||
!isCurrentPath(currentPath, scopeNode.spec.redirectPath)
|
!isCurrentPath(currentPath, scopeNode.spec.redirectPath)
|
||||||
) {
|
) {
|
||||||
@@ -402,7 +507,6 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
|
|||||||
if (
|
if (
|
||||||
firstScopeNavigation &&
|
firstScopeNavigation &&
|
||||||
'url' in firstScopeNavigation.spec &&
|
'url' in firstScopeNavigation.spec &&
|
||||||
typeof firstScopeNavigation.spec.url === 'string' &&
|
|
||||||
// Only redirect to dashboards TODO: Remove this once Logs Drilldown has Scopes support
|
// Only redirect to dashboards TODO: Remove this once Logs Drilldown has Scopes support
|
||||||
firstScopeNavigation.spec.url.includes('/d/') &&
|
firstScopeNavigation.spec.url.includes('/d/') &&
|
||||||
// Don't redirect if we're already on the target path
|
// Don't redirect if we're already on the target path
|
||||||
@@ -462,13 +566,11 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
|
|||||||
const recentScopes = parseScopesFromLocalStorage(content);
|
const recentScopes = parseScopesFromLocalStorage(content);
|
||||||
|
|
||||||
// Load parent nodes for recent scopes
|
// Load parent nodes for recent scopes
|
||||||
const parentNodes = Object.fromEntries(
|
return Object.fromEntries(
|
||||||
recentScopes
|
recentScopes
|
||||||
.map((scopes) => [scopes[0]?.parentNode?.metadata?.name, scopes[0]?.parentNode])
|
.map((scopes) => [scopes[0]?.parentNode?.metadata?.name, scopes[0]?.parentNode])
|
||||||
.filter(([key, parentNode]) => parentNode !== undefined && key !== undefined)
|
.filter(([key, parentNode]) => parentNode !== undefined && key !== undefined)
|
||||||
);
|
);
|
||||||
|
|
||||||
return parentNodes;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -499,40 +601,42 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
|
|||||||
let newTree = closeNodes(this.state.tree);
|
let newTree = closeNodes(this.state.tree);
|
||||||
|
|
||||||
if (this.state.selectedScopes.length && this.state.selectedScopes[0].scopeNodeId) {
|
if (this.state.selectedScopes.length && this.state.selectedScopes[0].scopeNodeId) {
|
||||||
let path = getPathOfNode(this.state.selectedScopes[0].scopeNodeId, this.state.nodes);
|
|
||||||
|
|
||||||
// Get node at path, and request it's children if they don't exist yet
|
|
||||||
let nodeAtPath = treeNodeAtPath(newTree, path);
|
|
||||||
|
|
||||||
// In the cases where nodes are not in the tree yet
|
|
||||||
if (!nodeAtPath) {
|
|
||||||
try {
|
|
||||||
const result = await this.resolvePathToRoot(this.state.selectedScopes[0].scopeNodeId, newTree);
|
|
||||||
newTree = result.tree;
|
|
||||||
// Update path to use the resolved path since nodes have been fetched
|
|
||||||
path = result.path.map((n) => n.metadata.name);
|
|
||||||
path.unshift('');
|
|
||||||
nodeAtPath = treeNodeAtPath(newTree, path);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to resolve path to root', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// We have resolved to root, which means the parent node should be available
|
|
||||||
let parentPath = path.slice(0, -1);
|
|
||||||
let parentNodeAtPath = treeNodeAtPath(newTree, parentPath);
|
|
||||||
|
|
||||||
if (parentNodeAtPath && !parentNodeAtPath.childrenLoaded) {
|
|
||||||
// This will update the tree with the children
|
|
||||||
const { newTree: newTreeWithChildren } = await this.loadNodeChildren(parentPath, parentNodeAtPath, '');
|
|
||||||
newTree = newTreeWithChildren;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expand the nodes to the selected scope - must be done after loading children
|
|
||||||
try {
|
try {
|
||||||
newTree = expandNodes(newTree, parentPath);
|
// Get the path for the selected scope, preferring defaultPath from scope metadata
|
||||||
|
const pathNodes = await this.getPathForScope(
|
||||||
|
this.state.selectedScopes[0].scopeId,
|
||||||
|
this.state.selectedScopes[0].scopeNodeId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pathNodes.length > 0) {
|
||||||
|
// Convert to string path
|
||||||
|
const stringPath = pathNodes.map((n) => n.metadata.name);
|
||||||
|
stringPath.unshift(''); // Add root segment
|
||||||
|
|
||||||
|
// Check if nodes are in tree
|
||||||
|
let nodeAtPath = treeNodeAtPath(newTree, stringPath);
|
||||||
|
|
||||||
|
// If nodes aren't in tree yet, insert them
|
||||||
|
if (!nodeAtPath) {
|
||||||
|
newTree = insertPathNodesIntoTree(newTree, pathNodes);
|
||||||
|
// Update state so loadNodeChildren can see the inserted nodes
|
||||||
|
this.updateState({ tree: newTree });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load children of the parent node if needed to show all siblings
|
||||||
|
const parentPath = stringPath.slice(0, -1);
|
||||||
|
const parentNodeAtPath = treeNodeAtPath(newTree, parentPath);
|
||||||
|
|
||||||
|
if (parentNodeAtPath && !parentNodeAtPath.childrenLoaded) {
|
||||||
|
const { newTree: newTreeWithChildren } = await this.loadNodeChildren(parentPath, parentNodeAtPath, '');
|
||||||
|
newTree = newTreeWithChildren;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand the nodes to show the selected scope
|
||||||
|
newTree = expandNodes(newTree, parentPath);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to expand nodes', error);
|
console.error('Failed to expand to selected scope', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,9 +684,14 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
|
|||||||
// Get nodes that are not in the cache
|
// Get nodes that are not in the cache
|
||||||
const nodesToFetch = scopeNodeNames.filter((name) => !nodesMap[name]);
|
const nodesToFetch = scopeNodeNames.filter((name) => !nodesMap[name]);
|
||||||
|
|
||||||
const nodes = await this.apiClient.fetchMultipleScopeNodes(nodesToFetch);
|
if (nodesToFetch.length > 0) {
|
||||||
for (const node of nodes) {
|
const nodes = await this.apiClient.fetchMultipleScopeNodes(nodesToFetch);
|
||||||
nodesMap[node.metadata.name] = node;
|
// Handle case where API returns undefined or non-array
|
||||||
|
if (Array.isArray(nodes)) {
|
||||||
|
for (const node of nodes) {
|
||||||
|
nodesMap[node.metadata.name] = node;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const newNodes = { ...this.state.nodes, ...nodesMap };
|
const newNodes = { ...this.state.nodes, ...nodesMap };
|
||||||
|
|||||||
@@ -127,17 +127,27 @@ export const insertPathNodesIntoTree = (tree: TreeNode, path: ScopeNode[]) => {
|
|||||||
if (!childNodeName) {
|
if (!childNodeName) {
|
||||||
console.warn('Failed to insert full path into tree. Did not find child to' + stringPath[index]);
|
console.warn('Failed to insert full path into tree. Did not find child to' + stringPath[index]);
|
||||||
treeNode.childrenLoaded = treeNode.childrenLoaded ?? false;
|
treeNode.childrenLoaded = treeNode.childrenLoaded ?? false;
|
||||||
return treeNode;
|
return;
|
||||||
|
}
|
||||||
|
// Create node if it doesn't exist
|
||||||
|
if (!treeNode.children[childNodeName]) {
|
||||||
|
treeNode.children[childNodeName] = {
|
||||||
|
expanded: false,
|
||||||
|
scopeNodeId: childNodeName,
|
||||||
|
query: '',
|
||||||
|
children: {},
|
||||||
|
childrenLoaded: false,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Node exists, ensure it has children object for nested insertion
|
||||||
|
if (treeNode.children[childNodeName].children === undefined) {
|
||||||
|
treeNode.children[childNodeName] = {
|
||||||
|
...treeNode.children[childNodeName],
|
||||||
|
children: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
treeNode.children[childNodeName] = {
|
|
||||||
expanded: false,
|
|
||||||
scopeNodeId: childNodeName,
|
|
||||||
query: '',
|
|
||||||
children: undefined,
|
|
||||||
childrenLoaded: false,
|
|
||||||
};
|
|
||||||
treeNode.childrenLoaded = treeNode.childrenLoaded ?? false;
|
treeNode.childrenLoaded = treeNode.childrenLoaded ?? false;
|
||||||
return treeNode;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return newTree;
|
return newTree;
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
} from './utils/actions';
|
} from './utils/actions';
|
||||||
import {
|
import {
|
||||||
expectRecentScope,
|
expectRecentScope,
|
||||||
expectRecentScopeNotPresent,
|
|
||||||
expectRecentScopeNotPresentInDocument,
|
expectRecentScopeNotPresentInDocument,
|
||||||
expectRecentScopesSection,
|
expectRecentScopesSection,
|
||||||
expectResultApplicationsGrafanaSelected,
|
expectResultApplicationsGrafanaSelected,
|
||||||
@@ -133,18 +132,21 @@ describe('Selector', () => {
|
|||||||
expectRecentScope('Grafana Applications');
|
expectRecentScope('Grafana Applications');
|
||||||
expectRecentScope('Grafana, Mimir Applications');
|
expectRecentScope('Grafana, Mimir Applications');
|
||||||
await selectRecentScope('Grafana Applications');
|
await selectRecentScope('Grafana Applications');
|
||||||
|
await jest.runOnlyPendingTimersAsync();
|
||||||
|
|
||||||
expectScopesSelectorValue('Grafana');
|
expectScopesSelectorValue('Grafana');
|
||||||
|
|
||||||
await openSelector();
|
// With defaultPath auto-expansion, tree expands to show selected scope
|
||||||
// Close to root node so we can see the recent scopes
|
// So we need to clear selection first to see recent scopes again
|
||||||
await expandResultApplications();
|
await hoverSelector();
|
||||||
|
await clearSelector();
|
||||||
|
|
||||||
|
await openSelector();
|
||||||
await expandRecentScopes();
|
await expandRecentScopes();
|
||||||
expectRecentScope('Grafana, Mimir Applications');
|
expectRecentScope('Grafana, Mimir Applications');
|
||||||
expectRecentScopeNotPresent('Grafana Applications');
|
expectRecentScope('Grafana Applications');
|
||||||
expectRecentScopeNotPresent('Mimir Applications');
|
|
||||||
await selectRecentScope('Grafana, Mimir Applications');
|
await selectRecentScope('Grafana, Mimir Applications');
|
||||||
|
await jest.runOnlyPendingTimersAsync();
|
||||||
|
|
||||||
expectScopesSelectorValue('Grafana + Mimir');
|
expectScopesSelectorValue('Grafana + Mimir');
|
||||||
});
|
});
|
||||||
@@ -156,8 +158,8 @@ describe('Selector', () => {
|
|||||||
await applyScopes();
|
await applyScopes();
|
||||||
|
|
||||||
await openSelector();
|
await openSelector();
|
||||||
// Close to root node so we can try to see the recent scopes
|
// With defaultPath auto-expansion, tree expands to show selected scope
|
||||||
await expandResultApplications();
|
// So recent scopes are not visible (they only show at root with tree collapsed)
|
||||||
expectRecentScopeNotPresentInDocument();
|
expectRecentScopeNotPresentInDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user