mirror of https://github.com/grafana/grafana.git
				
				
				
			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
This commit is contained in:
		
							parent
							
								
									7055ba9140
								
							
						
					
					
						commit
						5639ecf711
					
				|  | @ -479,6 +479,11 @@ export interface FeatureToggles { | ||||||
|   */ |   */ | ||||||
|   useScopeSingleNodeEndpoint?: boolean; |   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. |   * In-development feature that will allow injection of labels into prometheus queries. | ||||||
|   * @default true |   * @default true | ||||||
|   */ |   */ | ||||||
|  |  | ||||||
|  | @ -806,6 +806,16 @@ var ( | ||||||
| 			HideFromDocs:      true, | 			HideFromDocs:      true, | ||||||
| 			HideFromAdminPage: 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", | 			Name:              "promQLScope", | ||||||
| 			Description:       "In-development feature that will allow injection of labels into prometheus queries.", | 			Description:       "In-development feature that will allow injection of labels into prometheus queries.", | ||||||
|  |  | ||||||
|  | @ -106,6 +106,7 @@ alertingSaveStatePeriodic,privatePreview,@grafana/alerting-squad,false,false,fal | ||||||
| alertingSaveStateCompressed,preview,@grafana/alerting-squad,false,false,false | alertingSaveStateCompressed,preview,@grafana/alerting-squad,false,false,false | ||||||
| scopeApi,experimental,@grafana/grafana-app-platform-squad,false,false,false | scopeApi,experimental,@grafana/grafana-app-platform-squad,false,false,false | ||||||
| useScopeSingleNodeEndpoint,experimental,@grafana/grafana-operator-experience-squad,false,false,true | 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 | promQLScope,GA,@grafana/oss-big-tent,false,false,false | ||||||
| logQLScope,privatePreview,@grafana/observability-logs,false,false,false | logQLScope,privatePreview,@grafana/observability-logs,false,false,false | ||||||
| sqlExpressions,preview,@grafana/grafana-datasources-core-services,false,false,false | sqlExpressions,preview,@grafana/grafana-datasources-core-services,false,false,false | ||||||
|  |  | ||||||
| 
 | 
|  | @ -435,6 +435,10 @@ const ( | ||||||
| 	// Use the single node endpoint for the scope api. This is used to fetch the scope parent node.
 | 	// Use the single node endpoint for the scope api. This is used to fetch the scope parent node.
 | ||||||
| 	FlagUseScopeSingleNodeEndpoint = "useScopeSingleNodeEndpoint" | 	FlagUseScopeSingleNodeEndpoint = "useScopeSingleNodeEndpoint" | ||||||
| 
 | 
 | ||||||
|  | 	// FlagUseMultipleScopeNodesEndpoint
 | ||||||
|  | 	// Makes the frontend use the 'names' param for fetching multiple scope nodes at once
 | ||||||
|  | 	FlagUseMultipleScopeNodesEndpoint = "useMultipleScopeNodesEndpoint" | ||||||
|  | 
 | ||||||
| 	// FlagPromQLScope
 | 	// FlagPromQLScope
 | ||||||
| 	// In-development feature that will allow injection of labels into prometheus queries.
 | 	// In-development feature that will allow injection of labels into prometheus queries.
 | ||||||
| 	FlagPromQLScope = "promQLScope" | 	FlagPromQLScope = "promQLScope" | ||||||
|  |  | ||||||
|  | @ -3845,6 +3845,22 @@ | ||||||
|         "frontend": true |         "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": { |       "metadata": { | ||||||
|         "name": "useScopeSingleNodeEndpoint", |         "name": "useScopeSingleNodeEndpoint", | ||||||
|  |  | ||||||
|  | @ -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.
 |         // 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.
 |         // In the previous test the scope actions were mapped to global level to show correctly.
 | ||||||
|         parent: 'scopes', |         parent: 'scopes', | ||||||
|  |         subtitle: 'some parent', | ||||||
|       }, |       }, | ||||||
|     ]; |     ]; | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -139,7 +139,7 @@ function useScopesRow(onApply: () => void) { | ||||||
|  * @param parentId |  * @param parentId | ||||||
|  */ |  */ | ||||||
| function useGlobalScopesSearch(searchQuery: string, parentId?: string | null) { | function useGlobalScopesSearch(searchQuery: string, parentId?: string | null) { | ||||||
|   const { selectScope, searchAllNodes } = useScopeServicesState(); |   const { selectScope, searchAllNodes, getScopeNodes } = useScopeServicesState(); | ||||||
|   const [actions, setActions] = useState<CommandPaletteAction[] | undefined>(undefined); |   const [actions, setActions] = useState<CommandPaletteAction[] | undefined>(undefined); | ||||||
|   const searchQueryRef = useRef<string>(); |   const searchQueryRef = useRef<string>(); | ||||||
| 
 | 
 | ||||||
|  | @ -151,6 +151,28 @@ function useGlobalScopesSearch(searchQuery: string, parentId?: string | null) { | ||||||
|         if (searchQueryRef.current === searchQuery) { |         if (searchQueryRef.current === searchQuery) { | ||||||
|           // Only show leaf nodes because otherwise there are issues with navigating to a category without knowing
 |           // Only show leaf nodes because otherwise there are issues with navigating to a category without knowing
 | ||||||
|           // where in the tree it is.
 |           // where in the tree it is.
 | ||||||
|  | 
 | ||||||
|  |           const parentNodesMap = new Map<string | undefined, string>(); | ||||||
|  | 
 | ||||||
|  |           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 leafNodes = nodes.filter((node) => node.spec.nodeType === 'leaf'); | ||||||
|             const actions = [getScopesParentAction()]; |             const actions = [getScopesParentAction()]; | ||||||
|             for (const node of leafNodes) { |             for (const node of leafNodes) { | ||||||
|  | @ -158,12 +180,13 @@ function useGlobalScopesSearch(searchQuery: string, parentId?: string | null) { | ||||||
|             } |             } | ||||||
|             setActions(actions); |             setActions(actions); | ||||||
|           } |           } | ||||||
|  |         } | ||||||
|       }); |       }); | ||||||
|     } else { |     } else { | ||||||
|       searchQueryRef.current = undefined; |       searchQueryRef.current = undefined; | ||||||
|       setActions(undefined); |       setActions(undefined); | ||||||
|     } |     } | ||||||
|   }, [searchAllNodes, searchQuery, parentId, selectScope]); |   }, [searchAllNodes, searchQuery, parentId, selectScope, getScopeNodes]); | ||||||
| 
 | 
 | ||||||
|   return actions; |   return actions; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -30,6 +30,7 @@ describe('mapScopeNodeToAction', () => { | ||||||
|       priority: SCOPES_PRIORITY, |       priority: SCOPES_PRIORITY, | ||||||
|       parent: 'parent1', |       parent: 'parent1', | ||||||
|       perform: expect.any(Function), |       perform: expect.any(Function), | ||||||
|  |       subtitle: 'Parent Scope', | ||||||
|     }); |     }); | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|  | @ -43,6 +44,7 @@ describe('mapScopeNodeToAction', () => { | ||||||
|       keywords: 'Scope 1 scope1', |       keywords: 'Scope 1 scope1', | ||||||
|       priority: SCOPES_PRIORITY, |       priority: SCOPES_PRIORITY, | ||||||
|       parent: 'parent1', |       parent: 'parent1', | ||||||
|  |       subtitle: 'Parent Scope', | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     // Non-leaf nodes don't have a perform function
 |     // Non-leaf nodes don't have a perform function
 | ||||||
|  |  | ||||||
|  | @ -18,6 +18,7 @@ export function useScopeServicesState() { | ||||||
|       selectScope: () => {}, |       selectScope: () => {}, | ||||||
|       resetSelection: () => {}, |       resetSelection: () => {}, | ||||||
|       searchAllNodes: () => Promise.resolve([]), |       searchAllNodes: () => Promise.resolve([]), | ||||||
|  |       getScopeNodes: (_: string[]) => Promise.resolve([]), | ||||||
|       apply: () => {}, |       apply: () => {}, | ||||||
|       deselectScope: () => {}, |       deselectScope: () => {}, | ||||||
|       nodes: {}, |       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; |     services.scopesSelectorService; | ||||||
|   const selectorServiceState: ScopesSelectorServiceState | undefined = useObservable( |   const selectorServiceState: ScopesSelectorServiceState | undefined = useObservable( | ||||||
|     services.scopesSelectorService.stateObservable ?? new Observable(), |     services.scopesSelectorService.stateObservable ?? new Observable(), | ||||||
|  | @ -39,6 +40,7 @@ export function useScopeServicesState() { | ||||||
|   ); |   ); | ||||||
| 
 | 
 | ||||||
|   return { |   return { | ||||||
|  |     getScopeNodes, | ||||||
|     filterNode, |     filterNode, | ||||||
|     updateNode, |     updateNode, | ||||||
|     selectScope, |     selectScope, | ||||||
|  | @ -90,7 +92,12 @@ export function mapScopesNodesTreeToActions( | ||||||
|       if (child.spec.nodeType === 'leaf' && scopeIsSelected) { |       if (child.spec.nodeType === 'leaf' && scopeIsSelected) { | ||||||
|         continue; |         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); |       actions.push(action); | ||||||
|       traverse(childTreeNode, action.id); |       traverse(childTreeNode, action.id); | ||||||
|     } |     } | ||||||
|  | @ -110,13 +117,16 @@ export function mapScopesNodesTreeToActions( | ||||||
| export function mapScopeNodeToAction( | export function mapScopeNodeToAction( | ||||||
|   scopeNode: ScopeNode, |   scopeNode: ScopeNode, | ||||||
|   selectScope: (id: string) => void, |   selectScope: (id: string) => void, | ||||||
|   parentId?: string |   parentId?: string, | ||||||
|  |   parentName?: string | ||||||
| ): CommandPaletteAction { | ): CommandPaletteAction { | ||||||
|   let action: CommandPaletteAction; |   let action: CommandPaletteAction; | ||||||
|  |   const subtitle = parentName || scopeNode.spec.parentName || undefined; | ||||||
|   if (parentId) { |   if (parentId) { | ||||||
|     action = { |     action = { | ||||||
|       id: `${parentId}/${scopeNode.metadata.name}`, |       id: `${parentId}/${scopeNode.metadata.name}`, | ||||||
|       name: scopeNode.spec.title, |       name: scopeNode.spec.title, | ||||||
|  |       subtitle: subtitle, | ||||||
|       keywords: `${scopeNode.spec.title} ${scopeNode.metadata.name}`, |       keywords: `${scopeNode.spec.title} ${scopeNode.metadata.name}`, | ||||||
|       priority: SCOPES_PRIORITY, |       priority: SCOPES_PRIORITY, | ||||||
|       parent: parentId, |       parent: parentId, | ||||||
|  | @ -135,7 +145,7 @@ export function mapScopeNodeToAction( | ||||||
|       keywords: `${scopeNode.spec.title} ${scopeNode.metadata.name}`, |       keywords: `${scopeNode.spec.title} ${scopeNode.metadata.name}`, | ||||||
|       priority: SCOPES_PRIORITY, |       priority: SCOPES_PRIORITY, | ||||||
|       section: t('command-palette.action.scopes', 'Scopes'), |       section: t('command-palette.action.scopes', 'Scopes'), | ||||||
|       subtitle: scopeNode.spec.parentName, |       subtitle: subtitle, | ||||||
|       perform: () => { |       perform: () => { | ||||||
|         selectScope(scopeNode.metadata.name); |         selectScope(scopeNode.metadata.name); | ||||||
|       }, |       }, | ||||||
|  |  | ||||||
|  | @ -26,6 +26,21 @@ export class ScopesApiClient { | ||||||
|     return scopes.filter((scope) => scope !== undefined); |     return scopes.filter((scope) => scope !== undefined); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   async fetchMultipleScopeNodes(names: string[]): Promise<ScopeNode[]> { | ||||||
|  |     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. |    * Fetches a map of nodes based on the specified options. | ||||||
|    * |    * | ||||||
|  |  | ||||||
|  | @ -508,6 +508,30 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi | ||||||
|     this.updateState({ nodes: newNodes }); |     this.updateState({ nodes: newNodes }); | ||||||
|     return scopeNodes; |     return scopeNodes; | ||||||
|   }; |   }; | ||||||
|  | 
 | ||||||
|  |   public getScopeNodes = async (scopeNodeNames: string[]): Promise<ScopeNode[]> => { | ||||||
|  |     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 } { | function isScopeLocalStorageV1(obj: unknown): obj is { scope: Scope } { | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue