From 5639ecf7112f7944909769ace5ce6f7089a403f8 Mon Sep 17 00:00:00 2001 From: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com> Date: Tue, 30 Sep 2025 21:46:06 +0200 Subject: [PATCH] Scopes: Get parent nodes for command palette search results (#111820) * Add feature flag for multiple scopes endpoint usage * Add method to API client for fetching multiple ScopeNodes at the same time * Add parent title to tree * Get nodes from cache too * Update test with new functionality * Update test * Fix linting issue * Remove unapplied scope parents --- .../src/types/featureToggles.gen.ts | 5 +++ pkg/services/featuremgmt/registry.go | 10 +++++ pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 ++ pkg/services/featuremgmt/toggles_gen.json | 16 ++++++++ .../actions/scopeActions.test.tsx | 1 + .../commandPalette/actions/scopeActions.tsx | 37 +++++++++++++++---- .../actions/scopesUtils.test.ts | 2 + .../commandPalette/actions/scopesUtils.ts | 18 +++++++-- public/app/features/scopes/ScopesApiClient.ts | 15 ++++++++ .../scopes/selector/ScopesSelectorService.ts | 24 ++++++++++++ 11 files changed, 122 insertions(+), 11 deletions(-) diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index f92396c8e8a..5fd9f7960c5 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -479,6 +479,11 @@ export interface FeatureToggles { */ useScopeSingleNodeEndpoint?: boolean; /** + * Makes the frontend use the 'names' param for fetching multiple scope nodes at once + * @default false + */ + useMultipleScopeNodesEndpoint?: boolean; + /** * In-development feature that will allow injection of labels into prometheus queries. * @default true */ diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index dfe68f5ecf5..9e368a4bc5c 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -806,6 +806,16 @@ var ( HideFromDocs: true, HideFromAdminPage: true, }, + { + Name: "useMultipleScopeNodesEndpoint", + Description: "Makes the frontend use the 'names' param for fetching multiple scope nodes at once", + Stage: FeatureStageExperimental, + Owner: grafanaOperatorExperienceSquad, + Expression: "false", + FrontendOnly: true, + HideFromDocs: true, + HideFromAdminPage: true, + }, { Name: "promQLScope", Description: "In-development feature that will allow injection of labels into prometheus queries.", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 2df0d029c26..4e489bf84f9 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -106,6 +106,7 @@ alertingSaveStatePeriodic,privatePreview,@grafana/alerting-squad,false,false,fal alertingSaveStateCompressed,preview,@grafana/alerting-squad,false,false,false scopeApi,experimental,@grafana/grafana-app-platform-squad,false,false,false useScopeSingleNodeEndpoint,experimental,@grafana/grafana-operator-experience-squad,false,false,true +useMultipleScopeNodesEndpoint,experimental,@grafana/grafana-operator-experience-squad,false,false,true promQLScope,GA,@grafana/oss-big-tent,false,false,false logQLScope,privatePreview,@grafana/observability-logs,false,false,false sqlExpressions,preview,@grafana/grafana-datasources-core-services,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 033e8990946..097cd66ab45 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -435,6 +435,10 @@ const ( // Use the single node endpoint for the scope api. This is used to fetch the scope parent node. FlagUseScopeSingleNodeEndpoint = "useScopeSingleNodeEndpoint" + // FlagUseMultipleScopeNodesEndpoint + // Makes the frontend use the 'names' param for fetching multiple scope nodes at once + FlagUseMultipleScopeNodesEndpoint = "useMultipleScopeNodesEndpoint" + // FlagPromQLScope // In-development feature that will allow injection of labels into prometheus queries. FlagPromQLScope = "promQLScope" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index bc11ea33d02..7e32061c9d5 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -3845,6 +3845,22 @@ "frontend": true } }, + { + "metadata": { + "name": "useMultipleScopeNodesEndpoint", + "resourceVersion": "1759237515008", + "creationTimestamp": "2025-09-30T13:05:15Z" + }, + "spec": { + "description": "Makes the frontend use the 'names' param for fetching multiple scope nodes at once", + "stage": "experimental", + "codeowner": "@grafana/grafana-operator-experience-squad", + "frontend": true, + "hideFromAdminPage": true, + "hideFromDocs": true, + "expression": "false" + } + }, { "metadata": { "name": "useScopeSingleNodeEndpoint", diff --git a/public/app/features/commandPalette/actions/scopeActions.test.tsx b/public/app/features/commandPalette/actions/scopeActions.test.tsx index 093ac40b1c7..0d828d66a81 100644 --- a/public/app/features/commandPalette/actions/scopeActions.test.tsx +++ b/public/app/features/commandPalette/actions/scopeActions.test.tsx @@ -230,6 +230,7 @@ describe('useRegisterScopesActions', () => { // The main difference here is that we map it to a parent if we are in the "scopes" section of the cmdK. // In the previous test the scope actions were mapped to global level to show correctly. parent: 'scopes', + subtitle: 'some parent', }, ]; diff --git a/public/app/features/commandPalette/actions/scopeActions.tsx b/public/app/features/commandPalette/actions/scopeActions.tsx index 724a3d49d46..783ec7b1b35 100644 --- a/public/app/features/commandPalette/actions/scopeActions.tsx +++ b/public/app/features/commandPalette/actions/scopeActions.tsx @@ -139,7 +139,7 @@ function useScopesRow(onApply: () => void) { * @param parentId */ function useGlobalScopesSearch(searchQuery: string, parentId?: string | null) { - const { selectScope, searchAllNodes } = useScopeServicesState(); + const { selectScope, searchAllNodes, getScopeNodes } = useScopeServicesState(); const [actions, setActions] = useState(undefined); const searchQueryRef = useRef(); @@ -151,19 +151,42 @@ function useGlobalScopesSearch(searchQuery: string, parentId?: string | null) { if (searchQueryRef.current === searchQuery) { // Only show leaf nodes because otherwise there are issues with navigating to a category without knowing // where in the tree it is. - const leafNodes = nodes.filter((node) => node.spec.nodeType === 'leaf'); - const actions = [getScopesParentAction()]; - for (const node of leafNodes) { - actions.push(mapScopeNodeToAction(node, selectScope, parentId || undefined)); + + const parentNodesMap = new Map(); + + if (config.featureToggles.useMultipleScopeNodesEndpoint) { + // Make sure we only request unqiue parent node names + const uniqueParentNodeNames = [ + ...new Set(nodes.map((node) => node.spec.parentName).filter((name) => name !== undefined)), + ]; + getScopeNodes(uniqueParentNodeNames).then((parentNodes) => { + for (const parentNode of parentNodes) { + parentNodesMap.set(parentNode.metadata.name, parentNode.spec.title); + } + + const leafNodes = nodes.filter((node) => node.spec.nodeType === 'leaf'); + const actions = [getScopesParentAction()]; + for (const node of leafNodes) { + const parentName = parentNodesMap.get(node.spec.parentName); + actions.push(mapScopeNodeToAction(node, selectScope, parentId || undefined, parentName || undefined)); + } + setActions(actions); + }); + } else { + const leafNodes = nodes.filter((node) => node.spec.nodeType === 'leaf'); + const actions = [getScopesParentAction()]; + for (const node of leafNodes) { + actions.push(mapScopeNodeToAction(node, selectScope, parentId || undefined)); + } + setActions(actions); } - setActions(actions); } }); } else { searchQueryRef.current = undefined; setActions(undefined); } - }, [searchAllNodes, searchQuery, parentId, selectScope]); + }, [searchAllNodes, searchQuery, parentId, selectScope, getScopeNodes]); return actions; } diff --git a/public/app/features/commandPalette/actions/scopesUtils.test.ts b/public/app/features/commandPalette/actions/scopesUtils.test.ts index 0fe22c0f0b3..411497240fc 100644 --- a/public/app/features/commandPalette/actions/scopesUtils.test.ts +++ b/public/app/features/commandPalette/actions/scopesUtils.test.ts @@ -30,6 +30,7 @@ describe('mapScopeNodeToAction', () => { priority: SCOPES_PRIORITY, parent: 'parent1', perform: expect.any(Function), + subtitle: 'Parent Scope', }); }); @@ -43,6 +44,7 @@ describe('mapScopeNodeToAction', () => { keywords: 'Scope 1 scope1', priority: SCOPES_PRIORITY, parent: 'parent1', + subtitle: 'Parent Scope', }); // Non-leaf nodes don't have a perform function diff --git a/public/app/features/commandPalette/actions/scopesUtils.ts b/public/app/features/commandPalette/actions/scopesUtils.ts index 65c27a0c0c1..2848569247a 100644 --- a/public/app/features/commandPalette/actions/scopesUtils.ts +++ b/public/app/features/commandPalette/actions/scopesUtils.ts @@ -18,6 +18,7 @@ export function useScopeServicesState() { selectScope: () => {}, resetSelection: () => {}, searchAllNodes: () => Promise.resolve([]), + getScopeNodes: (_: string[]) => Promise.resolve([]), apply: () => {}, deselectScope: () => {}, nodes: {}, @@ -31,7 +32,7 @@ export function useScopeServicesState() { }, }; } - const { updateNode, filterNode, selectScope, resetSelection, searchAllNodes, deselectScope, apply } = + const { updateNode, filterNode, selectScope, resetSelection, searchAllNodes, deselectScope, apply, getScopeNodes } = services.scopesSelectorService; const selectorServiceState: ScopesSelectorServiceState | undefined = useObservable( services.scopesSelectorService.stateObservable ?? new Observable(), @@ -39,6 +40,7 @@ export function useScopeServicesState() { ); return { + getScopeNodes, filterNode, updateNode, selectScope, @@ -90,7 +92,12 @@ export function mapScopesNodesTreeToActions( if (child.spec.nodeType === 'leaf' && scopeIsSelected) { continue; } - let action = mapScopeNodeToAction(child, selectScope, parentId); + let action = mapScopeNodeToAction( + child, + selectScope, + parentId, + child.spec.parentName ? nodes[child.spec.parentName]?.spec.title : undefined + ); actions.push(action); traverse(childTreeNode, action.id); } @@ -110,13 +117,16 @@ export function mapScopesNodesTreeToActions( export function mapScopeNodeToAction( scopeNode: ScopeNode, selectScope: (id: string) => void, - parentId?: string + parentId?: string, + parentName?: string ): CommandPaletteAction { let action: CommandPaletteAction; + const subtitle = parentName || scopeNode.spec.parentName || undefined; if (parentId) { action = { id: `${parentId}/${scopeNode.metadata.name}`, name: scopeNode.spec.title, + subtitle: subtitle, keywords: `${scopeNode.spec.title} ${scopeNode.metadata.name}`, priority: SCOPES_PRIORITY, parent: parentId, @@ -135,7 +145,7 @@ export function mapScopeNodeToAction( keywords: `${scopeNode.spec.title} ${scopeNode.metadata.name}`, priority: SCOPES_PRIORITY, section: t('command-palette.action.scopes', 'Scopes'), - subtitle: scopeNode.spec.parentName, + subtitle: subtitle, perform: () => { selectScope(scopeNode.metadata.name); }, diff --git a/public/app/features/scopes/ScopesApiClient.ts b/public/app/features/scopes/ScopesApiClient.ts index 00f9923705d..5c1a43fd8ec 100644 --- a/public/app/features/scopes/ScopesApiClient.ts +++ b/public/app/features/scopes/ScopesApiClient.ts @@ -26,6 +26,21 @@ export class ScopesApiClient { return scopes.filter((scope) => scope !== undefined); } + async fetchMultipleScopeNodes(names: string[]): Promise { + if (!config.featureToggles.useMultipleScopeNodesEndpoint || names.length === 0) { + return Promise.resolve([]); + } + + try { + const res = await getBackendSrv().get<{ items: ScopeNode[] }>(apiUrl + `/find/scope_node_children`, { + names: names, + }); + return res?.items ?? []; + } catch (err) { + return []; + } + } + /** * Fetches a map of nodes based on the specified options. * diff --git a/public/app/features/scopes/selector/ScopesSelectorService.ts b/public/app/features/scopes/selector/ScopesSelectorService.ts index 31db178033a..7e8f1ae060a 100644 --- a/public/app/features/scopes/selector/ScopesSelectorService.ts +++ b/public/app/features/scopes/selector/ScopesSelectorService.ts @@ -508,6 +508,30 @@ export class ScopesSelectorService extends ScopesServiceBase => { + const nodesMap: NodesMap = {}; + // Get nodes that are already in the cache + for (const name of scopeNodeNames) { + if (this.state.nodes[name]) { + nodesMap[name] = this.state.nodes[name]; + } + } + + // 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; + } + + const newNodes = { ...this.state.nodes, ...nodesMap }; + + // Return both caches and fetches nodes in the correct order + this.updateState({ nodes: newNodes }); + return scopeNodeNames.map((name) => nodesMap[name]).filter((node) => node !== undefined); + }; } function isScopeLocalStorageV1(obj: unknown): obj is { scope: Scope } {