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:
Eric Shields
2026-01-09 09:26:02 -08:00
committed by GitHub
co-authored by Claude Tobias Skarhed
parent ad3763f04d
commit 909ed02218
10 changed files with 1985 additions and 96 deletions
+33
View File
@@ -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
View File
@@ -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', () => {
+41 -4
View File
@@ -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();
});