From 291e3ea9cfe2ca0826c57c3ebe9e93149106ae13 Mon Sep 17 00:00:00 2001 From: Matias Chomicki Date: Wed, 26 Nov 2025 12:00:15 +0100 Subject: [PATCH] Logs: Persist sort order in the Explore URL (#114350) * Logs: store sort order in the URL * ToolbarExtensionPoint: pass sort order to extension * Logs: send sort order in links * ToolbarExtensionPoint: pass panelState instead of sortOrder * Update test * Remove condition * Logs: initialize sort order and remove unnecessary check --- packages/grafana-data/src/types/explore.ts | 3 +- public/app/features/explore/Logs/Logs.tsx | 59 ++++++++++++------- .../extensions/ToolbarExtensionPoint.test.tsx | 25 +++++++- .../extensions/ToolbarExtensionPoint.tsx | 21 +++++-- 4 files changed, 77 insertions(+), 31 deletions(-) diff --git a/packages/grafana-data/src/types/explore.ts b/packages/grafana-data/src/types/explore.ts index c3755fa954f..71fd7eae35d 100644 --- a/packages/grafana-data/src/types/explore.ts +++ b/packages/grafana-data/src/types/explore.ts @@ -1,4 +1,4 @@ -import { DataQuery } from '@grafana/schema'; +import { DataQuery, LogsSortOrder } from '@grafana/schema'; import { PreferredVisualisationType } from './data'; import { SelectableValue } from './select'; @@ -84,6 +84,7 @@ export interface ExploreLogsPanelState { // Used for logs table visualisation, contains the refId of the dataFrame that is currently visualized refId?: string; displayedFields?: string[]; + sortOrder?: LogsSortOrder; } export interface SplitOpenOptions { diff --git a/public/app/features/explore/Logs/Logs.tsx b/public/app/features/explore/Logs/Logs.tsx index 02b63224e76..b958aebcc62 100644 --- a/public/app/features/explore/Logs/Logs.tsx +++ b/public/app/features/explore/Logs/Logs.tsx @@ -197,7 +197,7 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { ); const [dedupStrategy, setDedupStrategy] = useState(LogsDedupStrategy.none); const [logsSortOrder, setLogsSortOrder] = useState( - store.get(SETTINGS_KEYS.logsSortOrder) || LogsSortOrder.Descending + panelState?.logs?.sortOrder ?? store.get(SETTINGS_KEYS.logsSortOrder) ?? LogsSortOrder.Descending ); const [isFlipping, setIsFlipping] = useState(false); const [displayedFields, setDisplayedFields] = useState(panelState?.logs?.displayedFields ?? []); @@ -269,6 +269,18 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { } }, [dispatch, exploreId, loading, panelState, previousLoading]); + useEffect(() => { + // Initialize URL sort order + if (!panelState?.logs?.sortOrder) { + dispatch( + changePanelState(exploreId, 'logs', { + ...panelState, + sortOrder: logsSortOrder, + }) + ); + } + }, [dispatch, exploreId, logsSortOrder, panelState]); + useEffect(() => { const visualisationType = panelState?.logs?.visualisationType ?? getDefaultVisualisationType(); setVisualisationType(visualisationType); @@ -287,23 +299,17 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { useUnmount(() => { // If we're unmounting logs (e.g. switching to another datasource), we need to remove the logs specific panel state, otherwise it will persist in the explore url - if ( - panelState?.logs?.columns || - panelState?.logs?.refId || - panelState?.logs?.labelFieldName || - panelState?.logs?.displayedFields - ) { - dispatch( - changePanelState(exploreId, 'logs', { - ...panelState?.logs, - columns: undefined, - visualisationType: visualisationType, - labelFieldName: undefined, - refId: undefined, - displayedFields: undefined, - }) - ); - } + dispatch( + changePanelState(exploreId, 'logs', { + ...panelState?.logs, + columns: undefined, + visualisationType: visualisationType, + labelFieldName: undefined, + refId: undefined, + displayedFields: undefined, + sortOrder: undefined, + }) + ); }); const updatePanelState = useCallback( @@ -398,8 +404,14 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { dispatch(changeQueries({ exploreId, queries: newQueries })); dispatch(runQueries({ exploreId })); } + dispatch( + changePanelState(exploreId, 'logs', { + ...panelState?.logs, + sortOrder: newSortOrder, + }) + ); }, - [dispatch, exploreId, logsQueries] + [dispatch, exploreId, logsQueries, panelState?.logs] ); const onChangeLogsSortOrder = useCallback( @@ -588,7 +600,12 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { const urlState = getUrlStateFromPaneState(getState().explore.panes[exploreId]!); urlState.panelsState = { ...panelState, - logs: { id: row.uid, visualisationType: visualisationType ?? getDefaultVisualisationType(), displayedFields }, + logs: { + id: row.uid, + visualisationType: visualisationType ?? getDefaultVisualisationType(), + displayedFields, + sortOrder: logsSortOrder, + }, }; urlState.range = getLogsPermalinkRange(row, logRows, absoluteRange); @@ -604,7 +621,7 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { logRowLevel: row.logLevel, }); }, - [absoluteRange, displayedFields, exploreId, logRows, panelState, visualisationType] + [absoluteRange, displayedFields, exploreId, logRows, logsSortOrder, panelState, visualisationType] ); const scrollToTopLogs = useCallback(() => { diff --git a/public/app/features/explore/extensions/ToolbarExtensionPoint.test.tsx b/public/app/features/explore/extensions/ToolbarExtensionPoint.test.tsx index d3988d68e95..6fd93785099 100644 --- a/public/app/features/explore/extensions/ToolbarExtensionPoint.test.tsx +++ b/public/app/features/explore/extensions/ToolbarExtensionPoint.test.tsx @@ -3,9 +3,9 @@ import userEvent from '@testing-library/user-event'; import { ReactNode } from 'react'; import { Provider } from 'react-redux'; -import { PluginExtensionPoints, PluginExtensionTypes } from '@grafana/data'; +import { ExplorePanelsState, PluginExtensionPoints, PluginExtensionTypes } from '@grafana/data'; import { usePluginLinks } from '@grafana/runtime'; -import { DataQuery } from '@grafana/schema'; +import { DataQuery, LogsSortOrder } from '@grafana/schema'; import { contextSrv } from 'app/core/services/context_srv'; import { configureStore } from 'app/store/configureStore'; import { ExplorePanelData, ExploreState } from 'app/types/explore'; @@ -27,13 +27,14 @@ const usePluginLinksMock = jest.mocked(usePluginLinks); type storeOptions = { targets: DataQuery[]; data: ExplorePanelData; + panelsState?: ExplorePanelsState; }; function renderWithExploreStore( children: ReactNode, options: storeOptions = { targets: [{ refId: 'A' }], data: createEmptyQueryResponse() } ) { - const { targets, data } = options; + const { targets, data, panelsState } = options; const store = configureStore({ explore: { panes: { @@ -43,6 +44,7 @@ function renderWithExploreStore( range: { raw: { from: 'now-1h', to: 'now' }, }, + panelsState, }, }, } as unknown as ExploreState, @@ -90,6 +92,9 @@ describe('ToolbarExtensionPoint', () => { isLoading: false, }); }); + beforeEach(() => { + jest.mocked(usePluginLinksMock).mockClear(); + }); it('should render "Add" extension point menu button', () => { renderWithExploreStore(setupToolbarExtensionPoint()); @@ -180,6 +185,20 @@ describe('ToolbarExtensionPoint', () => { expect(extensionPointId).toBe(PluginExtensionPoints.ExploreToolbarAction); }); + + it('should pass panelsState to the extensions', async () => { + const panelsState: ExplorePanelsState = { + logs: { sortOrder: LogsSortOrder.Ascending, displayedFields: ['time', 'body'] }, + }; + const targets = [{ refId: 'A' }]; + const data = createEmptyQueryResponse(); + renderWithExploreStore(setupToolbarExtensionPoint(), { targets, data, panelsState }); + + const [options] = usePluginLinksMock.mock.calls[0]; + const { context } = options; + + expect(context).toHaveProperty('panelsState', panelsState); + }); }); describe('with extension points without categories', () => { diff --git a/public/app/features/explore/extensions/ToolbarExtensionPoint.tsx b/public/app/features/explore/extensions/ToolbarExtensionPoint.tsx index 070e48caa47..f9d6abe3df2 100644 --- a/public/app/features/explore/extensions/ToolbarExtensionPoint.tsx +++ b/public/app/features/explore/extensions/ToolbarExtensionPoint.tsx @@ -1,6 +1,12 @@ import { ReactElement, useMemo, useState } from 'react'; -import { type PluginExtensionLink, PluginExtensionPoints, RawTimeRange, getTimeZone } from '@grafana/data'; +import { + type ExplorePanelsState, + type PluginExtensionLink, + PluginExtensionPoints, + RawTimeRange, + getTimeZone, +} from '@grafana/data'; import { reportInteraction, usePluginLinks } from '@grafana/runtime'; import { DataQuery, TimeZone } from '@grafana/schema'; import { contextSrv } from 'app/core/services/context_srv'; @@ -89,13 +95,14 @@ export type PluginExtensionExploreContext = { timeRange: RawTimeRange; timeZone: TimeZone; shouldShowAddCorrelation: boolean; + panelsSate?: ExplorePanelsState; }; function useExtensionPointContext(props: Props): PluginExtensionExploreContext { const { exploreId, timeZone } = props; const isCorrelationDetails = useSelector(selectCorrelationDetails); const isCorrelationsEditorMode = isCorrelationDetails?.editorMode || false; - const { queries, queryResponse, range } = useSelector(getExploreItemSelector(exploreId))!; + const { queries, queryResponse, range, panelsState } = useSelector(getExploreItemSelector(exploreId))!; const isLeftPane = useSelector(isLeftPaneSelector(exploreId)); const datasourceUids = queries.map((query) => query?.datasource?.uid).filter((uid) => uid !== undefined); @@ -110,16 +117,18 @@ function useExtensionPointContext(props: Props): PluginExtensionExploreContext { timeRange: range.raw, timeZone: getTimeZone({ timeZone }), shouldShowAddCorrelation: canWriteCorrelations && !isCorrelationsEditorMode && isLeftPane && numUniqueIds === 1, + panelsState, }; }, [ + canWriteCorrelations, exploreId, + isCorrelationsEditorMode, + isLeftPane, + numUniqueIds, + panelsState, queries, queryResponse, range.raw, timeZone, - canWriteCorrelations, - isCorrelationsEditorMode, - isLeftPane, - numUniqueIds, ]); }