diff --git a/package.json b/package.json index 744901de7..2f40e9e82 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "@emotion/css": "^11.10.6", "@grafana/data": "^11.0.0", "@grafana/runtime": "^11.0.0", - "@grafana/scenes": "4.32.0", + "@grafana/scenes": "5.2.0", "@grafana/ui": "^11.0.0", "@playwright/test": "^1.43.1", "@types/react-table": "^7.7.20", diff --git a/src/Components/App.tsx b/src/Components/App.tsx index 8c73101d2..2565ce9ac 100644 --- a/src/Components/App.tsx +++ b/src/Components/App.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { AppRootProps } from '@grafana/data'; -import { Routes } from './Routes'; +import { LogExplorationView } from './LogExplorationPage'; const PluginPropsContext = React.createContext(null); @@ -8,7 +8,7 @@ export class App extends React.PureComponent { render() { return ( - + ); } diff --git a/src/Components/IndexScene/IndexScene.tsx b/src/Components/IndexScene/IndexScene.tsx index 131418f8f..b0a091228 100644 --- a/src/Components/IndexScene/IndexScene.tsx +++ b/src/Components/IndexScene/IndexScene.tsx @@ -32,11 +32,12 @@ import { import { addLastUsedDataSourceToStorage, getLastUsedDataSourceFromStorage } from 'services/store'; import { ServiceScene } from '../ServiceScene/ServiceScene'; -import { ServiceSelectionScene, StartingPointSelectedEvent } from '../ServiceSelectionScene/ServiceSelectionScene'; import { LayoutScene } from './LayoutScene'; import { FilterOp } from 'services/filters'; - -type LogExplorationMode = 'service_selection' | 'service_details'; +import { getSlug, PageSlugs } from '../../services/routing'; +import { ServiceSelectionScene } from '../ServiceSelectionScene/ServiceSelectionScene'; +import { LoadingPlaceholder } from '@grafana/ui'; +import { locationService } from '@grafana/runtime'; export interface AppliedPattern { pattern: string; @@ -47,16 +48,14 @@ export interface IndexSceneState extends SceneObjectState { // contentScene is the scene that is displayed in the main body of the index scene - it can be either the service selection or service scene contentScene?: SceneObject; controls: SceneObject[]; - body: LayoutScene; - // mode is the current mode of the index scene - it can be either 'service_selection' or 'service_details' - mode?: LogExplorationMode; + body?: LayoutScene; initialFilters?: AdHocVariableFilter[]; initialDS?: string; patterns?: AppliedPattern[]; } export class IndexScene extends SceneObjectBase { - protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['mode', 'patterns'] }); + protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['patterns'] }); public constructor(state: Partial) { super({ @@ -69,8 +68,10 @@ export class IndexScene extends SceneObjectBase { new SceneTimePicker({}), new SceneRefreshPicker({}), ], - body: new LayoutScene({}), + // Need to clear patterns state when the class in constructed + patterns: [], ...state, + body: new LayoutScene({}), }); this.addActivationHandler(this.onActivate.bind(this)); @@ -78,70 +79,90 @@ export class IndexScene extends SceneObjectBase { static Component = ({ model }: SceneComponentProps) => { const { body } = model.useState(); + if (body) { + return ; + } - return ; + return ; }; public onActivate() { + const stateUpdate: Partial = {}; + if (!this.state.contentScene) { - this.setState({ contentScene: getContentScene(this.state.mode) }); + stateUpdate.contentScene = getContentScene(); } - // Some scene elements publish this - this.subscribeToEvent(StartingPointSelectedEvent, this._handleStartingPointSelected.bind(this)); + this.setState(stateUpdate); + const patternsVariable = sceneGraph.lookupVariable(VAR_PATTERNS, this); + if (patternsVariable instanceof CustomVariable) { + this.updatePatterns(this.state, patternsVariable); + } - this.subscribeToState((newState, oldState) => { - if (newState.mode !== oldState.mode) { - this.setState({ contentScene: getContentScene(newState.mode) }); - } + const fieldsVariable = sceneGraph.lookupVariable(VAR_FIELDS, this); + if (fieldsVariable instanceof AdHocFiltersVariable) { + this.syncFieldsWithUrl(fieldsVariable); + } - const patternsVariable = sceneGraph.lookupVariable(VAR_PATTERNS, this); - if (patternsVariable instanceof CustomVariable) { - const patternsLine = renderPatternFilters(newState.patterns ?? []); - patternsVariable.changeValueTo(patternsLine); - } - }); + this._subs.add( + this.subscribeToState((newState) => { + const patternsVariable = sceneGraph.lookupVariable(VAR_PATTERNS, this); + if (patternsVariable instanceof CustomVariable) { + this.updatePatterns(newState, patternsVariable); + } + }) + ); return () => { getUrlSyncManager().cleanUp(this); }; } + /** + * @todo why do we need to manually sync fields, but nothing else? + * @param fieldsVariable + * @private + */ + private syncFieldsWithUrl(fieldsVariable: AdHocFiltersVariable) { + const location = locationService.getLocation(); + const search = new URLSearchParams(location.search); + const filtersFromUrl = search.get('var-fields'); + + // If the filters aren't in the URL, then they're coming from the cache, set the state to sync with url + if (filtersFromUrl === null) { + fieldsVariable.setState({ filters: [] }); + } + } + + private updatePatterns(newState: IndexSceneState, patternsVariable: CustomVariable) { + const patternsLine = renderPatternFilters(newState.patterns ?? []); + patternsVariable.changeValueTo(patternsLine); + } + getUrlState() { return { - mode: this.state.mode, - patterns: this.state.mode === 'service_selection' ? '' : JSON.stringify(this.state.patterns), + patterns: JSON.stringify(this.state.patterns), }; } updateFromUrl(values: SceneObjectUrlValues) { const stateUpdate: Partial = {}; - if (values.mode !== this.state.mode) { - const mode: LogExplorationMode = (values.mode as LogExplorationMode) ?? 'service_selection'; - stateUpdate.mode = mode; - stateUpdate.contentScene = getContentScene(mode); - } - if (this.state.mode === 'service_selection') { - // Clear patterns on start - stateUpdate.patterns = undefined; - } else if (values.patterns && typeof values.patterns === 'string') { + + if (values.patterns && typeof values.patterns === 'string') { stateUpdate.patterns = JSON.parse(values.patterns) as AppliedPattern[]; } - this.setState(stateUpdate); - } - private _handleStartingPointSelected(evt: StartingPointSelectedEvent) { - this.setState({ - mode: 'service_details', - }); + this.setState(stateUpdate); } } -function getContentScene(mode?: LogExplorationMode) { - if (mode === 'service_details') { - return new ServiceScene({}); +function getContentScene() { + const slug = getSlug(); + if (slug === PageSlugs.explore) { + return new ServiceSelectionScene({}); } - return new ServiceSelectionScene({}); + + return new ServiceScene({}); } function getVariableSet(initialDS?: string, initialFilters?: AdHocVariableFilter[]) { @@ -195,6 +216,7 @@ function getVariableSet(initialDS?: string, initialFilters?: AdHocVariableFilter dsVariable, filterVariable, fieldsVariable, + // @todo where is patterns being added to the url? Why do we have var-patterns and patterns? new CustomVariable({ name: VAR_PATTERNS, value: '', diff --git a/src/Components/LogExplorationPage.tsx b/src/Components/LogExplorationPage.tsx index 4b95c0aa8..3ad12190b 100644 --- a/src/Components/LogExplorationPage.tsx +++ b/src/Components/LogExplorationPage.tsx @@ -1,19 +1,19 @@ -import React, { useEffect, useMemo } from 'react'; +import React, { useEffect } from 'react'; -import { SceneTimeRange, getUrlSyncManager } from '@grafana/scenes'; -import { IndexScene } from './IndexScene/IndexScene'; -const DEFAULT_TIME_RANGE = { from: 'now-15m', to: 'now' }; +import { getUrlSyncManager, SceneApp, useSceneApp } from '@grafana/scenes'; +import { config } from '@grafana/runtime'; +import { Redirect } from 'react-router-dom'; +import { makeIndexPage, makeRedirectPage } from './Pages'; + +const getSceneApp = () => + new SceneApp({ + pages: [makeIndexPage(), makeRedirectPage()], + }); export function LogExplorationView() { const [isInitialized, setIsInitialized] = React.useState(false); - // Must memoize the top-level scene or any route change will re-instantiate all the scene classes - const scene = useMemo( - () => - new IndexScene({ - $timeRange: new SceneTimeRange(DEFAULT_TIME_RANGE), - }), - [] - ); + + const scene = useSceneApp(getSceneApp); useEffect(() => { if (!isInitialized) { @@ -22,6 +22,12 @@ export function LogExplorationView() { } }, [scene, isInitialized]); + const userPermissions = config.bootData.user.permissions; + const canUseApp = userPermissions?.['grafana-lokiexplore-app:read'] || userPermissions?.['datasources:explore']; + if (!canUseApp) { + return ; + } + if (!isInitialized) { return null; } diff --git a/src/Components/Pages.tsx b/src/Components/Pages.tsx new file mode 100644 index 000000000..578723373 --- /dev/null +++ b/src/Components/Pages.tsx @@ -0,0 +1,117 @@ +import { + EmbeddedScene, + SceneAppPage, + SceneAppPageLike, + SceneFlexLayout, + SceneRouteMatch, + SceneTimeRange, +} from '@grafana/scenes'; +import { + DRILLDOWN_URL_KEYS, + extractServiceFromRoute, + navigateToIndex, + PLUGIN_BASE_URL, + prefixRoute, + ROUTE_DEFINITIONS, + ROUTES, + SERVICE_URL_KEYS, + PageSlugs, +} from '../services/routing'; +import { PageLayoutType } from '@grafana/data'; +import { IndexScene } from './IndexScene/IndexScene'; + +function getServicesScene() { + const DEFAULT_TIME_RANGE = { from: 'now-15m', to: 'now' }; + return new EmbeddedScene({ + body: new IndexScene({ + $timeRange: new SceneTimeRange(DEFAULT_TIME_RANGE), + }), + }); +} + +// Index page +export function makeIndexPage() { + return new SceneAppPage({ + // Top level breadcrumb + title: 'Logs', + url: prefixRoute(PageSlugs.explore), + layout: PageLayoutType.Custom, + preserveUrlKeys: SERVICE_URL_KEYS, + routePath: prefixRoute(PageSlugs.explore), + getScene: () => getServicesScene(), + drilldowns: [ + { + routePath: ROUTE_DEFINITIONS.logs, + getPage: (routeMatch, parent) => makeBreakdownPage(routeMatch, parent, PageSlugs.logs), + defaultRoute: true, + }, + { + routePath: ROUTE_DEFINITIONS.labels, + getPage: (routeMatch, parent) => makeBreakdownPage(routeMatch, parent, PageSlugs.labels), + }, + { + routePath: ROUTE_DEFINITIONS.patterns, + getPage: (routeMatch, parent) => makeBreakdownPage(routeMatch, parent, PageSlugs.patterns), + }, + { + routePath: ROUTE_DEFINITIONS.fields, + getPage: (routeMatch, parent) => makeBreakdownPage(routeMatch, parent, PageSlugs.fields), + }, + { + routePath: '*', + getPage: () => makeRedirectPage(), + }, + ], + }); +} + +// Redirect page back to index +export function makeRedirectPage() { + return new SceneAppPage({ + title: '', + url: PLUGIN_BASE_URL, + getScene: makeEmptyScene(), + hideFromBreadcrumbs: true, + routePath: '*', + $behaviors: [ + () => { + navigateToIndex(); + }, + ], + }); +} + +function makeEmptyScene(): (routeMatch: SceneRouteMatch) => EmbeddedScene { + return () => + new EmbeddedScene({ + body: new SceneFlexLayout({ + direction: 'column', + children: [], + }), + }); +} + +export function makeBreakdownPage( + routeMatch: SceneRouteMatch<{ service: string }>, + parent: SceneAppPageLike, + slug: PageSlugs +): SceneAppPage { + const { service } = extractServiceFromRoute(routeMatch); + + return new SceneAppPage({ + title: slugToBreadcrumbTitle(slug), + layout: PageLayoutType.Custom, + url: ROUTES[slug](service), + preserveUrlKeys: DRILLDOWN_URL_KEYS, + getParentPage: () => parent, + getScene: () => getServicesScene(), + }); +} + +function slugToBreadcrumbTitle(slug: PageSlugs) { + if (slug === 'fields') { + return 'Detected fields'; + } + // capitalize first letter + return slug.charAt(0).toUpperCase() + slug.slice(1); +} diff --git a/src/Components/Routes.tsx b/src/Components/Routes.tsx deleted file mode 100644 index 192e6b0b4..000000000 --- a/src/Components/Routes.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import React from 'react'; -import { Redirect, Route, Switch } from 'react-router-dom'; -import { config } from '@grafana/runtime'; -import { prefixRoute, ROUTES } from 'services/routing'; -import { LogExplorationView } from './LogExplorationPage'; - -export const Routes = () => { - const userPermissions = config.bootData.user.permissions; - const canUseApp = userPermissions?.['grafana-lokiexplore-app:read'] || userPermissions?.['datasources:explore']; - if (!canUseApp) { - return ; - } - - return ( - - - - - ); -}; diff --git a/src/Components/ServiceScene/Breakdowns/FieldsBreakdownScene.tsx b/src/Components/ServiceScene/Breakdowns/FieldsBreakdownScene.tsx index 7fee6c5a1..84e481430 100644 --- a/src/Components/ServiceScene/Breakdowns/FieldsBreakdownScene.tsx +++ b/src/Components/ServiceScene/Breakdowns/FieldsBreakdownScene.tsx @@ -249,7 +249,6 @@ export class FieldsBreakdownScene extends SceneObjectBase>) { { value: 'rows', label: 'Rows' }, ], active: 'grid', - actionView: 'labels', layouts: [ new SceneCSSGridLayout({ templateColumns: GRID_TEMPLATE_COLUMNS, @@ -326,7 +325,6 @@ function buildLabelValuesLayout(variable: CustomVariable) { { value: 'rows', label: 'Rows' }, ], active: 'grid', - actionView: 'labels', layouts: [ new SceneFlexLayout({ direction: 'column', diff --git a/src/Components/ServiceScene/Breakdowns/LayoutSwitcher.tsx b/src/Components/ServiceScene/Breakdowns/LayoutSwitcher.tsx index c1f1a8039..393944740 100644 --- a/src/Components/ServiceScene/Breakdowns/LayoutSwitcher.tsx +++ b/src/Components/ServiceScene/Breakdowns/LayoutSwitcher.tsx @@ -4,14 +4,12 @@ import { SelectableValue } from '@grafana/data'; import { SceneComponentProps, SceneObject, SceneObjectBase, SceneObjectState } from '@grafana/scenes'; import { Field, RadioButtonGroup } from '@grafana/ui'; import { USER_EVENTS_ACTIONS, USER_EVENTS_PAGES, reportAppInteraction } from 'services/analytics'; -import { ActionViewType } from '../ServiceScene'; +import { getSlug } from '../../../services/routing'; export interface LayoutSwitcherState extends SceneObjectState { active: LayoutType; layouts: SceneObject[]; options: Array>; - // actionView is used mainly for analytics - actionView: ActionViewType; } export type LayoutType = 'single' | 'grid' | 'rows'; @@ -30,7 +28,7 @@ export class LayoutSwitcher extends SceneObjectBase { public onLayoutChange = (active: LayoutType) => { reportAppInteraction(USER_EVENTS_PAGES.service_details, USER_EVENTS_ACTIONS.service_details.layout_type_changed, { layout: active, - view: this.state.actionView, + view: getSlug(), }); this.setState({ active }); }; diff --git a/src/Components/ServiceScene/LogsListScene.tsx b/src/Components/ServiceScene/LogsListScene.tsx index e817fccb0..2c85dfa32 100644 --- a/src/Components/ServiceScene/LogsListScene.tsx +++ b/src/Components/ServiceScene/LogsListScene.tsx @@ -19,10 +19,11 @@ import { LogsVolumePanel } from './LogsVolumePanel'; import { css } from '@emotion/css'; import { reportAppInteraction, USER_EVENTS_ACTIONS, USER_EVENTS_PAGES } from '../../services/analytics'; import { DataFrame } from '@grafana/data'; -import { FilterType, addToFilters } from './Breakdowns/AddToFiltersButton'; -import { LabelType, getLabelTypeFromFrame } from 'services/fields'; +import { addToFilters, FilterType } from './Breakdowns/AddToFiltersButton'; +import { getLabelTypeFromFrame, LabelType } from 'services/fields'; import { VAR_FIELDS, VAR_FILTERS } from 'services/variables'; import { getAdHocFiltersVariable } from 'services/scenes'; +import { locationService } from '@grafana/runtime'; export interface LogsListSceneState extends SceneObjectState { loading?: boolean; @@ -90,6 +91,9 @@ export class LogsListScene extends SceneObjectBase { } public onActivate() { + const searchParams = new URLSearchParams(locationService.getLocation().search); + this.setStateFromUrl(searchParams); + if (!this.state.panel) { this.setState({ panel: this.getVizPanel(), @@ -105,6 +109,28 @@ export class LogsListScene extends SceneObjectBase { }); } + private setStateFromUrl(searchParams: URLSearchParams) { + const state: Partial = {}; + const selectedLineUrl = searchParams.get('selectedLine'); + const urlColumnsUrl = searchParams.get('urlColumns'); + const vizTypeUrl = searchParams.get('visualizationType'); + + if (selectedLineUrl) { + state.selectedLine = JSON.parse(selectedLineUrl); + } + if (urlColumnsUrl) { + state.urlColumns = JSON.parse(urlColumnsUrl); + } + if (vizTypeUrl) { + state.visualizationType = JSON.parse(vizTypeUrl); + } + + // If state is saved in url on activation, save to scene state + if (Object.keys(state).length) { + this.setState(state); + } + } + private handleLabelFilter(key: string, value: string, frame: DataFrame | undefined, operator: FilterType) { // @TODO: NOOP. We need a way to let the user know why this is not possible. if (key === 'service_name') { diff --git a/src/Components/ServiceScene/PageScene.tsx b/src/Components/ServiceScene/PageScene.tsx deleted file mode 100644 index d65e2721b..000000000 --- a/src/Components/ServiceScene/PageScene.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { SceneComponentProps, SceneObject, SceneObjectBase, SceneObjectState } from '@grafana/scenes'; -import { PageLayoutType } from '@grafana/data'; -import { PluginPage } from '@grafana/runtime'; -import React from 'react'; - -interface PageSceneState extends SceneObjectState { - body: SceneObject; - title: string; -} -export class PageScene extends SceneObjectBase { - constructor(state: PageSceneState) { - super({ - body: state.body, - title: state.title, - }); - } - public static Component = ({ model }: SceneComponentProps) => { - const { body, title } = model.useState(); - return ( - - - - ); - }; -} diff --git a/src/Components/ServiceScene/ServiceScene.tsx b/src/Components/ServiceScene/ServiceScene.tsx index 4061983cb..253f3973b 100644 --- a/src/Components/ServiceScene/ServiceScene.tsx +++ b/src/Components/ServiceScene/ServiceScene.tsx @@ -2,7 +2,6 @@ import { css } from '@emotion/css'; import React from 'react'; import { GrafanaTheme2, LoadingState } from '@grafana/data'; -import { locationService } from '@grafana/runtime'; import { AdHocFiltersVariable, CustomVariable, @@ -13,8 +12,6 @@ import { SceneObject, SceneObjectBase, SceneObjectState, - SceneObjectUrlSyncConfig, - SceneObjectUrlValues, SceneVariable, VariableDependencyConfig, } from '@grafana/scenes'; @@ -25,7 +22,7 @@ import { reportAppInteraction, USER_EVENTS_ACTIONS, USER_EVENTS_PAGES } from 'se import { DetectedLabelsResponse, extractParserAndFieldsFromDataFrame } from 'services/fields'; import { getQueryRunner } from 'services/panel'; import { buildLokiQuery } from 'services/query'; -import { EXPLORATIONS_ROUTE, PLUGIN_ID } from 'services/routing'; +import { getSlug, navigateToBreakdown, navigateToIndex, PLUGIN_ID, PageSlugs } from 'services/routing'; import { getExplorationFor, getLokiDatasource } from 'services/scenes'; import { ALL_VARIABLE_VALUE, @@ -34,7 +31,6 @@ import { VAR_DATASOURCE, VAR_FIELDS, VAR_FILTERS, - VAR_LINE_FILTER, VAR_LOGS_FORMAT, VAR_PATTERNS, } from 'services/variables'; @@ -44,7 +40,6 @@ import { buildPatternsScene } from './Breakdowns/PatternsBreakdownScene'; import { GoToExploreButton } from './GoToExploreButton'; import { buildLogsListScene } from './LogsListScene'; import { testIds } from 'services/testIds'; -import { PageScene } from './PageScene'; import { sortLabelsByCardinality } from 'services/filters'; import { SERVICE_NAME } from 'Components/ServiceSelectionScene/ServiceSelectionScene'; @@ -53,11 +48,9 @@ export interface LokiPattern { samples: Array<[number, string]>; } -export type ActionViewType = 'logs' | 'labels' | 'patterns' | 'fields'; - -interface ActionViewDefinition { +interface BreakdownViewDefinition { displayName: string; - value: ActionViewType; + value: PageSlugs; testId: string; getScene: (changeFields: (f: string[]) => void) => SceneObject; } @@ -66,7 +59,6 @@ type MakeOptional = Pick, K> & Omit; export interface ServiceSceneState extends SceneObjectState { body: SceneFlexLayout; - actionView?: string; detectedFields?: string[]; labels?: string[]; @@ -78,7 +70,6 @@ export interface ServiceSceneState extends SceneObjectState { } export class ServiceScene extends SceneObjectBase { - protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['actionView'] }); protected _variableDependency = new VariableDependencyConfig(this, { variableNames: [VAR_DATASOURCE, VAR_FILTERS, VAR_FIELDS, VAR_PATTERNS], onReferencedVariableValueChanged: this.onReferencedVariableValueChanged.bind(this), @@ -95,8 +86,9 @@ export class ServiceScene extends SceneObjectBase { this.addActivationHandler(this.onActivate.bind(this)); } - private getFiltersVariable(): AdHocFiltersVariable { + public getFiltersVariable(): AdHocFiltersVariable { const variable = sceneGraph.lookupVariable(VAR_FILTERS, this)!; + if (!(variable instanceof AdHocFiltersVariable)) { throw new Error('Filters variable not found'); } @@ -122,38 +114,12 @@ export class ServiceScene extends SceneObjectBase { } private redirectToStart() { - const fields = sceneGraph.lookupVariable(VAR_FIELDS, this)! as AdHocFiltersVariable; - fields.setState({ filters: [] }); - const lineFilter = sceneGraph.lookupVariable(VAR_LINE_FILTER, this); - if (lineFilter instanceof CustomVariable) { - lineFilter.changeValueTo(''); - } - - // Use locationService to do the redirect and allow the users to start afresh, - // potentially getting them unstuck of any leakage produced by subscribers, listeners, - // variables, etc., without having to do a full reload. - const params = locationService.getSearch(); - const newParams = new URLSearchParams(); - const from = params.get('from'); - if (from) { - newParams.set('from', from); - } - const to = params.get('to'); - if (to) { - newParams.set('to', to); - } - const ds = params.get('var-ds'); - if (ds) { - newParams.set('var-ds', ds); - } - locationService.push(`${EXPLORATIONS_ROUTE}?${newParams}`); + // Redirect to root with updated params, which will trigger history push back to index route, preventing empty page or empty service query bugs + navigateToIndex(); } private onActivate() { - if (this.state.actionView === undefined) { - this.setActionView('logs'); - } - + this.setBreakdownView(getSlug()); this.setEmptyFiltersRedirection(); const unsubs: Unsubscribable[] = []; @@ -183,16 +149,21 @@ export class ServiceScene extends SceneObjectBase { this.redirectToStart(); return; } + const filterVariable = this.getFiltersVariable(); if (filterVariable.state.filters.length === 0) { return; } - this.updatePatterns(); - this.updateLabels(); - // For patterns, we don't want to reload to logs as we allow users to select multiple patterns - if (variable.state.name !== VAR_PATTERNS) { - locationService.partial({ actionView: 'logs' }); - } + Promise.all([this.updatePatterns(), this.updateLabels()]) + .finally(() => { + // For patterns, we don't want to reload to logs as we allow users to select multiple patterns + if (variable.state.name !== VAR_PATTERNS) { + navigateToBreakdown(PageSlugs.logs); + } + }) + .catch((err) => { + console.error('Failed to update', err); + }); } private getLogsFormatVariable() { @@ -321,42 +292,23 @@ export class ServiceScene extends SceneObjectBase { } } - getUrlState() { - return { actionView: this.state.actionView }; - } - - updateFromUrl(values: SceneObjectUrlValues) { - if (typeof values.actionView === 'string') { - if (this.state.actionView !== values.actionView) { - const actionViewDef = actionViewsDefinitions.find((v) => v.value === values.actionView); - if (actionViewDef) { - this.setActionView(actionViewDef.value); - } - } - } else if (values.actionView === null) { - this.setActionView(undefined); - } - } - - public setActionView(actionView?: ActionViewType) { + public setBreakdownView(breakdownView?: PageSlugs) { const { body } = this.state; - const actionViewDef = actionViewsDefinitions.find((v) => v.value === actionView); + const breakdownViewDef = breakdownViewsDefinitions.find((v) => v.value === breakdownView); - if (actionViewDef && actionViewDef.value !== this.state.actionView) { + if (breakdownViewDef) { body.setState({ children: [ ...body.state.children.slice(0, 1), - actionViewDef.getScene((vals) => { - if (actionViewDef.value === 'fields') { + breakdownViewDef.getScene((vals) => { + if (breakdownViewDef.value === 'fields') { this.setState({ detectedFieldsCount: vals.length }); } }), ], }); - this.setState({ actionView: actionViewDef.value }); } else { - body.setState({ children: body.state.children.slice(0, 1) }); - this.setState({ actionView: undefined }); + console.error('not setting breakdown view'); } } @@ -366,29 +318,29 @@ export class ServiceScene extends SceneObjectBase { }; } -const actionViewsDefinitions: ActionViewDefinition[] = [ +const breakdownViewsDefinitions: BreakdownViewDefinition[] = [ { displayName: 'Logs', - value: 'logs', - getScene: () => new PageScene({ body: buildLogsListScene(), title: 'Logs' }), + value: PageSlugs.logs, + getScene: () => buildLogsListScene(), testId: testIds.exploreServiceDetails.tabLogs, }, { displayName: 'Labels', - value: 'labels', - getScene: () => new PageScene({ body: buildLabelBreakdownActionScene(), title: 'Labels' }), + value: PageSlugs.labels, + getScene: () => buildLabelBreakdownActionScene(), testId: testIds.exploreServiceDetails.tabLabels, }, { displayName: 'Detected fields', - value: 'fields', - getScene: (f) => new PageScene({ body: buildFieldsBreakdownActionScene(f), title: 'Detected fields' }), + value: PageSlugs.fields, + getScene: (f) => buildFieldsBreakdownActionScene(f), testId: testIds.exploreServiceDetails.tabDetectedFields, }, { displayName: 'Patterns', - value: 'patterns', - getScene: () => new PageScene({ body: buildPatternsScene(), title: 'Patterns' }), + value: PageSlugs.patterns, + getScene: () => buildPatternsScene(), testId: testIds.exploreServiceDetails.tabPatterns, }, ]; @@ -397,27 +349,27 @@ export interface LogsActionBarState extends SceneObjectState {} export class LogsActionBar extends SceneObjectBase { public static Component = ({ model }: SceneComponentProps) => { - const serviceScene = sceneGraph.getAncestor(model, ServiceScene); const styles = useStyles2(getStyles); const exploration = getExplorationFor(model); - const { actionView } = serviceScene.useState(); + const currentBreakdownViewSlug = getSlug(); - const getCounter = (tab: ActionViewDefinition) => { + const getCounter = (tab: BreakdownViewDefinition, state: ServiceSceneState) => { switch (tab.value) { case 'fields': return ( - serviceScene.state.detectedFieldsCount ?? - (serviceScene.state.detectedFields?.filter((l) => l !== ALL_VARIABLE_VALUE) ?? []).length + state.detectedFieldsCount ?? (state.detectedFields?.filter((l) => l !== ALL_VARIABLE_VALUE) ?? []).length ); case 'patterns': - return serviceScene.state.patterns?.length; + return state.patterns?.length; case 'labels': - return (serviceScene.state.labels?.filter((l) => l !== ALL_VARIABLE_VALUE) ?? []).length; + return (state.labels?.filter((l) => l !== ALL_VARIABLE_VALUE) ?? []).length; default: return undefined; } }; + const serviceScene = sceneGraph.getAncestor(model, ServiceScene); + const { loading, ...state } = serviceScene.useState(); return (
@@ -427,25 +379,36 @@ export class LogsActionBar extends SceneObjectBase {
- {actionViewsDefinitions.map((tab, index) => { + {breakdownViewsDefinitions.map((tab, index) => { return ( { - if (tab.value !== serviceScene.state.actionView) { + if (tab.value !== currentBreakdownViewSlug) { reportAppInteraction( USER_EVENTS_PAGES.service_details, USER_EVENTS_ACTIONS.service_details.action_view_changed, { newActionView: tab.value, - previousActionView: serviceScene.state.actionView, + previousActionView: currentBreakdownViewSlug, } ); - serviceScene.setActionView(tab.value); + if (tab.value) { + const serviceScene = sceneGraph.getAncestor(model, ServiceScene); + const variable = serviceScene.getFiltersVariable(); + const service = variable.state.filters.find((f) => f.key === SERVICE_NAME); + + if (service?.value) { + navigateToBreakdown(tab.value); + } else { + navigateToIndex(); + } + } } }} /> diff --git a/src/Components/ServiceSelectionScene/SelectServiceButton.tsx b/src/Components/ServiceSelectionScene/SelectServiceButton.tsx index 5241e1c67..2e5e80a42 100644 --- a/src/Components/ServiceSelectionScene/SelectServiceButton.tsx +++ b/src/Components/ServiceSelectionScene/SelectServiceButton.tsx @@ -11,9 +11,10 @@ import { Button } from '@grafana/ui'; import { VariableHide } from '@grafana/schema'; import { addToFavoriteServicesInStorage } from 'services/store'; import { VAR_DATASOURCE, VAR_FILTERS } from 'services/variables'; -import { SERVICE_NAME, StartingPointSelectedEvent } from './ServiceSelectionScene'; +import { SERVICE_NAME } from './ServiceSelectionScene'; import { reportAppInteraction, USER_EVENTS_ACTIONS, USER_EVENTS_PAGES } from 'services/analytics'; import { FilterOp } from 'services/filters'; +import { navigateToBreakdown, ROUTES } from '../../services/routing'; export interface SelectServiceButtonState extends SceneObjectState { service: string; @@ -47,8 +48,7 @@ export class SelectServiceButton extends SceneObjectBase) => { diff --git a/src/Components/ServiceSelectionScene/ServiceSelectionScene.tsx b/src/Components/ServiceSelectionScene/ServiceSelectionScene.tsx index 58739d65b..712384ee1 100644 --- a/src/Components/ServiceSelectionScene/ServiceSelectionScene.tsx +++ b/src/Components/ServiceSelectionScene/ServiceSelectionScene.tsx @@ -1,7 +1,7 @@ import { css } from '@emotion/css'; import { debounce } from 'lodash'; import React, { useCallback, useState } from 'react'; -import { BusEventBase, DashboardCursorSync, GrafanaTheme2, PageLayoutType, TimeRange } from '@grafana/data'; +import { DashboardCursorSync, GrafanaTheme2, TimeRange } from '@grafana/data'; import { AdHocFiltersVariable, behaviors, @@ -35,11 +35,10 @@ import { LEVEL_VARIABLE_VALUE, VAR_DATASOURCE, VAR_FILTERS } from 'services/vari import { SelectServiceButton } from './SelectServiceButton'; import { PLUGIN_ID } from 'services/routing'; import { buildLokiQuery } from 'services/query'; -import { USER_EVENTS_ACTIONS, USER_EVENTS_PAGES, reportAppInteraction } from 'services/analytics'; +import { reportAppInteraction, USER_EVENTS_ACTIONS, USER_EVENTS_PAGES } from 'services/analytics'; import { getQueryRunner, setLeverColorOverrides } from 'services/panel'; import { ConfigureVolumeError } from './ConfigureVolumeError'; import { NoVolumeError } from './NoVolumeError'; -import { PluginPage } from '@grafana/runtime'; import { getLabelsFromSeries, toggleLevelFromFilter } from 'services/levels'; export const SERVICE_NAME = 'service_name'; @@ -61,10 +60,6 @@ interface ServiceSelectionSceneState extends SceneObjectState { serviceLevel: Map; } -export class StartingPointSelectedEvent extends BusEventBase { - public static type = 'start-point-selected-event'; -} - export class ServiceSelectionScene extends SceneObjectBase { protected _variableDependency = new VariableDependencyConfig(this, { // We want to subscribe to changes in datasource variables and update the top services when the datasource changes @@ -366,36 +361,34 @@ export class ServiceSelectionScene extends SceneObjectBase -
-
-
- {/** When services fetched, show how many services are we showing */} - {isServicesByVolumeLoading && ( - - )} - {!isServicesByVolumeLoading && <>Showing {servicesToQuery?.length ?? 0} services} -
- - } - placeholder="Search services" - onChange={onSearchChange} - /> - - {/** If we don't have any servicesByVolume, volume endpoint is probably not enabled */} - {!isServicesByVolumeLoading && volumeApiError && } - {!isServicesByVolumeLoading && !volumeApiError && !servicesByVolume?.length && } - {!isServicesByVolumeLoading && servicesToQuery && servicesToQuery.length > 0 && ( -
- -
+
+
+
+ {/** When services fetched, show how many services are we showing */} + {isServicesByVolumeLoading && ( + )} + {!isServicesByVolumeLoading && <>Showing {servicesToQuery?.length ?? 0} services}
+ + } + placeholder="Search services" + onChange={onSearchChange} + /> + + {/** If we don't have any servicesByVolume, volume endpoint is probably not enabled */} + {!isServicesByVolumeLoading && volumeApiError && } + {!isServicesByVolumeLoading && !volumeApiError && !servicesByVolume?.length && } + {!isServicesByVolumeLoading && servicesToQuery && servicesToQuery.length > 0 && ( +
+ +
+ )}
- +
); }; } diff --git a/src/Components/Table/Context/TableColumnsContext.tsx b/src/Components/Table/Context/TableColumnsContext.tsx index 7bbb1c1fa..1b5f24be4 100644 --- a/src/Components/Table/Context/TableColumnsContext.tsx +++ b/src/Components/Table/Context/TableColumnsContext.tsx @@ -95,6 +95,7 @@ export const TableColumnContextProvider = ({ (newColumns: FieldNameMetaStore) => { if (newColumns) { const columns = removeExtraColumns(newColumns); + setColumns(columns); // Sync react state update with scenes url management diff --git a/src/Components/Table/LineActionIcons.tsx b/src/Components/Table/LineActionIcons.tsx index 59fa44e7a..3fe360ead 100644 --- a/src/Components/Table/LineActionIcons.tsx +++ b/src/Components/Table/LineActionIcons.tsx @@ -5,6 +5,7 @@ import { css } from '@emotion/css'; import { SelectedTableRow } from 'Components/Table/LogLineCellComponent'; import { useQueryContext } from 'Components/Table/Context/QueryContext'; import { testIds } from '../../services/testIds'; +import { locationService } from '@grafana/runtime'; export enum UrlParameterType { SelectedLine = 'selectedLine', @@ -80,7 +81,8 @@ export function LineActionIcons(props: { rowIndex: number; value: unknown }) { tooltipPlacement="top" tabIndex={0} getText={() => { - const searchParams = new URLSearchParams(window.location.search); + const location = locationService.getLocation(); + const searchParams = new URLSearchParams(location.search); if (searchParams && timeRange) { const selectedLine: SelectedTableRow = { row: props.rowIndex, @@ -91,7 +93,10 @@ export function LineActionIcons(props: { rowIndex: number; value: unknown }) { searchParams.set(UrlParameterType.To, timeRange.to.toISOString()); searchParams.set(UrlParameterType.SelectedLine, JSON.stringify(selectedLine)); - return window.location.origin + window.location.pathname + '?' + searchParams.toString(); + // @todo can encoding + as %20 break other stuff? Can label names or values have + in them that we don't want encoded? Should we just update values? + // + encoding for whitespace is for application/x-www-form-urlencoded, which appears to be the default encoding for URLSearchParams, replacing + with %20 to keep urls meant for the browser from breaking + const searchString = searchParams.toString().replace(/\+/g, '%20'); + return window.location.origin + location.pathname + '?' + searchString; } return ''; }} diff --git a/src/services/routing.test.ts b/src/services/routing.test.ts new file mode 100644 index 000000000..ad0e5202d --- /dev/null +++ b/src/services/routing.test.ts @@ -0,0 +1,67 @@ +import { buildBreakdownUrl, buildServicesUrl, PageSlugs, ROUTES } from './routing'; + +describe('buildBreakdownUrl', () => { + const OLD_LOCATION = window.location; + + afterAll(() => { + Object.defineProperty(window, 'location', { + value: OLD_LOCATION, + writable: true, + }); + }); + + it('generates correct url for each page slug', () => { + Object.defineProperty(window, 'location', { + value: new URL( + 'http://localhost:3000/a/grafana-lokiexplore-app/explore?var-ds=DSID&from=now-5m&to=now&patterns=%5B%5D&var-fields=' + ), + writable: true, + }); + Object.keys(PageSlugs).forEach((slug) => { + const breakdownUrl = buildBreakdownUrl(slug); + expect(breakdownUrl).toBe(`${slug}?var-ds=DSID&from=now-5m&to=now&patterns=%5B%5D&var-fields=`); + }); + }); + + it('removes invalid url keys', () => { + Object.defineProperty(window, 'location', { + value: new URL( + 'http://localhost:3000/a/grafana-lokiexplore-app/explore?var-ds=DSID&from=now-5m&to=now&patterns=%5B%5D&var-fields=¬AThing=whoopsie' + ), + writable: true, + }); + + Object.keys(PageSlugs).forEach((slug) => { + const breakdownUrl = buildBreakdownUrl(slug); + expect(breakdownUrl).toBe(`${slug}?var-ds=DSID&from=now-5m&to=now&patterns=%5B%5D&var-fields=`); + }); + }); + + it('preserves valid url keys', () => { + Object.defineProperty(window, 'location', { + value: new URL( + 'http://localhost:3000/a/grafana-lokiexplore-app/explore/service/tempo-distributor/logs?var-ds=DSID&from=now-5m&to=now&patterns=%5B%5D&var-fields=&var-filters=service_name%7C%3D%7Ctempo-distributor&urlColumns=%5B%22Time%22,%22Line%22%5D&visualizationType=%22table%22' + ), + writable: true, + }); + + Object.keys(PageSlugs).forEach((slug) => { + const breakdownUrl = buildBreakdownUrl(slug); + expect(breakdownUrl).toBe( + `${slug}?var-ds=DSID&from=now-5m&to=now&patterns=%5B%5D&var-fields=&var-filters=service_name%7C%3D%7Ctempo-distributor&urlColumns=%5B%22Time%22,%22Line%22%5D&visualizationType=%22table%22` + ); + }); + }); + + it('service page will remove keys from breakdown routes', () => { + Object.defineProperty(window, 'location', { + value: new URL( + 'http://localhost:3000/a/grafana-lokiexplore-app/explore/service/tempo-distributor/logs?var-ds=DSID&from=now-5m&to=now&patterns=%5B%5D&var-fields=&var-filters=service_name%7C%3D%7Ctempo-distributor&urlColumns=%5B%22Time%22,%22Line%22%5D&visualizationType=%22table%22' + ), + writable: true, + }); + + const breakdownUrl = buildServicesUrl(ROUTES.explore()); + expect(breakdownUrl).toBe(`/a/grafana-lokiexplore-app/${PageSlugs.explore}?var-ds=DSID&from=now-5m&to=now`); + }); +}); diff --git a/src/services/routing.ts b/src/services/routing.ts index 3b4b12611..97c7f131c 100644 --- a/src/services/routing.ts +++ b/src/services/routing.ts @@ -1,15 +1,152 @@ import pluginJson from '../plugin.json'; +import { UrlQueryMap, urlUtil } from '@grafana/data'; +import { + VAR_DATASOURCE, + VAR_FIELD_GROUP_BY, + VAR_FIELDS, + VAR_FILTERS, + VAR_LABEL_GROUP_BY, + VAR_LINE_FILTER, + VAR_LOGS_FORMAT, + VAR_PATTERNS, +} from './variables'; +import { locationService } from '@grafana/runtime'; +import { SceneRouteMatch } from '@grafana/scenes'; export const PLUGIN_ID = pluginJson.id; export const PLUGIN_BASE_URL = `/a/${PLUGIN_ID}`; -export enum ROUTES { - Explore = 'explore', +export enum PageSlugs { + explore = 'explore', + logs = 'logs', + labels = 'labels', + patterns = 'patterns', + fields = 'fields', } -export const EXPLORATIONS_ROUTE = `${PLUGIN_BASE_URL}/${ROUTES.Explore}`; +export function encodeParameter(parameter: string): string { + return encodeURIComponent(parameter.replace(/\//g, '---')); +} + +export function decodeParameter(parameter: string): string { + return decodeURIComponent(parameter).replace(/---/g, '/'); +} + +export const ROUTES = { + explore: () => prefixRoute(PageSlugs.explore), + logs: (service: string) => prefixRoute(`${PageSlugs.explore}/service/${encodeParameter(service)}/${PageSlugs.logs}`), + fields: (service: string) => + prefixRoute(`${PageSlugs.explore}/service/${encodeParameter(service)}/${PageSlugs.fields}`), + patterns: (service: string) => + prefixRoute(`${PageSlugs.explore}/service/${encodeParameter(service)}/${PageSlugs.patterns}`), + labels: (service: string) => + prefixRoute(`${PageSlugs.explore}/service/${encodeParameter(service)}/${PageSlugs.labels}`), +}; + +export const ROUTE_DEFINITIONS: Record = { + explore: prefixRoute(PageSlugs.explore), + logs: prefixRoute(`${PageSlugs.explore}/service/:service/${PageSlugs.logs}`), + fields: prefixRoute(`${PageSlugs.explore}/service/:service/${PageSlugs.fields}`), + patterns: prefixRoute(`${PageSlugs.explore}/service/:service/${PageSlugs.patterns}`), + labels: prefixRoute(`${PageSlugs.explore}/service/:service/${PageSlugs.labels}`), +}; + +export const EXPLORATIONS_ROUTE = `${PLUGIN_BASE_URL}/${PageSlugs.explore}`; // Prefixes the route with the base URL of the plugin export function prefixRoute(route: string): string { return `${PLUGIN_BASE_URL}/${route}`; } + +// For redirect back to service, we just want to keep datasource, and timerange +export const SERVICE_URL_KEYS = ['from', 'to', `var-${VAR_DATASOURCE}`]; +//@todo why patterns and var-patterns? +export const DRILLDOWN_URL_KEYS = [ + 'from', + 'to', + 'mode', + 'urlColumns', + 'visualizationType', + `selectedLine`, + VAR_PATTERNS, + `var-${VAR_PATTERNS}`, + `var-${VAR_DATASOURCE}`, + `var-${VAR_FILTERS}`, + `var-${VAR_FIELDS}`, + `var-${VAR_FIELD_GROUP_BY}`, + `var-${VAR_LABEL_GROUP_BY}`, + `var-${VAR_DATASOURCE}`, + `var-${VAR_LOGS_FORMAT}`, + `var-${VAR_LINE_FILTER}`, +]; + +export function navigateToIndex() { + const location = locationService.getLocation(); + const serviceUrl = buildServicesUrl(ROUTES.explore()); + const currentUrl = location.pathname + location.search; + + if (serviceUrl === currentUrl) { + return; + } + + locationService.push(serviceUrl); +} + +export function navigateToBreakdown(path: PageSlugs | string, extraQueryParams?: UrlQueryMap) { + const location = locationService.getLocation(); + const pathParts = location.pathname.split('/'); + const currentSlug = pathParts[pathParts.length - 1]; + const breakdownUrl = buildBreakdownUrl(path, extraQueryParams); + + if (breakdownUrl === currentSlug + location.search) { + // Url did not change, don't add an event to browser history + return; + } + + locationService.push(breakdownUrl); +} + +export function buildBreakdownUrl(path: PageSlugs | string, extraQueryParams?: UrlQueryMap): string { + return urlUtil.renderUrl(path, buildBreakdownRoute(extraQueryParams)); +} + +export function buildBreakdownRoute(extraQueryParams?: UrlQueryMap): UrlQueryMap { + return { + ...Object.entries(urlUtil.getUrlSearchParams()).reduce((acc, [key, value]) => { + if (DRILLDOWN_URL_KEYS.includes(key)) { + acc[key] = value; + } + + return acc; + }, {}), + ...extraQueryParams, + }; +} + +export function buildServicesUrl(path: string, extraQueryParams?: UrlQueryMap): string { + return urlUtil.renderUrl(path, buildServicesRoute(extraQueryParams)); +} + +export function getSlug() { + const location = locationService.getLocation(); + const slug = location.pathname.slice(location.pathname.lastIndexOf('/') + 1, location.pathname.length); + return slug as PageSlugs; +} + +export function extractServiceFromRoute(routeMatch: SceneRouteMatch<{ service: string }>): { service: string } { + const service = routeMatch.params.service; + return { service }; +} + +export function buildServicesRoute(extraQueryParams?: UrlQueryMap): UrlQueryMap { + return { + ...Object.entries(urlUtil.getUrlSearchParams()).reduce((acc, [key, value]) => { + if (SERVICE_URL_KEYS.includes(key)) { + acc[key] = value; + } + + return acc; + }, {}), + ...extraQueryParams, + }; +} diff --git a/tests/appNavigation.spec.ts b/tests/appNavigation.spec.ts index 3174d7995..5198c790e 100644 --- a/tests/appNavigation.spec.ts +++ b/tests/appNavigation.spec.ts @@ -1,6 +1,5 @@ import pluginJson from '../src/plugin.json'; import { test, expect } from '@grafana/plugin-e2e'; -import { ROUTES } from '../src/services/routing'; import { ExplorePage } from './fixtures/explore'; test.describe('navigating app', () => { @@ -12,7 +11,7 @@ test.describe('navigating app', () => { }); test('explore page should render successfully', async ({ page }) => { - await page.goto(`/a/${pluginJson.id}/${ROUTES.Explore}`); + await page.goto(`/a/${pluginJson.id}/explore`); await expect(page.getByText('Data source')).toBeVisible(); }); @@ -20,6 +19,6 @@ test.describe('navigating app', () => { await explorePage.gotoServicesBreakdown(); await page.getByTestId('data-testid Toggle menu').click(); await page.getByTestId('data-testid navigation mega-menu').getByRole('link', { name: 'Logs' }).click(); - await expect(page).toHaveURL(/mode=service_selection/); + await expect(page).toHaveURL(/a\/grafana\-lokiexplore\-app\/explore\?patterns\=%5B%5D&var\-fields\=&var\-ds\=gdev\-loki&var\-patterns\=&var\-lineFilter\=&var\-logsFormat\=/); }); }); diff --git a/tests/fixtures/explore.ts b/tests/fixtures/explore.ts index 9f0c32c49..2e61c3a63 100644 --- a/tests/fixtures/explore.ts +++ b/tests/fixtures/explore.ts @@ -1,6 +1,5 @@ import type { Page, Locator } from '@playwright/test'; import pluginJson from '../../src/plugin.json'; -import { ROUTES } from '../../src/services/routing'; import { testIds } from '../../src/services/testIds'; export class ExplorePage { @@ -19,16 +18,17 @@ export class ExplorePage { } async gotoServices() { - await this.page.goto(`/a/${pluginJson.id}/${ROUTES.Explore}`); + await this.page.goto(`/a/${pluginJson.id}/explore`); } async addServiceName() { await this.firstServicePageSelect.click(); } + //@todo pull service from url if not in params async gotoServicesBreakdown() { await this.page.goto( - `/a/${pluginJson.id}/${ROUTES.Explore}?mode=service_details&var-patterns=&var-filters=service_name%7C%3D%7Ctempo-distributor&actionView=logs&var-logsFormat=%20%7C%20logfmt` + `/a/${pluginJson.id}/explore/service/tempo-distributor/logs?mode=service_details&patterns=[]&var-filters=service_name|=|tempo-distributor&var-logsFormat= | logfmt` ); } } diff --git a/yarn.lock b/yarn.lock index 8b318abfb..672d5ca13 100644 --- a/yarn.lock +++ b/yarn.lock @@ -812,16 +812,16 @@ rxjs "7.8.1" tslib "2.6.2" -"@grafana/scenes@4.32.0": - version "4.32.0" - resolved "https://registry.yarnpkg.com/@grafana/scenes/-/scenes-4.32.0.tgz#15a38cbc532f03fec7f1c7f5c9ec157b67d3bf6b" - integrity sha512-4B6MfBXd514EA0EPCzVCb+eVIKodSHAZQPsdnMtgjaw9OkbvPh2DR7lwbqY6aiaBxIY7bbhqiwQefZ+qatcSIw== +"@grafana/scenes@5.2.0": + version "5.2.0" + resolved "https://registry.yarnpkg.com/@grafana/scenes/-/scenes-5.2.0.tgz#a436e2185499aa9cbbc7db368c69640bcc3137d9" + integrity sha512-1JWPQPTvfH5ZeREy1rhwUbhQgok4Q3WO313zTYE58zOqfroGIe7MZio4yqtl4bYHyYsAzRhYpaZ4JCf8FQe2WQ== dependencies: "@grafana/e2e-selectors" "^11.0.0" "@leeoniya/ufuzzy" "^1.0.14" react-grid-layout "1.3.4" - react-use "17.4.0" - react-virtualized-auto-sizer "1.0.7" + react-use "17.5.0" + react-virtualized-auto-sizer "1.0.24" uuid "^9.0.0" "@grafana/schema@11.0.0": @@ -6147,7 +6147,7 @@ ms@^2.1.1: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== -nano-css@^5.3.1, nano-css@^5.6.1: +nano-css@^5.6.1: version "5.6.1" resolved "https://registry.yarnpkg.com/nano-css/-/nano-css-5.6.1.tgz#964120cb1af6cccaa6d0717a473ccd876b34c197" integrity sha512-T2Mhc//CepkTa3X4pUhKgbEheJHYAxD0VptuqFhDbGMUWVV2m+lkNiW/Ieuj35wrfC8Zm0l7HvssQh7zcEttSw== @@ -7185,26 +7185,6 @@ react-universal-interface@^0.6.2: resolved "https://registry.yarnpkg.com/react-universal-interface/-/react-universal-interface-0.6.2.tgz#5e8d438a01729a4dbbcbeeceb0b86be146fe2b3b" integrity sha512-dg8yXdcQmvgR13RIlZbTRQOoUrDciFVoSBZILwjE2LFISxZZ8loVJKAkuzswl5js8BHda79bIb2b84ehU8IjXw== -react-use@17.4.0: - version "17.4.0" - resolved "https://registry.yarnpkg.com/react-use/-/react-use-17.4.0.tgz#cefef258b0a6c534a5c8021c2528ac6e1a4cdc6d" - integrity sha512-TgbNTCA33Wl7xzIJegn1HndB4qTS9u03QUwyNycUnXaweZkE4Kq2SB+Yoxx8qbshkZGYBDvUXbXWRUmQDcZZ/Q== - dependencies: - "@types/js-cookie" "^2.2.6" - "@xobotyi/scrollbar-width" "^1.9.5" - copy-to-clipboard "^3.3.1" - fast-deep-equal "^3.1.3" - fast-shallow-equal "^1.0.0" - js-cookie "^2.2.1" - nano-css "^5.3.1" - react-universal-interface "^0.6.2" - resize-observer-polyfill "^1.5.1" - screenfull "^5.1.0" - set-harmonic-interval "^1.0.1" - throttle-debounce "^3.0.1" - ts-easing "^0.2.0" - tslib "^2.1.0" - react-use@17.5.0: version "17.5.0" resolved "https://registry.yarnpkg.com/react-use/-/react-use-17.5.0.tgz#1fae45638828a338291efa0f0c61862db7ee6442" @@ -7225,10 +7205,10 @@ react-use@17.5.0: ts-easing "^0.2.0" tslib "^2.1.0" -react-virtualized-auto-sizer@1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.7.tgz#bfb8414698ad1597912473de3e2e5f82180c1195" - integrity sha512-Mxi6lwOmjwIjC1X4gABXMJcKHsOo0xWl3E3ugOgufB8GJU+MqrtY35aBuvCYv/razQ1Vbp7h1gWJjGjoNN5pmA== +react-virtualized-auto-sizer@1.0.24: + version "1.0.24" + resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.24.tgz#3ebdc92f4b05ad65693b3cc8e7d8dd54924c0227" + integrity sha512-3kCn7N9NEb3FlvJrSHWGQ4iVl+ydQObq2fHMn12i5wbtm74zHOPhz/i64OL3c1S1vi9i2GXtZqNqUJTQ+BnNfg== react-window@1.8.10: version "1.8.10"