mirror of
https://github.com/grafana/grafana.git
synced 2025-12-20 11:40:21 +08:00
Compare commits
21 Commits
fix-modal-
...
eshields/s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f685893b8 | ||
|
|
2bf5047d37 | ||
|
|
0ac54b2d09 | ||
|
|
3b14940c7f | ||
|
|
2779905296 | ||
|
|
b2f8e1b4d1 | ||
|
|
fde846d8e5 | ||
|
|
43c43c98de | ||
|
|
e568a5859b | ||
|
|
ebc6980a57 | ||
|
|
0969179918 | ||
|
|
2eb3898c71 | ||
|
|
3bf27d926e | ||
|
|
862998a9cb | ||
|
|
4b34d8d8a0 | ||
|
|
e3ff80a38b | ||
|
|
7430af15e6 | ||
|
|
42cdd9a46b | ||
|
|
3cf45edc15 | ||
|
|
e224793eba | ||
|
|
7620c1f24f |
@@ -10,7 +10,7 @@ require (
|
||||
github.com/cockroachdb/apd/v3 v3.2.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/emicklei/proto v1.13.2 // indirect
|
||||
github.com/expr-lang/expr v1.17.0 // indirect
|
||||
github.com/expr-lang/expr v1.17.7 // indirect
|
||||
github.com/getkin/kin-openapi v0.132.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
|
||||
@@ -48,6 +48,23 @@ scopes:
|
||||
operator: equals
|
||||
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:
|
||||
gdev-scopes:
|
||||
title: gdev-scopes
|
||||
@@ -68,6 +85,13 @@ tree:
|
||||
nodeType: leaf
|
||||
linkId: app2
|
||||
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:
|
||||
title: Test cases
|
||||
nodeType: container
|
||||
@@ -83,6 +107,15 @@ tree:
|
||||
nodeType: leaf
|
||||
linkId: test-case-2
|
||||
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:
|
||||
title: Test case with redirect
|
||||
nodeType: leaf
|
||||
|
||||
@@ -51,8 +51,9 @@ type Config struct {
|
||||
|
||||
// ScopeConfig is used for YAML parsing - converts to v0alpha1.ScopeSpec
|
||||
type ScopeConfig struct {
|
||||
Title string `yaml:"title"`
|
||||
Filters []ScopeFilterConfig `yaml:"filters"`
|
||||
Title string `yaml:"title"`
|
||||
DefaultPath []string `yaml:"defaultPath,omitempty"`
|
||||
Filters []ScopeFilterConfig `yaml:"filters"`
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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{
|
||||
Title: cfg.Title,
|
||||
Filters: filters,
|
||||
Title: cfg.Title,
|
||||
DefaultPath: defaultPath,
|
||||
Filters: filters,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ require (
|
||||
github.com/dave/jennifer v1.7.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/emicklei/proto v1.14.2 // indirect
|
||||
github.com/expr-lang/expr v1.17.6 // indirect
|
||||
github.com/expr-lang/expr v1.17.7 // indirect
|
||||
github.com/getkin/kin-openapi v0.133.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.4 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
|
||||
|
||||
@@ -11,8 +11,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/emicklei/proto v1.14.2 h1:wJPxPy2Xifja9cEMrcA/g08art5+7CGJNFNk35iXC1I=
|
||||
github.com/emicklei/proto v1.14.2/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A=
|
||||
github.com/expr-lang/expr v1.17.6 h1:1h6i8ONk9cexhDmowO/A64VPxHScu7qfSl2k8OlINec=
|
||||
github.com/expr-lang/expr v1.17.6/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
|
||||
github.com/expr-lang/expr v1.17.7 h1:Q0xY/e/2aCIp8g9s/LGvMDCC5PxYlvHgDZRQ4y16JX8=
|
||||
github.com/expr-lang/expr v1.17.7/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
|
||||
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
|
||||
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
|
||||
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
|
||||
|
||||
@@ -17,7 +17,7 @@ require (
|
||||
github.com/cockroachdb/apd/v3 v3.2.1 // indirect
|
||||
github.com/dave/dst v0.27.3 // indirect
|
||||
github.com/emicklei/proto v1.14.2 // indirect
|
||||
github.com/expr-lang/expr v1.17.6 // indirect
|
||||
github.com/expr-lang/expr v1.17.7 // indirect
|
||||
github.com/getkin/kin-openapi v0.133.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.4 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
|
||||
|
||||
@@ -12,8 +12,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/emicklei/proto v1.14.2 h1:wJPxPy2Xifja9cEMrcA/g08art5+7CGJNFNk35iXC1I=
|
||||
github.com/emicklei/proto v1.14.2/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A=
|
||||
github.com/expr-lang/expr v1.17.6 h1:1h6i8ONk9cexhDmowO/A64VPxHScu7qfSl2k8OlINec=
|
||||
github.com/expr-lang/expr v1.17.6/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
|
||||
github.com/expr-lang/expr v1.17.7 h1:Q0xY/e/2aCIp8g9s/LGvMDCC5PxYlvHgDZRQ4y16JX8=
|
||||
github.com/expr-lang/expr v1.17.7/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
|
||||
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
|
||||
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
|
||||
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ElementSelectionContext, useSidebar, useStyles2, Sidebar } from '@grafa
|
||||
import NativeScrollbar, { DivScrollElement } from 'app/core/components/NativeScrollbar';
|
||||
import { useGrafana } from 'app/core/context/GrafanaContext';
|
||||
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
|
||||
import { playlistSrv } from 'app/features/playlist/PlaylistSrv';
|
||||
import { KioskMode } from 'app/types/dashboard';
|
||||
|
||||
import { DashboardScene } from '../scene/DashboardScene';
|
||||
@@ -32,7 +33,7 @@ export function DashboardEditPaneSplitter({ dashboard, isEditing, body, controls
|
||||
const styles = useStyles2(getStyles, headerHeight ?? 0);
|
||||
const { chrome } = useGrafana();
|
||||
const { kioskMode } = chrome.useState();
|
||||
const isInKioskMode = kioskMode === KioskMode.Full;
|
||||
const { isPlaying } = playlistSrv.useState();
|
||||
|
||||
if (!config.featureToggles.dashboardNewLayouts) {
|
||||
return (
|
||||
@@ -94,8 +95,10 @@ export function DashboardEditPaneSplitter({ dashboard, isEditing, body, controls
|
||||
};
|
||||
|
||||
function renderBody() {
|
||||
const renderWithoutSidebar = isPlaying || kioskMode === KioskMode.Full;
|
||||
|
||||
// In kiosk mode the full document body scrolls so we don't need to wrap in our own scrollbar
|
||||
if (isInKioskMode) {
|
||||
if (renderWithoutSidebar) {
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.bodyWrapper, styles.bodyWrapperKiosk)}
|
||||
|
||||
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;
|
||||
|
||||
if (appliedScopes.length > 0 && appliedScopes[0].scopeNodeId) {
|
||||
let path = getPathOfNode(appliedScopes[0].scopeNodeId, nodes);
|
||||
// Get reed of empty root section and the actual scope node
|
||||
path = path.slice(1, -1);
|
||||
if (appliedScopes.length > 0) {
|
||||
const firstScope = appliedScopes[0];
|
||||
const scope = scopes[firstScope.scopeId];
|
||||
|
||||
// We may not have all the nodes in path loaded
|
||||
nicePath = path.map((p) => nodes[p]?.spec.title).filter((p) => p);
|
||||
// Prefer defaultPath from scope metadata
|
||||
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;
|
||||
@@ -127,7 +139,7 @@ function ScopesTooltip({ nodes, scopes, appliedScopes, onRemoveAllClick, disable
|
||||
return t('scopes.selector.input.tooltip', 'Select scope');
|
||||
}
|
||||
|
||||
const nicePath = getScopesPath(appliedScopes, nodes);
|
||||
const nicePath = getScopesPath(appliedScopes, nodes, scopes);
|
||||
const scopeNames = appliedScopes.map((s) => {
|
||||
if (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,
|
||||
} from './scopesTreeUtils';
|
||||
import { NodesMap, RecentScope, RecentScopeSchema, ScopeSchema, ScopesMap, SelectedScope, TreeNode } from './types';
|
||||
|
||||
export const RECENT_SCOPES_KEY = 'grafana.scopes.recent';
|
||||
|
||||
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);
|
||||
if (!node) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Add current node to visited set
|
||||
const newVisited = new Set(visited);
|
||||
newVisited.add(scopeNodeId);
|
||||
|
||||
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];
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
scopeNodeId: string,
|
||||
tree: TreeNode
|
||||
tree: TreeNode,
|
||||
scopeId?: string
|
||||
): 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);
|
||||
|
||||
this.updateState({ tree: newTree });
|
||||
@@ -207,16 +254,39 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
|
||||
}
|
||||
|
||||
const newTree = modifyTreeNodeAtPath(this.state.tree, path, (treeNode) => {
|
||||
// Set parent query only when filtering within existing children
|
||||
treeNode.children = {};
|
||||
// Preserve existing children that have nested structure (from insertPathNodesIntoTree)
|
||||
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) {
|
||||
treeNode.children[node.metadata.name] = {
|
||||
expanded: false,
|
||||
scopeNodeId: node.metadata.name,
|
||||
// Only set query on tree nodes if parent already has children (filtering vs first expansion). This is used for saerch highlighting.
|
||||
query: query || '',
|
||||
children: undefined,
|
||||
};
|
||||
// If this child was preserved, merge with fetched data
|
||||
if (childrenToPreserve[node.metadata.name]) {
|
||||
treeNode.children[node.metadata.name] = {
|
||||
...childrenToPreserve[node.metadata.name],
|
||||
// Update query but keep nested children
|
||||
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
|
||||
treeNode.childrenLoaded = true;
|
||||
@@ -356,16 +426,52 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
};
|
||||
@@ -375,7 +481,7 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
|
||||
// Check if we are currently on an active scope navigation
|
||||
const currentPath = locationService.getLocation().pathname;
|
||||
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 isCurrentPath(currentPath, s.spec.url);
|
||||
@@ -386,7 +492,6 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
|
||||
!activeScopeNavigation &&
|
||||
scopeNode &&
|
||||
scopeNode.spec.redirectPath &&
|
||||
typeof scopeNode.spec.redirectPath === 'string' &&
|
||||
// Don't redirect if we're already on the target path
|
||||
!isCurrentPath(currentPath, scopeNode.spec.redirectPath)
|
||||
) {
|
||||
@@ -402,7 +507,6 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
|
||||
if (
|
||||
firstScopeNavigation &&
|
||||
'url' in firstScopeNavigation.spec &&
|
||||
typeof firstScopeNavigation.spec.url === 'string' &&
|
||||
// Only redirect to dashboards TODO: Remove this once Logs Drilldown has Scopes support
|
||||
firstScopeNavigation.spec.url.includes('/d/') &&
|
||||
// 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);
|
||||
|
||||
// Load parent nodes for recent scopes
|
||||
const parentNodes = Object.fromEntries(
|
||||
return Object.fromEntries(
|
||||
recentScopes
|
||||
.map((scopes) => [scopes[0]?.parentNode?.metadata?.name, scopes[0]?.parentNode])
|
||||
.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);
|
||||
|
||||
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 {
|
||||
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) {
|
||||
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
|
||||
const nodesToFetch = scopeNodeNames.filter((name) => !nodesMap[name]);
|
||||
|
||||
const nodes = await this.apiClient.fetchMultipleScopeNodes(nodesToFetch);
|
||||
for (const node of nodes) {
|
||||
nodesMap[node.metadata.name] = node;
|
||||
if (nodesToFetch.length > 0) {
|
||||
const nodes = await this.apiClient.fetchMultipleScopeNodes(nodesToFetch);
|
||||
// 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 };
|
||||
|
||||
@@ -127,17 +127,27 @@ export const insertPathNodesIntoTree = (tree: TreeNode, path: ScopeNode[]) => {
|
||||
if (!childNodeName) {
|
||||
console.warn('Failed to insert full path into tree. Did not find child to' + stringPath[index]);
|
||||
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;
|
||||
return treeNode;
|
||||
});
|
||||
}
|
||||
return newTree;
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
} from './utils/actions';
|
||||
import {
|
||||
expectRecentScope,
|
||||
expectRecentScopeNotPresent,
|
||||
expectRecentScopeNotPresentInDocument,
|
||||
expectRecentScopesSection,
|
||||
expectResultApplicationsGrafanaSelected,
|
||||
@@ -133,18 +132,21 @@ describe('Selector', () => {
|
||||
expectRecentScope('Grafana Applications');
|
||||
expectRecentScope('Grafana, Mimir Applications');
|
||||
await selectRecentScope('Grafana Applications');
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
|
||||
expectScopesSelectorValue('Grafana');
|
||||
|
||||
await openSelector();
|
||||
// Close to root node so we can see the recent scopes
|
||||
await expandResultApplications();
|
||||
// With defaultPath auto-expansion, tree expands to show selected scope
|
||||
// So we need to clear selection first to see recent scopes again
|
||||
await hoverSelector();
|
||||
await clearSelector();
|
||||
|
||||
await openSelector();
|
||||
await expandRecentScopes();
|
||||
expectRecentScope('Grafana, Mimir Applications');
|
||||
expectRecentScopeNotPresent('Grafana Applications');
|
||||
expectRecentScopeNotPresent('Mimir Applications');
|
||||
expectRecentScope('Grafana Applications');
|
||||
await selectRecentScope('Grafana, Mimir Applications');
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
|
||||
expectScopesSelectorValue('Grafana + Mimir');
|
||||
});
|
||||
@@ -156,8 +158,8 @@ describe('Selector', () => {
|
||||
await applyScopes();
|
||||
|
||||
await openSelector();
|
||||
// Close to root node so we can try to see the recent scopes
|
||||
await expandResultApplications();
|
||||
// With defaultPath auto-expansion, tree expands to show selected scope
|
||||
// So recent scopes are not visible (they only show at root with tree collapsed)
|
||||
expectRecentScopeNotPresentInDocument();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user