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; | ||||
|   /** | ||||
|   * 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 | ||||
|   */ | ||||
|  |  | |||
|  | @ -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.", | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
| 
 | 
|  | @ -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" | ||||
|  |  | |||
|  | @ -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", | ||||
|  |  | |||
|  | @ -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', | ||||
|       }, | ||||
|     ]; | ||||
| 
 | ||||
|  |  | |||
|  | @ -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<CommandPaletteAction[] | undefined>(undefined); | ||||
|   const searchQueryRef = useRef<string>(); | ||||
| 
 | ||||
|  | @ -151,6 +151,28 @@ 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 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 actions = [getScopesParentAction()]; | ||||
|             for (const node of leafNodes) { | ||||
|  | @ -158,12 +180,13 @@ function useGlobalScopesSearch(searchQuery: string, parentId?: string | null) { | |||
|             } | ||||
|             setActions(actions); | ||||
|           } | ||||
|         } | ||||
|       }); | ||||
|     } else { | ||||
|       searchQueryRef.current = undefined; | ||||
|       setActions(undefined); | ||||
|     } | ||||
|   }, [searchAllNodes, searchQuery, parentId, selectScope]); | ||||
|   }, [searchAllNodes, searchQuery, parentId, selectScope, getScopeNodes]); | ||||
| 
 | ||||
|   return actions; | ||||
| } | ||||
|  |  | |||
|  | @ -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
 | ||||
|  |  | |||
|  | @ -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); | ||||
|       }, | ||||
|  |  | |||
|  | @ -26,6 +26,21 @@ export class ScopesApiClient { | |||
|     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. | ||||
|    * | ||||
|  |  | |||
|  | @ -508,6 +508,30 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi | |||
|     this.updateState({ nodes: newNodes }); | ||||
|     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 } { | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue