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:
Tobias Skarhed 2025-09-30 21:46:06 +02:00 committed by GitHub
parent 7055ba9140
commit 5639ecf711
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 122 additions and 11 deletions

View File

@ -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
*/ */

View File

@ -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.",

View File

@ -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

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
106 alertingSaveStateCompressed preview @grafana/alerting-squad false false false
107 scopeApi experimental @grafana/grafana-app-platform-squad false false false
108 useScopeSingleNodeEndpoint experimental @grafana/grafana-operator-experience-squad false false true
109 useMultipleScopeNodesEndpoint experimental @grafana/grafana-operator-experience-squad false false true
110 promQLScope GA @grafana/oss-big-tent false false false
111 logQLScope privatePreview @grafana/observability-logs false false false
112 sqlExpressions preview @grafana/grafana-datasources-core-services false false false

View File

@ -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"

View File

@ -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",

View File

@ -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',
}, },
]; ];

View File

@ -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,19 +151,42 @@ 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 leafNodes = nodes.filter((node) => node.spec.nodeType === 'leaf');
const actions = [getScopesParentAction()]; const parentNodesMap = new Map<string | undefined, string>();
for (const node of leafNodes) {
actions.push(mapScopeNodeToAction(node, selectScope, parentId || undefined)); 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 { } else {
searchQueryRef.current = undefined; searchQueryRef.current = undefined;
setActions(undefined); setActions(undefined);
} }
}, [searchAllNodes, searchQuery, parentId, selectScope]); }, [searchAllNodes, searchQuery, parentId, selectScope, getScopeNodes]);
return actions; return actions;
} }

View File

@ -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

View File

@ -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);
}, },

View File

@ -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.
* *

View File

@ -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 } {