Scopes: Implement defaultPath support and refactor path resolution (#114863)
Implements support for the defaultPath field in Scope specifications * Faster performance: Batch API call (fetchMultipleScopeNodes) replaces N sequential calls * Instant selector opening: Pre-fetches all path nodes when applying scopes * Consistent resolution: Single source of truth for scopeNodeId and parentNodeId across UI and URLs * Correct URL syncing: scope_node parameter always reflects the canonical defaultPath * Backwards compatible: Gracefully falls back when defaultPath is unavailable Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Tobias Skarhed <tobias.skarhed@gmail.com>
This commit is contained in:
co-authored by
Claude
Tobias Skarhed
parent
ad3763f04d
commit
909ed02218
@@ -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
|
||||
|
||||
+16
-4
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
import { ScopeSpecFilter } from '@grafana/data';
|
||||
import { LocationService } from '@grafana/runtime';
|
||||
|
||||
import { ScopesService } from './ScopesService';
|
||||
@@ -16,8 +17,20 @@ describe('ScopesService', () => {
|
||||
let locationService: jest.Mocked<LocationService>;
|
||||
let selectorStateSubscription:
|
||||
| ((
|
||||
state: { appliedScopes: Array<{ scopeId: string; scopeNodeId?: string; parentNodeId?: string }> },
|
||||
prevState: { appliedScopes: Array<{ scopeId: string; scopeNodeId?: string; parentNodeId?: string }> }
|
||||
state: {
|
||||
appliedScopes: Array<{ scopeId: string; scopeNodeId?: string; parentNodeId?: string }>;
|
||||
scopes?: Record<
|
||||
string,
|
||||
{ metadata: { name: string }; spec: { title: string; defaultPath?: string[]; filters: ScopeSpecFilter[] } }
|
||||
>;
|
||||
},
|
||||
prevState: {
|
||||
appliedScopes: Array<{ scopeId: string; scopeNodeId?: string; parentNodeId?: string }>;
|
||||
scopes?: Record<
|
||||
string,
|
||||
{ metadata: { name: string }; spec: { title: string; defaultPath?: string[]; filters: ScopeSpecFilter[] } }
|
||||
>;
|
||||
}
|
||||
) => void)
|
||||
| undefined;
|
||||
let dashboardsStateSubscription:
|
||||
@@ -274,9 +287,11 @@ describe('ScopesService', () => {
|
||||
selectorStateSubscription(
|
||||
{
|
||||
appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node1' }],
|
||||
scopes: {},
|
||||
},
|
||||
{
|
||||
appliedScopes: [],
|
||||
scopes: {},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -298,9 +313,11 @@ describe('ScopesService', () => {
|
||||
selectorStateSubscription(
|
||||
{
|
||||
appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node1', parentNodeId: 'parent1' }],
|
||||
scopes: {},
|
||||
},
|
||||
{
|
||||
appliedScopes: [],
|
||||
scopes: {},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -320,9 +337,11 @@ describe('ScopesService', () => {
|
||||
selectorStateSubscription(
|
||||
{
|
||||
appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node2' }],
|
||||
scopes: {},
|
||||
},
|
||||
{
|
||||
appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node1' }],
|
||||
scopes: {},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -344,9 +363,11 @@ describe('ScopesService', () => {
|
||||
selectorStateSubscription(
|
||||
{
|
||||
appliedScopes: [{ scopeId: 'scope1' }],
|
||||
scopes: {},
|
||||
},
|
||||
{
|
||||
appliedScopes: [],
|
||||
scopes: {},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -370,15 +391,171 @@ describe('ScopesService', () => {
|
||||
selectorStateSubscription(
|
||||
{
|
||||
appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node1' }],
|
||||
scopes: {},
|
||||
},
|
||||
{
|
||||
appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'node1' }],
|
||||
scopes: {},
|
||||
}
|
||||
);
|
||||
|
||||
expect(locationService.partial).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('defaultPath support', () => {
|
||||
it('should extract scope_node from defaultPath when available', () => {
|
||||
if (!selectorStateSubscription) {
|
||||
throw new Error('selectorStateSubscription not set');
|
||||
}
|
||||
|
||||
selectorStateSubscription(
|
||||
{
|
||||
appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'old-node' }],
|
||||
scopes: {
|
||||
scope1: {
|
||||
metadata: { name: 'scope1' },
|
||||
spec: {
|
||||
title: 'Scope 1',
|
||||
defaultPath: ['', 'parent-node', 'correct-node'],
|
||||
filters: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
appliedScopes: [],
|
||||
scopes: {},
|
||||
}
|
||||
);
|
||||
|
||||
// Should use 'correct-node' from defaultPath, not 'old-node' from appliedScopes
|
||||
expect(locationService.partial).toHaveBeenCalledWith(
|
||||
{
|
||||
scopes: ['scope1'],
|
||||
scope_node: 'correct-node',
|
||||
scope_parent: null,
|
||||
},
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('should fallback to scopeNodeId when defaultPath is not available', () => {
|
||||
if (!selectorStateSubscription) {
|
||||
throw new Error('selectorStateSubscription not set');
|
||||
}
|
||||
|
||||
selectorStateSubscription(
|
||||
{
|
||||
appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'fallback-node' }],
|
||||
scopes: {
|
||||
scope1: {
|
||||
metadata: { name: 'scope1' },
|
||||
spec: {
|
||||
title: 'Scope 1',
|
||||
filters: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
appliedScopes: [],
|
||||
scopes: {},
|
||||
}
|
||||
);
|
||||
|
||||
// Should fallback to scopeNodeId from appliedScopes
|
||||
expect(locationService.partial).toHaveBeenCalledWith(
|
||||
{
|
||||
scopes: ['scope1'],
|
||||
scope_node: 'fallback-node',
|
||||
scope_parent: null,
|
||||
},
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty defaultPath gracefully', () => {
|
||||
if (!selectorStateSubscription) {
|
||||
throw new Error('selectorStateSubscription not set');
|
||||
}
|
||||
|
||||
selectorStateSubscription(
|
||||
{
|
||||
appliedScopes: [{ scopeId: 'scope1', scopeNodeId: 'fallback-node' }],
|
||||
scopes: {
|
||||
scope1: {
|
||||
metadata: { name: 'scope1' },
|
||||
spec: {
|
||||
title: 'Scope 1',
|
||||
defaultPath: [],
|
||||
filters: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
appliedScopes: [],
|
||||
scopes: {},
|
||||
}
|
||||
);
|
||||
|
||||
// Should fallback to scopeNodeId when defaultPath is empty
|
||||
expect(locationService.partial).toHaveBeenCalledWith(
|
||||
{
|
||||
scopes: ['scope1'],
|
||||
scope_node: 'fallback-node',
|
||||
scope_parent: null,
|
||||
},
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('should detect changes in defaultPath-derived scopeNodeId', () => {
|
||||
if (!selectorStateSubscription) {
|
||||
throw new Error('selectorStateSubscription not set');
|
||||
}
|
||||
|
||||
selectorStateSubscription(
|
||||
{
|
||||
appliedScopes: [{ scopeId: 'scope1' }],
|
||||
scopes: {
|
||||
scope1: {
|
||||
metadata: { name: 'scope1' },
|
||||
spec: {
|
||||
title: 'Scope 1',
|
||||
defaultPath: ['', 'parent', 'new-node'],
|
||||
filters: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
appliedScopes: [{ scopeId: 'scope1' }],
|
||||
scopes: {
|
||||
scope1: {
|
||||
metadata: { name: 'scope1' },
|
||||
spec: {
|
||||
title: 'Scope 1',
|
||||
defaultPath: ['', 'parent', 'old-node'],
|
||||
filters: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Should detect the change in defaultPath-derived scopeNodeId
|
||||
expect(locationService.partial).toHaveBeenCalledWith(
|
||||
{
|
||||
scopes: ['scope1'],
|
||||
scope_node: 'new-node',
|
||||
scope_parent: null,
|
||||
},
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should write navigation_scope to URL when navigationScope changes', () => {
|
||||
if (!dashboardsStateSubscription) {
|
||||
throw new Error('dashboardsStateSubscription not set');
|
||||
@@ -622,6 +799,30 @@ describe('ScopesService', () => {
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('should use defaultPath for scope_node when enabling scopes', () => {
|
||||
selectorService.state.appliedScopes = [{ scopeId: 'scope1', scopeNodeId: 'old-node' }];
|
||||
selectorService.state.scopes = {
|
||||
scope1: {
|
||||
metadata: { name: 'scope1' },
|
||||
spec: {
|
||||
title: 'Scope 1',
|
||||
defaultPath: ['', 'parent', 'correct-node-from-defaultPath'],
|
||||
filters: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
service.setEnabled(true);
|
||||
|
||||
// Should use defaultPath instead of scopeNodeId from appliedScopes
|
||||
expect(locationService.partial).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
scope_node: 'correct-node-from-defaultPath',
|
||||
}),
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('back/forward navigation handling', () => {
|
||||
|
||||
@@ -151,12 +151,26 @@ export class ScopesService implements ScopesContextValue {
|
||||
// Update the URL based on change in the scopes state
|
||||
this.subscriptions.push(
|
||||
selectorService.subscribeToState((state, prevState) => {
|
||||
const oldScopeNodeId = prevState.appliedScopes[0]?.scopeNodeId;
|
||||
const newScopeNodeId = state.appliedScopes[0]?.scopeNodeId;
|
||||
|
||||
const oldScopeNames = prevState.appliedScopes.map((scope) => scope.scopeId);
|
||||
const newScopeNames = state.appliedScopes.map((scope) => scope.scopeId);
|
||||
|
||||
// Extract scopeNodeId from defaultPath when available
|
||||
const getScopeNodeId = (appliedScopes: typeof state.appliedScopes, scopes: typeof state.scopes) => {
|
||||
const firstScope = appliedScopes[0];
|
||||
if (!firstScope) {
|
||||
return undefined;
|
||||
}
|
||||
const scope = scopes[firstScope.scopeId];
|
||||
// Prefer defaultPath when available
|
||||
if (scope?.spec.defaultPath && scope.spec.defaultPath.length > 0) {
|
||||
return scope.spec.defaultPath[scope.spec.defaultPath.length - 1];
|
||||
}
|
||||
return firstScope.scopeNodeId;
|
||||
};
|
||||
|
||||
const oldScopeNodeId = getScopeNodeId(prevState.appliedScopes, prevState.scopes);
|
||||
const newScopeNodeId = getScopeNodeId(state.appliedScopes, state.scopes);
|
||||
|
||||
const scopesChanged = !isEqual(oldScopeNames, newScopeNames);
|
||||
const scopeNodeChanged = oldScopeNodeId !== newScopeNodeId;
|
||||
|
||||
@@ -230,7 +244,7 @@ export class ScopesService implements ScopesContextValue {
|
||||
if (this.state.enabled !== enabled) {
|
||||
this.updateState({ enabled });
|
||||
if (enabled) {
|
||||
const scopeNodeId = this.selectorService.state.appliedScopes[0]?.scopeNodeId;
|
||||
const scopeNodeId = this.getScopeNodeIdForUrl();
|
||||
this.locationService.partial(
|
||||
{
|
||||
scopes: this.selectorService.state.appliedScopes.map((s) => s.scopeId),
|
||||
@@ -243,6 +257,29 @@ export class ScopesService implements ScopesContextValue {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts the scopeNodeId for URL syncing, preferring defaultPath when available.
|
||||
* When a scope has defaultPath, that is the source of truth for the node ID.
|
||||
* @private
|
||||
*/
|
||||
private getScopeNodeIdForUrl(): string | undefined {
|
||||
const firstScope = this.selectorService.state.appliedScopes[0];
|
||||
if (!firstScope) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const scope = this.selectorService.state.scopes[firstScope.scopeId];
|
||||
|
||||
// Prefer scopeNodeId from defaultPath if available (most reliable source)
|
||||
if (scope?.spec.defaultPath && scope.spec.defaultPath.length > 0) {
|
||||
// Extract scopeNodeId from the last element of defaultPath
|
||||
return scope.spec.defaultPath[scope.spec.defaultPath.length - 1];
|
||||
}
|
||||
|
||||
// Fallback to next in priority order: scopeNodeId from appliedScopes
|
||||
return firstScope.scopeNodeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns observable that emits when relevant parts of the selectorService state change.
|
||||
* @private
|
||||
|
||||
@@ -31,13 +31,33 @@ export function ScopesInput({
|
||||
onInputClick,
|
||||
onRemoveAllClick,
|
||||
}: ScopesInputProps) {
|
||||
const scopeNodeId = appliedScopes[0]?.scopeNodeId;
|
||||
const firstScope = appliedScopes[0];
|
||||
const scope = scopes[firstScope?.scopeId];
|
||||
const styles = useStyles2(getStyles);
|
||||
const parentNodeIdFromRecentScopes = appliedScopes[0]?.parentNodeId; // This is only set from recent scopes TODO: remove after recent scopes refactor
|
||||
|
||||
// Prefer scopeNodeId from defaultPath if available (most reliable source)
|
||||
let scopeNodeId: string | undefined;
|
||||
if (scope?.spec.defaultPath && scope.spec.defaultPath.length > 0) {
|
||||
// Extract scopeNodeId from the last element of defaultPath
|
||||
scopeNodeId = scope.spec.defaultPath[scope.spec.defaultPath.length - 1];
|
||||
} else {
|
||||
// Fallback to next in priority order: scopeNodeId from appliedScopes
|
||||
scopeNodeId = firstScope?.scopeNodeId;
|
||||
}
|
||||
|
||||
const { node: scopeNode, isLoading: scopeNodeLoading } = useScopeNode(scopeNodeId);
|
||||
|
||||
// Get parent from scope node if available, otherwise fallback to parent
|
||||
const parentNodeId = scopeNode?.spec.parentName ?? parentNodeIdFromRecentScopes;
|
||||
// Prefer parentNodeId from defaultPath if available
|
||||
let parentNodeId: string | undefined;
|
||||
if (scope?.spec.defaultPath && scope.spec.defaultPath.length > 1) {
|
||||
// Extract parentNodeId from the second-to-last element of defaultPath
|
||||
parentNodeId = scope.spec.defaultPath[scope.spec.defaultPath.length - 2];
|
||||
} else {
|
||||
// Fallback to parent from scope node or recent scopes
|
||||
const parentNodeIdFromRecentScopes = firstScope?.parentNodeId;
|
||||
parentNodeId = scopeNode?.spec.parentName ?? parentNodeIdFromRecentScopes;
|
||||
}
|
||||
|
||||
const { node: parentNode, isLoading: parentNodeLoading } = useScopeNode(parentNodeId);
|
||||
|
||||
// Prioritize scope node subtitle over parent node title
|
||||
@@ -99,16 +119,31 @@ export function ScopesInput({
|
||||
);
|
||||
}
|
||||
|
||||
const getScopesPath = (appliedScopes: SelectedScope[], nodes: NodesMap) => {
|
||||
const getScopesPath = (
|
||||
appliedScopes: SelectedScope[],
|
||||
nodes: NodesMap,
|
||||
defaultPath?: string[]
|
||||
): string[] | undefined => {
|
||||
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];
|
||||
|
||||
// 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 (defaultPath && defaultPath.length > 1) {
|
||||
// Get all nodes except the last one (which is the scope itself)
|
||||
const pathNodeIds = 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 +162,9 @@ function ScopesTooltip({ nodes, scopes, appliedScopes, onRemoveAllClick, disable
|
||||
return t('scopes.selector.input.tooltip', 'Select scope');
|
||||
}
|
||||
|
||||
const nicePath = getScopesPath(appliedScopes, nodes);
|
||||
const firstScope = appliedScopes[0];
|
||||
const scope = scopes[firstScope?.scopeId];
|
||||
const nicePath = getScopesPath(appliedScopes, nodes, scope?.spec.defaultPath);
|
||||
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,74 @@ 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.
|
||||
*
|
||||
* TODO: Consider making this public and exposing via a hook to avoid duplication
|
||||
* with getScopesPath in ScopesInput.tsx
|
||||
*
|
||||
* @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[];
|
||||
|
||||
// Check if scope has defaultPath for optimized resolution
|
||||
const scope = scopeId ? this.state.scopes[scopeId] : undefined;
|
||||
if (scope?.spec.defaultPath && scope.spec.defaultPath.length > 0) {
|
||||
// Use batch-fetched defaultPath (most efficient)
|
||||
nodePath = await this.getPathForScope(scopeId!, scopeNodeId);
|
||||
} else {
|
||||
// Fall back to node-based path resolution
|
||||
nodePath = await this.getNodePath(scopeNodeId);
|
||||
}
|
||||
|
||||
const newTree = insertPathNodesIntoTree(tree, nodePath);
|
||||
|
||||
this.updateState({ tree: newTree });
|
||||
@@ -207,16 +260,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 +432,54 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
|
||||
}
|
||||
|
||||
const newScopesState = { ...this.state.scopes };
|
||||
|
||||
// Validate API response is an array
|
||||
if (!Array.isArray(fetchedScopes)) {
|
||||
console.error('Expected fetchedScopes to be an array, got:', typeof fetchedScopes);
|
||||
this.updateState({ scopes: newScopesState, loading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
for (const scope of fetchedScopes) {
|
||||
newScopesState[scope.metadata.name] = scope;
|
||||
}
|
||||
|
||||
// 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;
|
||||
// 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]);
|
||||
|
||||
this.addRecentScopes(fetchedScopes, parentNode, scopes[0]?.scopeNodeId);
|
||||
if (nodesToFetch.length > 0) {
|
||||
await this.getScopeNodes(nodesToFetch);
|
||||
}
|
||||
}
|
||||
|
||||
// Get scopeNode and parentNode, preferring defaultPath as the source of truth
|
||||
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 next in priority order
|
||||
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);
|
||||
this.updateState({ scopes: newScopesState, loading: false });
|
||||
}
|
||||
};
|
||||
@@ -375,7 +489,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 +500,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 +515,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 +574,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 +609,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 +692,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;
|
||||
|
||||
@@ -132,11 +132,12 @@ 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
|
||||
// Collapse tree to root level to see recent scopes section
|
||||
await expandResultApplications();
|
||||
|
||||
await expandRecentScopes();
|
||||
@@ -155,8 +156,8 @@ describe('Selector', () => {
|
||||
await applyScopes();
|
||||
|
||||
await openSelector();
|
||||
// Close to root node so we can try to see the recent scopes
|
||||
await expandResultApplications();
|
||||
// Tree expands to show selected scope, so recent scopes are not visible
|
||||
// (recent scopes only show at root level with tree collapsed)
|
||||
expectRecentScopeNotPresentInDocument();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user