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

View File

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

View File

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

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

View File

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

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.
// In the previous test the scope actions were mapped to global level to show correctly.
parent: 'scopes',
subtitle: 'some parent',
},
];

View File

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

View File

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

View File

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

View File

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

View File

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