Scopes: Move url sync from scenes to the ScopesSelectorService (#102469)

* Add url sync

* Move the URL sync handling to ScopesService to keep persistence

* Reset location after tests

* Prettier

* Do url replace on enabling scopes

* Remove comment

* Update scenes version
This commit is contained in:
Andrej Ocenas
2025-03-24 11:00:50 +01:00
committed by GitHub
parent d25dba8d0a
commit 5d2dbd7883
9 changed files with 255 additions and 114 deletions
+2 -2
View File
@@ -277,8 +277,8 @@
"@grafana/prometheus": "workspace:*",
"@grafana/runtime": "workspace:*",
"@grafana/saga-icons": "workspace:*",
"@grafana/scenes": "6.5.2",
"@grafana/scenes-react": "6.5.2",
"@grafana/scenes": "6.5.3",
"@grafana/scenes-react": "6.5.3",
"@grafana/schema": "workspace:*",
"@grafana/sql": "workspace:*",
"@grafana/ui": "workspace:*",
@@ -62,6 +62,10 @@ export class ScopesApiClient {
});
}
/**
* @param parent
* @param query Filters by title substring
*/
async fetchNode(parent: string, query: string): Promise<NodesMap> {
try {
const nodes =
@@ -1,6 +1,6 @@
import { createContext, ReactNode, useMemo, useContext } from 'react';
import { createContext, ReactNode, useMemo, useContext, useEffect } from 'react';
import { config, ScopesContext } from '@grafana/runtime';
import { config, locationService, ScopesContext } from '@grafana/runtime';
import { ScopesApiClient } from './ScopesApiClient';
import { ScopesService } from './ScopesService';
@@ -36,7 +36,7 @@ export function defaultScopesServices() {
const dashboardService = new ScopesDashboardsService(client);
const selectorService = new ScopesSelectorService(client, dashboardService);
return {
scopesService: new ScopesService(selectorService, dashboardService),
scopesService: new ScopesService(selectorService, dashboardService, locationService),
scopesSelectorService: selectorService,
scopesDashboardsService: dashboardService,
client,
@@ -48,6 +48,12 @@ export const ScopesContextProvider = ({ children, services }: ScopesContextProvi
return services ?? defaultScopesServices();
}, [services]);
useEffect(() => {
return () => {
memoizedServices.scopesService.cleanUp();
};
}, [memoizedServices]);
return (
<ScopesContext.Provider value={config.featureToggles.scopeFilters ? memoizedServices.scopesService : undefined}>
<ScopesServicesContext.Provider value={config.featureToggles.scopeFilters ? memoizedServices : undefined}>
+80 -20
View File
@@ -1,8 +1,8 @@
import { isEqual } from 'lodash';
import { BehaviorSubject, Observable, combineLatest } from 'rxjs';
import { BehaviorSubject, Observable, combineLatest, Subscription } from 'rxjs';
import { map, distinctUntilChanged } from 'rxjs/operators';
import { ScopesContextValue, ScopesContextValueState } from '@grafana/runtime';
import { LocationService, ScopesContextValue, ScopesContextValueState } from '@grafana/runtime';
import { ScopesDashboardsService } from './dashboards/ScopesDashboardsService';
import { ScopesSelectorService } from './selector/ScopesSelectorService';
@@ -24,9 +24,12 @@ export class ScopesService implements ScopesContextValue {
// This will contain the combined state that will be public.
private readonly _stateObservable: BehaviorSubject<ScopesContextValueState>;
private subscriptions: Subscription[] = [];
constructor(
private selectorService: ScopesSelectorService,
private dashboardsService: ScopesDashboardsService
private dashboardsService: ScopesDashboardsService,
private locationService: LocationService
) {
this._state = new BehaviorSubject<State>({
enabled: false,
@@ -41,24 +44,64 @@ export class ScopesService implements ScopesContextValue {
});
// We combine the latest emissions from this state + selectorService + dashboardsService.
combineLatest([
this._state.asObservable(),
this.getSelectorServiceStateObservable(),
this.getDashboardsServiceStateObservable(),
])
.pipe(
// Map the 3 states into single ScopesContextValueState object
map(
([thisState, selectorState, dashboardsState]): ScopesContextValueState => ({
...thisState,
value: selectorState.selectedScopes,
loading: selectorState.loading,
drawerOpened: dashboardsState.drawerOpened,
})
this.subscriptions.push(
combineLatest([
this._state.asObservable(),
this.getSelectorServiceStateObservable(),
this.getDashboardsServiceStateObservable(),
])
.pipe(
// Map the 3 states into single ScopesContextValueState object
map(
([thisState, selectorState, dashboardsState]): ScopesContextValueState => ({
...thisState,
value: selectorState.selectedScopes,
loading: selectorState.loading,
drawerOpened: dashboardsState.drawerOpened,
})
)
)
)
// We pass this into behaviourSubject so we get the 1 event buffer and we can access latest value.
.subscribe(this._stateObservable);
// We pass this into behaviourSubject so we get the 1 event buffer and we can access latest value.
.subscribe(this._stateObservable)
);
// Init from the URL when we first load
const queryParams = new URLSearchParams(locationService.getLocation().search);
this.changeScopes(queryParams.getAll('scopes'));
// Update scopes state based on URL.
this.subscriptions.push(
locationService.getLocationObservable().subscribe((location) => {
if (!this.state.enabled) {
// We don't need to react on pages that don't interact with scopes.
return;
}
const queryParams = new URLSearchParams(location.search);
const scopes = queryParams.getAll('scopes');
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);
}
})
);
// Update the URL based on change in the scopes state
this.subscriptions.push(
selectorService.subscribeToState((state, prev) => {
const oldScopeNames = prev.selectedScopes.map((scope) => scope.scope.metadata.name);
const newScopeNames = state.selectedScopes.map((scope) => scope.scope.metadata.name);
if (!isEqual(oldScopeNames, newScopeNames)) {
this.locationService.partial(
{
scopes: newScopeNames,
},
true
);
}
})
);
}
/**
@@ -97,6 +140,14 @@ export class ScopesService implements ScopesContextValue {
public setEnabled = (enabled: boolean) => {
if (this.state.enabled !== enabled) {
this.updateState({ enabled });
if (enabled) {
this.locationService.partial(
{
scopes: this.selectorService.state.selectedScopes.map(({ scope }) => scope.metadata.name),
},
true
);
}
}
};
@@ -127,4 +178,13 @@ export class ScopesService implements ScopesContextValue {
distinctUntilChanged((prev, curr) => prev.drawerOpened === curr.drawerOpened)
);
}
/**
* Cleanup subscriptions so this can be garbage collected.
*/
public cleanUp() {
for (const sub of this.subscriptions) {
sub.unsubscribe();
}
}
}
@@ -1,4 +1,4 @@
import { isEqual } from 'lodash';
import { isEqual, last } from 'lodash';
import { ScopesApiClient } from '../ScopesApiClient';
import { ScopesServiceBase } from '../ScopesServiceBase';
@@ -14,7 +14,11 @@ export interface ScopesSelectorServiceState {
opened: boolean;
loadingNodeName: string | undefined;
nodes: NodesMap;
// Scopes that are selected and applied.
selectedScopes: SelectedScope[];
// Representation of what is selected in the tree in the UI. This state may not be yet applied to the selectedScopes.
treeScopes: TreeScope[];
}
@@ -45,29 +49,47 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
});
}
/**
* Updates a node at a path with the new expanded state and query. If we expand a node or change the query we will
* load its children. The expectation is that this is used to expand or filter nodes that are already loaded, not
* load a deep nested node which parents weren't loaded yet.
* @param path Path to the nodes. Each element in the path is a node name. Has to have at least one element.
* @param expanded
* @param query Substring of the title to filter by.
*/
public updateNode = async (path: string[], expanded: boolean, query: string) => {
let nodes = { ...this.state.nodes };
let currentLevel: NodesMap = nodes;
for (let idx = 0; idx < path.length - 1; idx++) {
currentLevel = currentLevel[path[idx]].nodes;
if (path.length < 1) {
return;
}
const loadingNodeName = path[path.length - 1];
const currentNode = currentLevel[loadingNodeName];
// Making a copy as we will be changing this in place and then updating state later.
// This though does not make a deep copy so you cannot rely on reference of nested nodes changing.
const nodes = { ...this.state.nodes };
let currentLevel: NodesMap = nodes;
let loadingNodeName = path[0];
if (path.length > 1) {
const pathToParent = path.slice(0, path.length - 1);
currentLevel = getNodesAtPath(nodes, pathToParent);
loadingNodeName = last(path)!;
}
const currentNode = currentLevel[loadingNodeName];
const differentQuery = currentNode.query !== query;
currentNode.expanded = expanded;
currentNode.query = query;
if (expanded || differentQuery) {
// Means we have to fetch the children of the node
this.updateState({ nodes, loadingNodeName });
// fetchNodeApi does not throw just return empty object
// fetchNodeApi does not throw just returns empty object.
// Load all the children of the loadingNodeName
const childNodes = await this.apiClient.fetchNode(loadingNodeName, query);
if (loadingNodeName === this.state.loadingNodeName) {
const [selectedScopes, treeScopes] = this.getScopesAndTreeScopesWithPaths(
const [selectedScopes, treeScopes] = getScopesAndTreeScopesWithPaths(
this.state.selectedScopes,
this.state.treeScopes,
path,
@@ -95,6 +117,14 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
}
};
/**
* Toggle a selection of a scope node. Only leaf nodes representing an actual scope can be toggled so the path should
* represent such node.
*
* The main function of this method is to update the treeScopes state which is a representation of what is selected in
* the UI and to prefetch the scope data from the server.
* @param path
*/
public toggleNodeSelect = (path: string[]) => {
let treeScopes = [...this.state.treeScopes];
@@ -110,6 +140,8 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
const selectedIdx = treeScopes.findIndex(({ scopeName }) => scopeName === linkId);
if (selectedIdx === -1) {
// We prefetch the scope when clicking on it. This will mean that once the selection is applied in closeAndApply()
// we already have all the scopes in cache and don't need to fetch all of them again is multiple requests.
this.apiClient.fetchScope(linkId!);
const selectedFromSameNode =
@@ -133,8 +165,14 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
changeScopes = (scopeNames: string[]) => this.setNewScopes(scopeNames.map((scopeName) => ({ scopeName, path: [] })));
/**
* Apply the selected scopes. Apart from setting the scopes it also fetches the scope metadata and also loads the
* related dashboards.
* @param treeScopes The scopes to be applied. If not provided the treeScopes state is used which was populated
* before for example by toggling the scopes in the scoped tree UI.
*/
private setNewScopes = async (treeScopes = this.state.treeScopes) => {
if (isEqual(treeScopes, this.getTreeScopesFromSelectedScopes(this.state.selectedScopes))) {
if (isEqual(treeScopes, getTreeScopesFromSelectedScopes(this.state.selectedScopes))) {
return;
}
@@ -142,7 +180,10 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
scope: getEmptyScopeObject(scopeName),
path,
}));
// Apply the scopes right away even though we don't have the metadata yet.
this.updateState({ selectedScopes, treeScopes, loading: true });
this.dashboardsService.fetchDashboards(selectedScopes.map(({ scope }) => scope.metadata.name));
selectedScopes = await this.apiClient.fetchMultipleScopes(treeScopes);
@@ -151,6 +192,9 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
public removeAllScopes = () => this.setNewScopes([]);
/**
* Opens the scopes selector drawer and loads the root nodes if they are not loaded yet.
*/
public open = async () => {
if (Object.keys(this.state.nodes[''].nodes).length === 0) {
await this.updateNode([''], true, '');
@@ -159,7 +203,7 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
let nodes = { ...this.state.nodes };
// First close all nodes
nodes = this.closeNodes(nodes);
nodes = closeNodes(nodes);
// Extract the path of a scope
let path = [...(this.state.selectedScopes[0]?.path ?? ['', ''])];
@@ -172,85 +216,90 @@ export class ScopesSelectorService extends ScopesServiceBase<ScopesSelectorServi
};
public closeAndReset = () => {
this.updateState({ opened: false, treeScopes: this.getTreeScopesFromSelectedScopes(this.state.selectedScopes) });
// Reset the treeScopes if we don't want them actually applied.
this.updateState({ opened: false, treeScopes: getTreeScopesFromSelectedScopes(this.state.selectedScopes) });
};
public closeAndApply = () => {
this.updateState({ opened: false });
this.setNewScopes();
};
}
private closeNodes = (nodes: NodesMap): NodesMap => {
return Object.entries(nodes).reduce<NodesMap>((acc, [id, node]) => {
acc[id] = {
...node,
expanded: false,
nodes: this.closeNodes(node.nodes),
};
/**
* Creates a deep copy of the node tree with expanded prop set to false.
* @param nodes
*/
function closeNodes(nodes: NodesMap): NodesMap {
return Object.entries(nodes).reduce<NodesMap>((acc, [id, node]) => {
acc[id] = {
...node,
expanded: false,
nodes: closeNodes(node.nodes),
};
return acc;
}, {});
};
return acc;
}, {});
}
private getTreeScopesFromSelectedScopes = (scopes: SelectedScope[]): TreeScope[] => {
return scopes.map(({ scope, path }) => ({
scopeName: scope.metadata.name,
path,
}));
};
function getTreeScopesFromSelectedScopes(scopes: SelectedScope[]): TreeScope[] {
return scopes.map(({ scope, path }) => ({
scopeName: scope.metadata.name,
path,
}));
}
// helper func to get the selected/tree scopes together with their paths
// needed to maintain selected scopes in tree for example when navigating
// between categories or when loading scopes from URL to find the scope's path
private getScopesAndTreeScopesWithPaths = (
selectedScopes: SelectedScope[],
treeScopes: TreeScope[],
path: string[],
childNodes: NodesMap
): [SelectedScope[], TreeScope[]] => {
const childNodesArr = Object.values(childNodes);
// helper func to get the selected/tree scopes together with their paths
// needed to maintain selected scopes in tree for example when navigating
// between categories or when loading scopes from URL to find the scope's path
function getScopesAndTreeScopesWithPaths(
selectedScopes: SelectedScope[],
treeScopes: TreeScope[],
path: string[],
childNodes: NodesMap
): [SelectedScope[], TreeScope[]] {
const childNodesArr = Object.values(childNodes);
// Get all scopes without paths
// We use tree scopes as the list is always up to date as opposed to selected scopes which can be outdated
const scopeNamesWithoutPaths = treeScopes.filter(({ path }) => path.length === 0).map(({ scopeName }) => scopeName);
// Get all scopes without paths
// We use tree scopes as the list is always up to date as opposed to selected scopes which can be outdated
const scopeNamesWithoutPaths = treeScopes.filter(({ path }) => path.length === 0).map(({ scopeName }) => scopeName);
// We search for the path of each scope name without a path
const scopeNamesWithPaths = scopeNamesWithoutPaths.reduce<Record<string, string[]>>((acc, scopeName) => {
const possibleParent = childNodesArr.find((childNode) => childNode.selectable && childNode.linkId === scopeName);
// We search for the path of each scope name without a path
const scopeNamesWithPaths = scopeNamesWithoutPaths.reduce<Record<string, string[]>>((acc, scopeName) => {
const possibleParent = childNodesArr.find((childNode) => childNode.selectable && childNode.linkId === scopeName);
if (possibleParent) {
acc[scopeName] = [...path, possibleParent.name];
}
if (possibleParent) {
acc[scopeName] = [...path, possibleParent.name];
}
return acc;
}, {});
return acc;
}, {});
// Update the paths of the selected scopes based on what we found
const newSelectedScopes = selectedScopes.map((selectedScope) => {
if (selectedScope.path.length > 0) {
return selectedScope;
}
// Update the paths of the selected scopes based on what we found
const newSelectedScopes = selectedScopes.map((selectedScope) => {
if (selectedScope.path.length > 0) {
return selectedScope;
}
return {
...selectedScope,
path: scopeNamesWithPaths[selectedScope.scope.metadata.name] ?? [],
};
});
return {
...selectedScope,
path: scopeNamesWithPaths[selectedScope.scope.metadata.name] ?? [],
};
});
// Update the paths of the tree scopes based on what we found
const newTreeScopes = treeScopes.map((treeScope) => {
if (treeScope.path.length > 0) {
return treeScope;
}
// Update the paths of the tree scopes based on what we found
const newTreeScopes = treeScopes.map((treeScope) => {
if (treeScope.path.length > 0) {
return treeScope;
}
return {
...treeScope,
path: scopeNamesWithPaths[treeScope.scopeName] ?? [],
};
});
return {
...treeScope,
path: scopeNamesWithPaths[treeScope.scopeName] ?? [],
};
});
return [newSelectedScopes, newTreeScopes];
};
return [newSelectedScopes, newTreeScopes];
}
function expandNodes(nodes: NodesMap, path: string[]): NodesMap {
@@ -269,3 +318,13 @@ function expandNodes(nodes: NodesMap, path: string[]): NodesMap {
return nodes;
}
function getNodesAtPath(nodes: NodesMap, path: string[]): NodesMap {
let currentNodes = nodes;
for (const section of path) {
currentNodes = currentNodes[section].nodes;
}
return currentNodes;
}
@@ -1,4 +1,4 @@
import { config } from '@grafana/runtime';
import { config, locationService } from '@grafana/runtime';
import { ScopesService } from '../ScopesService';
import { ScopesDashboardsService } from '../dashboards/ScopesDashboardsService';
@@ -65,6 +65,7 @@ describe('Dashboards list', () => {
});
afterEach(async () => {
locationService.replace('');
await resetScenes([fetchDashboardsSpy]);
});
@@ -1,4 +1,4 @@
import { config } from '@grafana/runtime';
import { config, locationService } from '@grafana/runtime';
import { getDashboardScenePageStateManager } from '../../dashboard-scene/pages/DashboardScenePageStateManager';
import { ScopesService } from '../ScopesService';
@@ -36,6 +36,7 @@ describe('Selector', () => {
});
afterEach(async () => {
locationService.replace('');
await resetScenes([fetchSelectedScopesSpy, dashboardReloadSpy]);
});
@@ -1,4 +1,4 @@
import { config } from '@grafana/runtime';
import { config, locationService } from '@grafana/runtime';
import { ScopesService } from '../ScopesService';
import { ScopesSelectorService } from '../selector/ScopesSelectorService';
@@ -74,6 +74,7 @@ describe('Tree', () => {
});
afterEach(async () => {
locationService.replace('');
await resetScenes([fetchNodesSpy, fetchScopeSpy]);
});
+23 -14
View File
@@ -1416,7 +1416,7 @@ __metadata:
languageName: node
linkType: hard
"@babel/runtime@npm:7.26.10, @babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.11.1, @babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.0, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.13, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.15.4, @babel/runtime@npm:^7.16.3, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.18.0, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.20.0, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.24.5, @babel/runtime@npm:^7.24.7, @babel/runtime@npm:^7.25.0, @babel/runtime@npm:^7.25.6, @babel/runtime@npm:^7.25.7, @babel/runtime@npm:^7.3.1, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7":
"@babel/runtime@npm:7.26.10":
version: 7.26.10
resolution: "@babel/runtime@npm:7.26.10"
dependencies:
@@ -1425,6 +1425,15 @@ __metadata:
languageName: node
linkType: hard
"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.11.1, @babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.0, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.13, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.13.10, @babel/runtime@npm:^7.15.4, @babel/runtime@npm:^7.16.3, @babel/runtime@npm:^7.17.8, @babel/runtime@npm:^7.18.0, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.20.0, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.24.5, @babel/runtime@npm:^7.24.7, @babel/runtime@npm:^7.25.0, @babel/runtime@npm:^7.25.6, @babel/runtime@npm:^7.25.7, @babel/runtime@npm:^7.3.1, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7":
version: 7.26.9
resolution: "@babel/runtime@npm:7.26.9"
dependencies:
regenerator-runtime: "npm:^0.14.0"
checksum: 10/08edd07d774eafbf157fdc8450ed6ddd22416fdd8e2a53e4a00349daba1b502c03ab7f7ad3ad3a7c46b9a24d99b5697591d0f852ee2f84642082ef7dda90b83d
languageName: node
linkType: hard
"@babel/template@npm:^7.22.5, @babel/template@npm:^7.24.7, @babel/template@npm:^7.25.9, @babel/template@npm:^7.26.9, @babel/template@npm:^7.3.3":
version: 7.26.9
resolution: "@babel/template@npm:7.26.9"
@@ -3471,11 +3480,11 @@ __metadata:
languageName: unknown
linkType: soft
"@grafana/scenes-react@npm:6.5.2":
version: 6.5.2
resolution: "@grafana/scenes-react@npm:6.5.2"
"@grafana/scenes-react@npm:6.5.3":
version: 6.5.3
resolution: "@grafana/scenes-react@npm:6.5.3"
dependencies:
"@grafana/scenes": "npm:6.5.2"
"@grafana/scenes": "npm:6.5.3"
lru-cache: "npm:^10.2.2"
react-use: "npm:^17.4.0"
peerDependencies:
@@ -3487,13 +3496,13 @@ __metadata:
react: ^18.0.0
react-dom: ^18.0.0
react-router-dom: ^6.28.0
checksum: 10/c922fea8ca86db49651a818640f32a7a35eeabcf886b52309ccd515a17bd071d19814e578158540299a4d01824ff3b9a05667bb86c91d87885c8ba7d0810c353
checksum: 10/42630d527e3d9db2e2ae6728ad551b7cc9d36d2c3cdad3a6fdd5c8a959e2f88503767d257674db48480dc676bc83833a030a00dacd3bbd7058a51256f2c3b816
languageName: node
linkType: hard
"@grafana/scenes@npm:6.5.2":
version: 6.5.2
resolution: "@grafana/scenes@npm:6.5.2"
"@grafana/scenes@npm:6.5.3":
version: 6.5.3
resolution: "@grafana/scenes@npm:6.5.3"
dependencies:
"@floating-ui/react": "npm:^0.26.16"
"@leeoniya/ufuzzy": "npm:^1.0.16"
@@ -3511,7 +3520,7 @@ __metadata:
react: ^18.0.0
react-dom: ^18.0.0
react-router-dom: ^6.28.0
checksum: 10/267af84b2cc86acc547afbacb661aeffdd67388146bbf0f682ddd6577992973766daf7f8b35fb79ef5170ed10808f73294bfc8565b90fc4dd044dd738a34eba8
checksum: 10/25a2041ba2e6a6bcac0e08d0493575ab26d3331c27db21d8398e9bdc32f6eb117f6620a4d07c2d20b0fbb02782e6b921932d134298f6765062f2872f6a9b972b
languageName: node
linkType: hard
@@ -17683,8 +17692,8 @@ __metadata:
"@grafana/prometheus": "workspace:*"
"@grafana/runtime": "workspace:*"
"@grafana/saga-icons": "workspace:*"
"@grafana/scenes": "npm:6.5.2"
"@grafana/scenes-react": "npm:6.5.2"
"@grafana/scenes": "npm:6.5.3"
"@grafana/scenes-react": "npm:6.5.3"
"@grafana/schema": "workspace:*"
"@grafana/sql": "workspace:*"
"@grafana/tsconfig": "npm:^2.0.0"
@@ -30524,7 +30533,7 @@ __metadata:
languageName: node
linkType: hard
"uuid@npm:11.0.5":
"uuid@npm:11.0.5, uuid@npm:^11.0.0, uuid@npm:^11.0.2":
version: 11.0.5
resolution: "uuid@npm:11.0.5"
bin:
@@ -30542,7 +30551,7 @@ __metadata:
languageName: node
linkType: hard
"uuid@npm:^11.0.0, uuid@npm:^11.0.2, uuid@npm:^11.0.5":
"uuid@npm:^11.0.5":
version: 11.1.0
resolution: "uuid@npm:11.1.0"
bin: