Explore: Compact mode (#107351)

* feat(explore): implement compact mode feature

This commit implements compact mode for Grafana Explore to address issue #102867.

Features implemented:
- URL parameter support: ?compact=true or ?compact=1 activates compact mode
- Query rows auto-collapse: Query editors start collapsed showing summary line
- Content outline auto-collapse: Sections start collapsed to save space
- Single DrilldownAlertBox: Shows single banner in split view (no duplicates)

Changes:
- Added compactMode to ExploreState interface and selectors
- Added URL parameter parsing in useStateSync hook
- Modified QueryRows and QueryEditorRow components to support compact mode
- Updated DrilldownAlertBox logic to prevent duplicates in split view
- Fixed test files to use proper mock state objects

Fixes #102867

* Move compact mode to pane state and sync with URL

* Clean up

* Show Drilldown alert box only when not splitted

* Rename and clean up

* Clean up

* Add missing props

* Add missing props

* Add missing translations

---------

Co-authored-by: Piotr Jamróz <pm.jamroz@gmail.com>
This commit is contained in:
Jennifer Villa
2025-07-30 09:20:01 +02:00
committed by GitHub
co-authored by Piotr Jamróz
parent 1f025fe1a3
commit af066d2312
18 changed files with 111 additions and 26 deletions
@@ -49,6 +49,7 @@ export interface ExploreUrlState<T extends DataQuery = AnyQuery> {
queries: T[];
range: URLRange;
panelsState?: ExplorePanelsState;
compact?: boolean;
}
export interface ExplorePanelsState extends Partial<Record<PreferredVisualisationType, {}>> {
@@ -89,6 +90,7 @@ export interface SplitOpenOptions<T extends AnyQuery = AnyQuery> {
range?: TimeRange;
panelsState?: ExplorePanelsState;
correlationHelperData?: ExploreCorrelationHelperData;
compact?: boolean;
}
/**
@@ -108,6 +108,8 @@ const dummyProps: Props = {
dsToExplore: [],
},
changeDatasource: jest.fn(),
compact: false,
changeCompactMode: jest.fn(),
};
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
+27 -7
View File
@@ -58,7 +58,7 @@ import { SecondaryActions } from './SecondaryActions';
import TableContainer from './Table/TableContainer';
import { TraceViewContainer } from './TraceView/TraceViewContainer';
import { changeDatasource } from './state/datasource';
import { changeSize } from './state/explorePane';
import { changeSize, changeCompactMode } from './state/explorePane';
import { splitOpen } from './state/main';
import {
addQueryRow,
@@ -182,14 +182,16 @@ export class Explore extends PureComponent<Props, ExploreState> {
onContentOutlineToogle = () => {
store.set(CONTENT_OUTLINE_LOCAL_STORAGE_KEYS.visible, !this.state.contentOutlineVisible);
this.setState((state) => {
const newContentOutlineVisible = this.props.compact ? true : !state.contentOutlineVisible;
reportInteraction('explore_toolbar_contentoutline_clicked', {
item: 'outline',
type: state.contentOutlineVisible ? 'close' : 'open',
type: newContentOutlineVisible ? 'open' : 'close',
});
return {
contentOutlineVisible: !state.contentOutlineVisible,
contentOutlineVisible: newContentOutlineVisible,
};
});
this.props.changeCompactMode(this.props.exploreId, false);
};
/**
@@ -306,9 +308,12 @@ export class Explore extends PureComponent<Props, ExploreState> {
updateTimeRange({ exploreId, absoluteRange });
};
/**
* Used for interaction from the visualizations. Will open split view in compact mode.
*/
onSplitOpen = (panelType: string) => {
return async (options?: SplitOpenOptions) => {
this.props.splitOpen(options);
this.props.splitOpen(options ? { ...options, compact: true } : options);
if (options && this.props.datasourceInstance) {
const target = (await getDataSourceSrv().get(options.datasourceUid)).type;
const source =
@@ -573,6 +578,8 @@ export class Explore extends PureComponent<Props, ExploreState> {
correlationEditorHelperData,
showQueryInspector,
setShowQueryInspector,
splitted,
compact,
} = this.props;
const { contentOutlineVisible } = this.state;
const styles = getStyles(theme);
@@ -614,7 +621,7 @@ export class Explore extends PureComponent<Props, ExploreState> {
}}
>
<div className={styles.wrapper}>
{contentOutlineVisible && (
{contentOutlineVisible && !compact && (
<ContentOutline scroller={this.scrollElement} panelId={`content-outline-container-${exploreId}`} />
)}
<ScrollContainer
@@ -631,9 +638,19 @@ export class Explore extends PureComponent<Props, ExploreState> {
mergeSingleChild={true}
>
<PanelContainer className={styles.queryContainer}>
<DrilldownAlertBox datasourceType={datasourceInstance?.type || ''} />
{!splitted && <DrilldownAlertBox datasourceType={datasourceInstance?.type || ''} />}
{correlationsBox}
<QueryRows exploreId={exploreId} />
<QueryRows
exploreId={exploreId}
// Don't simply pass isOpen here to avoid opening the row when content outline is openend and
// triggers exiting from compact mode. If it's confusing we can change the behavior to exit
// compact mode explicitly with a button in the UI instead of exiting when row is opened or
// content outline is opened.
isOpen={compact ? false : undefined}
changeCompactMode={(compact: boolean) =>
this.props.changeCompactMode(this.props.exploreId, false)
}
/>
<SecondaryActions
// do not allow people to add queries with potentially different datasources in correlations editor mode
addQueryRowButtonDisabled={
@@ -748,6 +765,7 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
showRawPrometheus,
supplementaryQueries,
correlationEditorHelperData,
compact,
} = item;
const loading = selectIsWaitingForData(exploreId)(state);
@@ -774,6 +792,7 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
showRawPrometheus,
showFlameGraph,
splitted: isSplit(state),
compact,
loading,
logsSample,
showLogsSample,
@@ -794,6 +813,7 @@ const mapDispatchToProps = {
addQueryRow,
splitOpen,
setSupplementaryQueryEnabled,
changeCompactMode,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
+8 -4
View File
@@ -2,9 +2,9 @@ import { css, cx } from '@emotion/css';
import { useEffect } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { t, Trans } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { ErrorBoundaryAlert, useStyles2, useTheme2 } from '@grafana/ui';
import { ErrorBoundaryAlert, LoadingPlaceholder, useStyles2, useTheme2 } from '@grafana/ui';
import { SplitPaneWrapper } from 'app/core/components/SplitPaneWrapper/SplitPaneWrapper';
import { useGrafana } from 'app/core/context/GrafanaContext';
import { useNavModel } from 'app/core/hooks/useNavModel';
@@ -84,10 +84,14 @@ function ExplorePageContent(props: GrafanaRouteComponentProps<{}, ExploreQueryPa
paneStyle={{ overflow: 'auto', display: 'flex', flexDirection: 'column' }}
onDragFinished={(size) => size && updateSplitSize(size)}
>
{panes.map(([exploreId]) => {
{panes.map(([exploreId, pane]) => {
return (
<ErrorBoundaryAlert key={exploreId} style="page">
<ExplorePaneContainer exploreId={exploreId} />
{pane.initialized ? (
<ExplorePaneContainer exploreId={exploreId} />
) : (
<LoadingPlaceholder text={t('explore.pane.loading-placeholder', 'Loading...')} />
)}
</ErrorBoundaryAlert>
);
})}
@@ -79,7 +79,7 @@ describe('Explore QueryRows', () => {
render(
<Provider store={store}>
<QueryRows exploreId={'left'} />
<QueryRows exploreId={'left'} changeCompactMode={jest.fn()} />
</Provider>
);
@@ -100,7 +100,7 @@ describe('Explore QueryRows', () => {
render(
<Provider store={store}>
<QueryLibraryContextProviderMock queryLibraryEnabled={true}>
<QueryRows exploreId={'left'} />
<QueryRows exploreId={'left'} changeCompactMode={jest.fn()} />
</QueryLibraryContextProviderMock>
</Provider>
);
@@ -117,7 +117,7 @@ describe('Explore QueryRows', () => {
render(
<Provider store={store}>
<QueryLibraryContextProviderMock queryLibraryEnabled={false}>
<QueryRows exploreId={'left'} />
<QueryRows exploreId={'left'} changeCompactMode={jest.fn()} />
</QueryLibraryContextProviderMock>
</Provider>
);
+17 -6
View File
@@ -4,6 +4,7 @@ import { useCallback, useMemo } from 'react';
import { CoreApp, getNextRefId } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { DataQuery, DataSourceRef } from '@grafana/schema';
import { ExploreItemState } from 'app/types/explore';
import { useDispatch, useSelector } from 'app/types/store';
import { getDatasourceSrv } from '../plugins/datasource_srv';
@@ -16,23 +17,25 @@ import { getExploreItemSelector } from './state/selectors';
interface Props {
exploreId: string;
changeCompactMode: (compact: boolean) => void;
isOpen?: boolean;
}
const makeSelectors = (exploreId: string) => {
const exploreItemSelector = getExploreItemSelector(exploreId);
return {
getQueries: createSelector(exploreItemSelector, (s) => s!.queries),
getQueryResponse: createSelector(exploreItemSelector, (s) => s!.queryResponse),
getHistory: createSelector(exploreItemSelector, (s) => s!.history),
getEventBridge: createSelector(exploreItemSelector, (s) => s!.eventBridge),
getQueries: createSelector(exploreItemSelector, (s: ExploreItemState | undefined) => s!.queries),
getQueryResponse: createSelector(exploreItemSelector, (s: ExploreItemState | undefined) => s!.queryResponse),
getHistory: createSelector(exploreItemSelector, (s: ExploreItemState | undefined) => s!.history),
getEventBridge: createSelector(exploreItemSelector, (s: ExploreItemState | undefined) => s!.eventBridge),
getDatasourceInstanceSettings: createSelector(
exploreItemSelector,
(s) => getDatasourceSrv().getInstanceSettings(s!.datasourceInstance?.uid)!
(s: ExploreItemState | undefined) => getDatasourceSrv().getInstanceSettings(s!.datasourceInstance?.uid)!
),
};
};
export const QueryRows = ({ exploreId }: Props) => {
export const QueryRows = ({ exploreId, isOpen, changeCompactMode }: Props) => {
const dispatch = useDispatch();
const { getQueries, getDatasourceInstanceSettings, getQueryResponse, getHistory, getEventBridge } = useMemo(
() => makeSelectors(exploreId),
@@ -86,6 +89,12 @@ export const QueryRows = ({ exploreId }: Props) => {
reportInteraction('grafana_query_row_toggle', queryStatus === undefined ? {} : { queryEnabled: queryStatus });
};
const onQueryOpenChanged = () => {
// Disables compact mode when query is opened.
// Compact mode can also be disabled by opening Content Outline.
changeCompactMode(false);
};
return (
<QueryEditorRows
dsSettings={dsSettings}
@@ -98,10 +107,12 @@ export const QueryRows = ({ exploreId }: Props) => {
onQueryRemoved={onQueryRemoved}
onQueryToggled={onQueryToggled}
onQueryReplacedFromLibrary={onQueryReplacedFromLibrary}
onQueryOpenChanged={onQueryOpenChanged}
data={queryResponse}
app={CoreApp.Explore}
history={history}
eventBus={eventBridge}
isOpen={isOpen}
queryRowWrapper={(children, refId) => (
<ContentOutlineItem
title={refId}
@@ -13,6 +13,7 @@ export function getUrlStateFromPaneState(pane: ExploreItemState): ExploreUrlStat
range: toURLRange(pane.range.raw),
// don't include panelsState in the url unless a piece of state is actually set
panelsState: pruneObject(pane.panelsState),
compact: pane.compact,
};
}
@@ -118,5 +118,6 @@ function applyDefaults(input: unknown): ExploreUrlState {
hasKey('to', input.range) &&
typeof input.range.from === 'string' &&
typeof input.range.to === 'string' && { range: { from: input.range.from, to: input.range.to } }),
...(hasKey('compact', input) && typeof input.compact === 'boolean' && { compact: input.compact }),
};
}
@@ -74,6 +74,7 @@ export function syncFromURL(
queries: withUniqueRefIds(queries),
range: fromURLRange(range),
panelsState,
compact: !!urlPane.compact,
position: i,
eventBridge: new EventBusSrv(),
})
@@ -33,7 +33,7 @@ export function initializeFromURL(
dispatch(clearPanes());
Promise.all(
Object.entries(urlState.panes).map(([exploreId, { datasource, queries, range, panelsState }]) => {
Object.entries(urlState.panes).map(([exploreId, { datasource, queries, range, panelsState, compact }]) => {
return getPaneDatasource(datasource, queries, orgId).then((paneDatasource) => {
return Promise.resolve(
// Given the Grafana datasource will always be present, this should always be defined.
@@ -64,13 +64,13 @@ export function initializeFromURL(
];
}
return { exploreId, range, panelsState, queries: validQueries, datasource: paneDatasource };
return { exploreId, compact, range, panelsState, queries: validQueries, datasource: paneDatasource };
});
});
})
).then(async (panes) => {
const initializedPanes = await Promise.all(
panes.map(({ exploreId, range, panelsState, queries, datasource }) => {
panes.map(({ exploreId, range, panelsState, queries, datasource, compact }) => {
return dispatch(
initializeExplore({
exploreId,
@@ -79,6 +79,7 @@ export function initializeFromURL(
range: fromURLRange(range),
panelsState,
eventBridge: new EventBusSrv(),
compact: !!compact,
})
).unwrap();
})
@@ -5,7 +5,7 @@ import { MutableRefObject } from 'react';
import { UrlQueryMap } from '@grafana/data';
import { LocationService } from '@grafana/runtime';
import { changeDatasource } from 'app/features/explore/state/datasource';
import { changePanelsStateAction } from 'app/features/explore/state/explorePane';
import { changeCompactModeAction, changePanelsStateAction } from 'app/features/explore/state/explorePane';
import { splitClose, splitOpen } from 'app/features/explore/state/main';
import { runQueries } from 'app/features/explore/state/query';
import { changeRangeAction } from 'app/features/explore/state/time';
@@ -21,6 +21,7 @@ We want to update the URL when:
- range is changed
- panel state is updated
- a datasource change has completed.
- compact mode changes
Note: Changing datasource causes a bunch of actions to be dispatched, we want to update the URL
only when the change set has completed. This is done by checking if the changeDatasource.pending action
@@ -37,6 +38,7 @@ export function syncToURLPredicate(paused: MutableRefObject<boolean>, action: Ac
changeRangeAction.type,
changePanelsStateAction.type,
changeDatasource.fulfilled.type,
changeCompactModeAction.type,
].includes(action.type) && !paused.current
);
}
@@ -46,6 +46,12 @@ export interface ChangeSizePayload {
export const changeSizeAction = createAction<ChangeSizePayload>('explore/changeSize');
interface ChangeCompactModePayload {
exploreId: string;
compact: boolean;
}
export const changeCompactModeAction = createAction<ChangeCompactModePayload>('explore/changeCompactMode');
/**
* Tracks the state of explore panels that gets synced with the url.
*/
@@ -101,6 +107,7 @@ interface InitializeExplorePayload {
range: TimeRange;
history: HistoryItem[];
datasourceInstance?: DataSourceApi;
compact: boolean;
eventBridge: EventBusExtended;
}
@@ -127,6 +134,10 @@ export function changeSize(exploreId: string, { width }: { width: number }): Pay
return changeSizeAction({ exploreId, width });
}
export function changeCompactMode(exploreId: string, compact: boolean): PayloadAction<ChangeCompactModePayload> {
return changeCompactModeAction({ exploreId, compact });
}
export interface InitializeExploreOptions {
exploreId: string;
datasource: DataSourceRef | string | undefined;
@@ -136,6 +147,7 @@ export interface InitializeExploreOptions {
correlationHelperData?: ExploreCorrelationHelperData;
position?: number;
eventBridge: EventBusExtended;
compact: boolean;
}
/**
@@ -155,6 +167,7 @@ export const initializeExplore = createAsyncThunk(
queries,
range,
panelsState,
compact,
correlationHelperData,
eventBridge,
}: InitializeExploreOptions,
@@ -177,6 +190,7 @@ export const initializeExplore = createAsyncThunk(
range: getRange(range, getTimeZone(getState().user)),
datasourceInstance: instance,
history,
compact,
eventBridge,
})
);
@@ -226,6 +240,11 @@ export const paneReducer = (state: ExploreItemState = makeExplorePaneState(), ac
return { ...state, containerWidth };
}
if (changeCompactModeAction.match(action)) {
const compact = action.payload.compact;
return { ...state, compact };
}
if (changePanelsStateAction.match(action)) {
const { panelsState } = action.payload;
return { ...state, panelsState };
@@ -244,7 +263,7 @@ export const paneReducer = (state: ExploreItemState = makeExplorePaneState(), ac
}
if (initializeExploreAction.match(action)) {
const { queries, range, datasourceInstance, history, eventBridge } = action.payload;
const { queries, range, datasourceInstance, history, eventBridge, compact } = action.payload;
return {
...state,
@@ -258,6 +277,7 @@ export const paneReducer = (state: ExploreItemState = makeExplorePaneState(), ac
queryResponse: createEmptyQueryResponse(),
cache: [],
correlations: [],
compact,
};
}
@@ -96,6 +96,7 @@ export const splitOpen = createAsyncThunk(
panelsState: options?.panelsState || originState?.panelsState,
correlationHelperData: options?.correlationHelperData,
eventBridge: new EventBusSrv(),
compact: !!options?.compact,
})
);
@@ -75,6 +75,7 @@ export const makeExplorePaneState = (overrides?: Partial<ExploreItemState>): Exp
supplementaryQueries: loadSupplementaryQueries(),
panelsState: {},
correlations: undefined,
compact: false,
...overrides,
});
@@ -64,9 +64,11 @@ export interface Props<TQuery extends DataQuery> {
onQueryCopied?: () => void;
onQueryRemoved?: () => void;
onQueryToggled?: (queryStatus?: boolean | undefined) => void;
onQueryOpenChanged?: (status?: boolean | undefined) => void;
onQueryReplacedFromLibrary?: () => void;
collapsable?: boolean;
hideRefId?: boolean;
isOpen?: boolean;
}
interface State<TQuery extends DataQuery> {
@@ -433,7 +435,7 @@ export class QueryEditorRow<TQuery extends DataQuery> extends PureComponent<Prop
};
render() {
const { query, index, visualization, collapsable, hideActionButtons } = this.props;
const { query, index, visualization, collapsable, hideActionButtons, isOpen, onQueryOpenChanged } = this.props;
const { datasource, showingHelp, data } = this.state;
const isHidden = query.hide;
const error =
@@ -459,6 +461,8 @@ export class QueryEditorRow<TQuery extends DataQuery> extends PureComponent<Prop
index={index}
headerElement={this.renderHeader}
actions={hideActionButtons ? undefined : this.renderActions}
isOpen={isOpen}
onOpen={onQueryOpenChanged}
>
<div className={rowClasses} id={this.id}>
<ErrorBoundaryAlert>
@@ -37,9 +37,11 @@ export interface Props {
onQueryCopied?: () => void;
onQueryRemoved?: () => void;
onQueryToggled?: (queryStatus?: boolean | undefined) => void;
onQueryOpenChanged?: (status?: boolean | undefined) => void;
onUpdateDatasources?: (datasource: DataSourceRef) => void;
onQueryReplacedFromLibrary?: () => void;
queryRowWrapper?: (children: ReactNode, refId: string) => ReactNode;
isOpen?: boolean;
}
export class QueryEditorRows extends PureComponent<Props> {
@@ -173,8 +175,10 @@ export class QueryEditorRows extends PureComponent<Props> {
onQueryCopied,
onQueryRemoved,
onQueryToggled,
onQueryOpenChanged,
onQueryReplacedFromLibrary,
queryRowWrapper,
isOpen,
} = this.props;
return (
@@ -206,12 +210,14 @@ export class QueryEditorRows extends PureComponent<Props> {
onQueryCopied={onQueryCopied}
onQueryRemoved={onQueryRemoved}
onQueryToggled={onQueryToggled}
onQueryOpenChanged={onQueryOpenChanged}
onQueryReplacedFromLibrary={onQueryReplacedFromLibrary}
queries={queries}
app={app}
range={getTimeSrv().timeRange()}
history={history}
eventBus={eventBus}
isOpen={isOpen}
/>
);
+5
View File
@@ -229,6 +229,11 @@ export interface ExploreItemState {
correlationEditorHelperData?: ExploreCorrelationHelperData;
correlations?: CorrelationData[];
/**
* If set to true, all query rows will be collapsed initially and the content outline will be hidden
*/
compact: boolean;
}
export interface ExploreUpdateState {
+3
View File
@@ -7006,6 +7006,9 @@
"pro-tip-define-sources-through-configuration-files": " ProTip: You can also define data sources through configuration files. "
}
},
"pane": {
"loading-placeholder": "Loading..."
},
"prev": "Prev",
"queryless-apps-extensions": {
"aria-label-go-queryless": "Go queryless"