From 972e2f31e5f3dfd8d9f8152b3bb1fe7224e5d1e1 Mon Sep 17 00:00:00 2001 From: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com> Date: Thu, 31 Jul 2025 16:32:41 +0200 Subject: [PATCH] ScopesInput: Display parent and use + as separator (#107698) * Display group and use + as separator * Set/get parent node from URL * Add hook useScopeNode * Add loading state and Skeleton * Add preload and actually commit hook * Add preload * Add feature flag * Update test * Fix test * Fix tree test --- .../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 +++++++ public/app/features/scopes/ScopesApiClient.ts | 14 +++++- public/app/features/scopes/ScopesService.ts | 27 +++++++++-- .../features/scopes/selector/ScopesInput.tsx | 47 ++++++++++++++----- .../scopes/selector/ScopesSelectorService.ts | 30 ++++++++++-- public/app/features/scopes/selector/types.ts | 2 + .../features/scopes/selector/useScopeNode.ts | 33 +++++++++++++ .../features/scopes/tests/selector.test.ts | 2 +- public/app/features/scopes/tests/tree.test.ts | 6 +-- 13 files changed, 172 insertions(+), 25 deletions(-) create mode 100644 public/app/features/scopes/selector/useScopeNode.ts diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index f8d6a827c20..87a3dee8d06 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -457,6 +457,11 @@ export interface FeatureToggles { */ scopeApi?: boolean; /** + * Use the single node endpoint for the scope api. This is used to fetch the scope parent node. + * @default false + */ + useScopeSingleNodeEndpoint?: 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 661313c4d30..9a0c468f0f1 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -769,6 +769,16 @@ var ( HideFromAdminPage: true, Expression: "false", }, + { + Name: "useScopeSingleNodeEndpoint", + Description: "Use the single node endpoint for the scope api. This is used to fetch the scope parent node.", + 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 0b7c3e381db..14f65065ed9 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -101,6 +101,7 @@ secretsManagementAppPlatform,experimental,@grafana/grafana-operator-experience-s alertingSaveStatePeriodic,privatePreview,@grafana/alerting-squad,false,false,false 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 promQLScope,GA,@grafana/oss-big-tent,false,false,false logQLScope,privatePreview,@grafana/observability-logs,false,false,false sqlExpressions,privatePreview,@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 9aee8de5873..c74ce4befc4 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -415,6 +415,10 @@ const ( // In-development feature flag for the scope api using the app platform. FlagScopeApi = "scopeApi" + // FlagUseScopeSingleNodeEndpoint + // Use the single node endpoint for the scope api. This is used to fetch the scope parent node. + FlagUseScopeSingleNodeEndpoint = "useScopeSingleNodeEndpoint" + // 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 e085dda6279..2324d34c0f8 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -3242,6 +3242,22 @@ "hideFromDocs": true } }, + { + "metadata": { + "name": "useScopeSingleNodeEndpoint", + "resourceVersion": "1753960766702", + "creationTimestamp": "2025-07-31T11:19:26Z" + }, + "spec": { + "description": "Use the single node endpoint for the scope api. This is used to fetch the scope parent node.", + "stage": "experimental", + "codeowner": "@grafana/grafana-operator-experience-squad", + "frontend": true, + "hideFromAdminPage": true, + "hideFromDocs": true, + "expression": "false" + } + }, { "metadata": { "name": "useScopesNavigationEndpoint", diff --git a/public/app/features/scopes/ScopesApiClient.ts b/public/app/features/scopes/ScopesApiClient.ts index 9f90e0c62fe..00f9923705d 100644 --- a/public/app/features/scopes/ScopesApiClient.ts +++ b/public/app/features/scopes/ScopesApiClient.ts @@ -1,5 +1,5 @@ import { Scope, ScopeDashboardBinding, ScopeNode } from '@grafana/data'; -import { getBackendSrv } from '@grafana/runtime'; +import { getBackendSrv, config } from '@grafana/runtime'; import { getAPINamespace } from '../../api/utils'; @@ -84,4 +84,16 @@ export class ScopesApiClient { return []; } }; + + public fetchScopeNode = async (scopeNodeId: string): Promise => { + if (!config.featureToggles.useScopeSingleNodeEndpoint) { + return Promise.resolve(undefined); + } + try { + const response = await getBackendSrv().get(apiUrl + `/scopenodes/${scopeNodeId}`); + return response; + } catch (err) { + return undefined; + } + }; } diff --git a/public/app/features/scopes/ScopesService.ts b/public/app/features/scopes/ScopesService.ts index e586c1666de..5c5d4b33147 100644 --- a/public/app/features/scopes/ScopesService.ts +++ b/public/app/features/scopes/ScopesService.ts @@ -70,7 +70,16 @@ export class ScopesService implements ScopesContextValue { // Init from the URL when we first load const queryParams = new URLSearchParams(locationService.getLocation().search); - this.changeScopes(queryParams.getAll('scopes')); + const parentNodeId = queryParams.get('scope_parent'); + + this.changeScopes(queryParams.getAll('scopes'), parentNodeId ?? undefined); + + // Pre-load parent node, to prevent UI flickering + if (parentNodeId) { + this.selectorService.getScopeNode(parentNodeId).catch((error) => { + console.error('Failed to pre-load parent node', error); + }); + } // Update scopes state based on URL. this.subscriptions.push( @@ -80,13 +89,16 @@ export class ScopesService implements ScopesContextValue { return; } const queryParams = new URLSearchParams(location.search); + + // If we have a parent node in the URL, fetch and expand it + const parentNode = queryParams.get('scope_parent'); const scopes = queryParams.getAll('scopes'); - //const scopesFromState = this.state.value.map((scope) => scope.metadata.name); + if (scopes.length) { // We only update scopes but never delete them. This is to keep the scopes in memory if user navigates to // page that does not use scopes (like from dashboard to dashboard list back to dashboard). If user // changes the URL directly, it would trigger a reload so scopes would still be reset. - this.changeScopes(scopes); + this.changeScopes(scopes, parentNode ?? undefined); } }) ); @@ -94,6 +106,12 @@ export class ScopesService implements ScopesContextValue { // Update the URL based on change in the scopes state this.subscriptions.push( selectorService.subscribeToState((state, prev) => { + const oldParentNode = prev.appliedScopes[0]?.parentNodeId; + const newParentNode = state.appliedScopes[0]?.parentNodeId; + if (oldParentNode !== newParentNode && newParentNode) { + this.locationService.partial({ scope_parent: newParentNode }, true); + } + const oldScopeNames = prev.appliedScopes.map((scope) => scope.scopeId); const newScopeNames = state.appliedScopes.map((scope) => scope.scopeId); if (!isEqual(oldScopeNames, newScopeNames)) { @@ -124,7 +142,8 @@ export class ScopesService implements ScopesContextValue { return this._stateObservable; } - public changeScopes = (scopeNames: string[]) => this.selectorService.changeScopes(scopeNames); + public changeScopes = (scopeNames: string[], parentNodeId?: string) => + this.selectorService.changeScopes(scopeNames, parentNodeId); public setReadOnly = (readOnly: boolean) => { if (this.state.readOnly !== readOnly) { diff --git a/public/app/features/scopes/selector/ScopesInput.tsx b/public/app/features/scopes/selector/ScopesInput.tsx index 13e0564bc79..6aed84a83e9 100644 --- a/public/app/features/scopes/selector/ScopesInput.tsx +++ b/public/app/features/scopes/selector/ScopesInput.tsx @@ -1,5 +1,6 @@ import { css } from '@emotion/css'; import { useEffect, useMemo, useState } from 'react'; +import Skeleton from 'react-loading-skeleton'; import { GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; @@ -7,6 +8,7 @@ import { IconButton, Input, Tooltip, useStyles2 } from '@grafana/ui'; import { getPathOfNode } from './scopesTreeUtils'; import { NodesMap, ScopesMap, SelectedScope } from './types'; +import { useScopeNode } from './useScopeNode'; export interface ScopesInputProps { nodes: NodesMap; @@ -32,6 +34,10 @@ export function ScopesInput({ }: ScopesInputProps) { const [tooltipVisible, setTooltipVisible] = useState(false); + const parentNodeId = appliedScopes[0]?.parentNodeId; + const { node: parentNode, isLoading: parentNodeLoading } = useScopeNode(parentNodeId); + const parentNodeTitle = parentNode?.spec.title; + useEffect(() => { setTooltipVisible(false); }, [appliedScopes]); @@ -47,10 +53,20 @@ export function ScopesInput({ // If we are still loading the scope data just show the id scopes[s.scopeId]?.spec.title || s.scopeId ) - .join(', '), + .join(' + '), [appliedScopes, scopes] ); + const parentNodePrefix = useMemo( + () => + parentNodeLoading ? ( + + ) : parentNodeTitle ? ( + {parentNodeTitle}: + ) : undefined, + [parentNodeLoading, parentNodeTitle] + ); + const input = useMemo( () => ( 0 && !disabled ? ( ), - [disabled, loading, onInputClick, onRemoveAllClick, appliedScopes, scopesTitles] + [disabled, loading, onInputClick, onRemoveAllClick, appliedScopes, scopesTitles, parentNodePrefix] ); return ( @@ -90,6 +107,21 @@ export function ScopesInput({ ); } +const getScopesPath = (appliedScopes: SelectedScope[], nodes: NodesMap) => { + let nicePath: string[] | undefined; + + if (appliedScopes.length > 0 && appliedScopes[0].scopeNodeId) { + let path = getPathOfNode(appliedScopes[0].scopeNodeId, nodes); + // Get reed of empty root section and the actual scope node + path = path.slice(1, -1); + + // We may not have all the nodes in path loaded + nicePath = path.map((p) => nodes[p]?.spec.title).filter((p) => p); + } + + return nicePath; +}; + export interface ScopesTooltipProps { nodes: NodesMap; scopes: ScopesMap; @@ -99,16 +131,7 @@ export interface ScopesTooltipProps { function ScopesTooltip({ nodes, scopes, appliedScopes }: ScopesTooltipProps) { const styles = useStyles2(getStyles); - let nicePath: string[] | undefined; - - if (appliedScopes[0].scopeNodeId) { - let path = getPathOfNode(appliedScopes[0].scopeNodeId, nodes); - // Get reed of empty root section and the actual scope node - path = path.slice(1, -1); - - // We may not have all the nodes in path loaded - nicePath = path.map((p) => nodes[p]?.spec.title).filter((p) => p); - } + const nicePath = getScopesPath(appliedScopes, nodes); const scopeNames = appliedScopes.map((s) => { if (s.scopeNodeId) { diff --git a/public/app/features/scopes/selector/ScopesSelectorService.ts b/public/app/features/scopes/selector/ScopesSelectorService.ts index fdc4c3d230a..b8daece424c 100644 --- a/public/app/features/scopes/selector/ScopesSelectorService.ts +++ b/public/app/features/scopes/selector/ScopesSelectorService.ts @@ -71,6 +71,24 @@ export class ScopesSelectorService extends ScopesServiceBase { + if (this.state.nodes[scopeNodeId]) { + return this.state.nodes[scopeNodeId]; + } + + try { + const node = await this.apiClient.fetchScopeNode(scopeNodeId); + if (node) { + this.updateState({ nodes: { ...this.state.nodes, [node.metadata.name]: node } }); + } + return node; + } catch (error) { + console.error('Failed to load node', error); + return undefined; + } + }; + private expandOrFilterNode = async (scopeNodeId: string, query?: string) => { const path = getPathOfNode(scopeNodeId, this.state.nodes); @@ -160,11 +178,15 @@ export class ScopesSelectorService extends ScopesServiceBase { - return this.applyScopes(scopeNames.map((id) => ({ scopeId: id }))); + changeScopes = (scopeNames: string[], parentNodeId?: string) => { + return this.applyScopes(scopeNames.map((id) => ({ scopeId: id, parentNodeId: parentNodeId }))); }; /** diff --git a/public/app/features/scopes/selector/types.ts b/public/app/features/scopes/selector/types.ts index d0b109ab08c..f30b7e04868 100644 --- a/public/app/features/scopes/selector/types.ts +++ b/public/app/features/scopes/selector/types.ts @@ -6,6 +6,8 @@ export type ScopesMap = Record; export interface SelectedScope { scopeId: string; scopeNodeId?: string; + // Used to display title next to selected scope + parentNodeId?: string; } export interface TreeNode { diff --git a/public/app/features/scopes/selector/useScopeNode.ts b/public/app/features/scopes/selector/useScopeNode.ts new file mode 100644 index 00000000000..30bed4886ff --- /dev/null +++ b/public/app/features/scopes/selector/useScopeNode.ts @@ -0,0 +1,33 @@ +import { useEffect, useState } from 'react'; + +import { ScopeNode } from '@grafana/data'; + +import { useScopesServices } from '../ScopesContextProvider'; + +// Light wrapper around the scopesSelectorService.getScopeNode to make it easier to use in the UI. +export function useScopeNode(scopeNodeId?: string) { + const [node, setNode] = useState(undefined); + const [isLoading, setIsLoading] = useState(false); + const scopesSelectorService = useScopesServices()?.scopesSelectorService; + + useEffect(() => { + const loadNode = async () => { + if (!scopeNodeId || !scopesSelectorService) { + setNode(undefined); + return; + } + setIsLoading(true); + try { + const node = await scopesSelectorService.getScopeNode(scopeNodeId); + setNode(node); + } catch (error) { + console.error('Failed to load node', error); + } finally { + setIsLoading(false); + } + }; + loadNode(); + }, [scopeNodeId, scopesSelectorService]); + + return { node, isLoading }; +} diff --git a/public/app/features/scopes/tests/selector.test.ts b/public/app/features/scopes/tests/selector.test.ts index edfda65b24a..9f9b5e55596 100644 --- a/public/app/features/scopes/tests/selector.test.ts +++ b/public/app/features/scopes/tests/selector.test.ts @@ -115,7 +115,7 @@ describe('Selector', () => { expectRecentScopeNotPresent('Mimir'); await selectRecentScope('Grafana, Mimir'); - expectScopesSelectorValue('Grafana, Mimir'); + expectScopesSelectorValue('Grafana + Mimir'); }); it('recent scopes should not be visible when the first scope is selected', async () => { diff --git a/public/app/features/scopes/tests/tree.test.ts b/public/app/features/scopes/tests/tree.test.ts index 71a48dcd203..a89c2e78910 100644 --- a/public/app/features/scopes/tests/tree.test.ts +++ b/public/app/features/scopes/tests/tree.test.ts @@ -103,7 +103,7 @@ describe('Tree', () => { await selectResultApplicationsMimir(); await selectResultApplicationsCloud(); await applyScopes(); - expectScopesSelectorValue('Grafana, Mimir, Cloud'); + expectScopesSelectorValue('Grafana + Mimir + Cloud'); }); it('Can select a node from an inner level', async () => { @@ -225,7 +225,7 @@ describe('Tree', () => { await selectResultApplicationsGrafana(); await applyScopes(); - expectScopesSelectorValue('Mimir, Grafana'); + expectScopesSelectorValue('Mimir + Grafana'); }); it('Deselects a persisted scope', async () => { @@ -237,7 +237,7 @@ describe('Tree', () => { await selectResultApplicationsGrafana(); await applyScopes(); - expectScopesSelectorValue('Mimir, Grafana'); + expectScopesSelectorValue('Mimir + Grafana'); await openSelector(); await selectPersistedApplicationsMimir();