Explore: Sync logs displayed fields with URL state (#96242)

* Explore: store displayedFields in URL state

* Displayed fields: reset when queries change

* Add test

* LogRowMessageDisplayedFields: pass logRowMenuIcons props to component

* LogsMetaRow: use primary outline for reset button

* Logs: clear displayedFields from URL
This commit is contained in:
Matias Chomicki
2024-11-19 16:27:26 +01:00
committed by GitHub
parent 4a071c6491
commit f3783146bb
9 changed files with 141 additions and 21 deletions
@@ -54,6 +54,7 @@ export interface ExploreLogsPanelState {
labelFieldName?: string;
// Used for logs table visualisation, contains the refId of the dataFrame that is currently visualized
refId?: string;
displayedFields?: string[];
}
export interface SplitOpenOptions<T extends AnyQuery = AnyQuery> {
+23 -7
View File
@@ -150,12 +150,12 @@ describe('Logs', () => {
},
});
const { rerender } = render(
const rendered = render(
<Provider store={fakeStore}>
{getComponent(partialProps, dataFrame ? dataFrame : getMockLokiFrame(), logs)}
</Provider>
);
return { rerender, store: fakeStore };
return { ...rendered, store: fakeStore };
};
describe('scrolling behavior', () => {
@@ -428,12 +428,14 @@ describe('Logs', () => {
});
it('should call createAndCopyShortLink on permalinkClick - logs', async () => {
const panelState: Partial<ExplorePanelsState> = { logs: { id: 'not-included', visualisationType: 'logs' } };
const panelState: Partial<ExplorePanelsState> = {
logs: { id: 'not-included', visualisationType: 'logs', displayedFields: ['field'] },
};
const rows = [
makeLog({ uid: '1', rowId: 'id1', timeEpochMs: 1 }),
makeLog({ uid: '2', rowId: 'id2', timeEpochMs: 1 }),
makeLog({ uid: '3', rowId: 'id3', timeEpochMs: 2 }),
makeLog({ uid: '4', rowId: 'id3', timeEpochMs: 2 }),
makeLog({ uid: '1', rowId: 'id1', timeEpochMs: 1, labels: { field: '1' } }),
makeLog({ uid: '2', rowId: 'id2', timeEpochMs: 1, labels: { field: '2' } }),
makeLog({ uid: '3', rowId: 'id3', timeEpochMs: 2, labels: { field: '3' } }),
makeLog({ uid: '4', rowId: 'id3', timeEpochMs: 2, labels: { field: '4' } }),
];
setup({ loading: false, panelState, logRows: rows });
@@ -449,6 +451,7 @@ describe('Logs', () => {
)
);
expect(createAndCopyShortLink).toHaveBeenCalledWith(expect.stringMatching('visualisationType%22:%22logs'));
expect(createAndCopyShortLink).toHaveBeenCalledWith(expect.stringMatching('displayedFields%22:%5B%22field'));
});
it('should call createAndCopyShortLink on permalinkClick - with infinite scrolling', async () => {
@@ -480,6 +483,19 @@ describe('Logs', () => {
});
});
describe('displayed fields', () => {
it('should sync displayed fields from the URL', async () => {
const panelState: Partial<ExplorePanelsState> = {
logs: { id: 'not-included', visualisationType: 'logs', displayedFields: ['field'] },
};
const rows = [makeLog({ uid: '1', rowId: 'id1', timeEpochMs: 1, labels: { field: 'field value' } })];
setup({ loading: false, panelState, logRows: rows });
expect(await screen.findByText('field=field value')).toBeInTheDocument();
expect(screen.queryByText(/log message/)).not.toBeInTheDocument();
});
});
describe('with table visualisation', () => {
let originalVisualisationTypeValue = config.featureToggles.logsExploreTableVisualisation;
+59 -10
View File
@@ -10,7 +10,6 @@ import {
LogRowModel,
LogsMetaItem,
DataFrame,
DataQuery,
AbsoluteTimeRange,
GrafanaTheme2,
LoadingState,
@@ -37,6 +36,7 @@ import {
urlUtil,
} from '@grafana/data';
import { config, reportInteraction } from '@grafana/runtime';
import { DataQuery } from '@grafana/schema';
import {
Button,
InlineField,
@@ -79,7 +79,7 @@ import { LogsMetaRow } from './LogsMetaRow';
import LogsNavigation from './LogsNavigation';
import { LogsTableWrap, getLogsTableHeight } from './LogsTableWrap';
import { LogsVolumePanelList } from './LogsVolumePanelList';
import { SETTINGS_KEYS, visualisationTypeKey } from './utils/logs';
import { canKeepDisplayedFields, SETTINGS_KEYS, visualisationTypeKey } from './utils/logs';
interface Props extends Themeable2 {
width: number;
@@ -221,6 +221,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
const cancelFlippingTimer = useRef<number | undefined>(undefined);
const toggleLegendRef = useRef<(name: string, mode: SeriesVisibilityChangeMode) => void>(() => {});
const topLogsRef = useRef<HTMLDivElement>(null);
const prevLogsQueries = usePrevious(logsQueries);
const tableHeight = getLogsTableHeight();
const styles = getStyles(theme, wrapLogMessage, tableHeight);
@@ -330,6 +331,16 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
store.set(visualisationTypeKey, visualisationType);
}, [panelState?.logs?.visualisationType]);
useEffect(() => {
let displayedFields: string[] = [];
if (Array.isArray(panelState?.logs?.displayedFields)) {
displayedFields = panelState?.logs?.displayedFields;
} else if (panelState?.logs?.displayedFields && typeof panelState?.logs?.displayedFields === 'object') {
displayedFields = Object.values(panelState?.logs?.displayedFields);
}
setDisplayedFields(displayedFields);
}, [panelState?.logs?.displayedFields]);
useEffect(() => {
registerLogLevelsWithContentOutline();
}, [logsVolumeData?.data, hiddenLogLevels, registerLogLevelsWithContentOutline]);
@@ -344,8 +355,13 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
});
useUnmount(() => {
// If we're unmounting logs (e.g. switching to another datasource), we need to remove the table specific panel state, otherwise it will persist in the explore url
if (panelState?.logs?.columns || panelState?.logs?.refId || panelState?.logs?.labelFieldName) {
// 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,
@@ -353,6 +369,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
visualisationType: visualisationType,
labelFieldName: undefined,
refId: undefined,
displayedFields: undefined,
})
);
}
@@ -369,13 +386,35 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
visualisationType: logsPanelState.visualisationType ?? visualisationType,
labelFieldName: logsPanelState.labelFieldName,
refId: logsPanelState.refId ?? panelState?.logs?.refId,
displayedFields: logsPanelState.displayedFields ?? panelState?.logs?.displayedFields,
})
);
}
},
[dispatch, exploreId, panelState?.logs?.columns, panelState?.logs?.refId, visualisationType]
[
dispatch,
exploreId,
panelState?.logs?.columns,
panelState?.logs?.displayedFields,
panelState?.logs?.refId,
visualisationType,
]
);
useEffect(() => {
if (!prevLogsQueries) {
// Initial load, ignore
return;
}
if (!canKeepDisplayedFields(logsQueries, prevLogsQueries)) {
setDisplayedFields([]);
updatePanelState({
...panelState?.logs,
displayedFields: [],
});
}
}, [logsQueries, panelState?.logs, prevLogsQueries, updatePanelState]);
// actions
const onLogRowHover = useCallback(
(row?: LogRowModel) => {
@@ -559,20 +598,30 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
const index = displayedFields.indexOf(key);
if (index === -1) {
setDisplayedFields(displayedFields.concat(key));
const updatedDisplayedFields = displayedFields.concat(key);
setDisplayedFields(updatedDisplayedFields);
updatePanelState({
...panelState?.logs,
displayedFields: updatedDisplayedFields,
});
}
},
[displayedFields]
[displayedFields, panelState?.logs, updatePanelState]
);
const hideField = useCallback(
(key: string) => {
const index = displayedFields.indexOf(key);
if (index > -1) {
setDisplayedFields(displayedFields.filter((k) => key !== k));
const updatedDisplayedFields = displayedFields.filter((k) => key !== k);
setDisplayedFields(updatedDisplayedFields);
updatePanelState({
...panelState?.logs,
displayedFields: updatedDisplayedFields,
});
}
},
[displayedFields]
[displayedFields, panelState?.logs, updatePanelState]
);
const clearDetectedFields = useCallback(() => {
@@ -658,7 +707,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
const urlState = getUrlStateFromPaneState(getState().explore.panes[exploreId]!);
urlState.panelsState = {
...panelState,
logs: { id: row.uid, visualisationType: visualisationType ?? getDefaultVisualisationType() },
logs: { id: row.uid, visualisationType: visualisationType ?? getDefaultVisualisationType(), displayedFields },
};
urlState.range = getPermalinkRange(row);
@@ -139,7 +139,7 @@ export const LogsMetaRow = memo(
{
label: '',
value: (
<Button variant="secondary" size="sm" onClick={clearDetectedFields}>
<Button variant="primary" fill="outline" size="sm" onClick={clearDetectedFields}>
Show original line
</Button>
),
@@ -1,3 +1,6 @@
import { shallowCompare } from '@grafana/data';
import { DataQuery } from '@grafana/schema';
export const SETTINGS_KEYS = {
showLabels: 'grafana.explore.logs.showLabels',
showTime: 'grafana.explore.logs.showTime',
@@ -8,3 +11,15 @@ export const SETTINGS_KEYS = {
};
export const visualisationTypeKey = 'grafana.explore.logs.visualisationType';
export const canKeepDisplayedFields = (logsQueries: DataQuery[] | undefined, prevLogsQueries: DataQuery[]): boolean => {
if (!logsQueries) {
return false;
}
for (let i = 0; i < logsQueries.length; i++) {
if (!shallowCompare(logsQueries[i], prevLogsQueries[i])) {
return false;
}
}
return true;
};
@@ -299,6 +299,8 @@ class UnThemedLogRow extends PureComponent<Props, State> {
pinned={this.props.pinned}
mouseIsOver={this.state.mouseIsOver}
onBlur={this.onMouseLeave}
logRowMenuIconsBefore={logRowMenuIconsBefore}
logRowMenuIconsAfter={logRowMenuIconsAfter}
/>
) : (
<LogRowMessage
@@ -212,7 +212,6 @@ line3`;
const { row } = setup({ logRowMenuIconsBefore, logRowMenuIconsAfter });
await userEvent.hover(screen.getByText('test123'));
await userEvent.click(screen.getByLabelText('Addon before'));
await userEvent.click(screen.getByLabelText('Addon after'));
@@ -1,6 +1,8 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { createTheme, LogLevel } from '@grafana/data';
import { IconButton } from '@grafana/ui';
import { LogRowMessageDisplayedFields, Props } from './LogRowMessageDisplayedFields';
import { createLogRow } from './__mocks__/logRow';
@@ -44,4 +46,25 @@ describe('LogRowMessageDisplayedFields', () => {
expect(screen.getByText(/place=Earth/)).toBeInTheDocument();
expect(screen.getByText(/planet=Mars/)).toBeInTheDocument();
});
describe('With custom buttons', () => {
it('supports custom buttons before and after the default options', async () => {
const onBefore = jest.fn();
const logRowMenuIconsBefore = [
<IconButton name="eye-slash" onClick={onBefore} tooltip="Addon before" aria-label="Addon before" key={1} />,
];
const onAfter = jest.fn();
const logRowMenuIconsAfter = [
<IconButton name="rss" onClick={onAfter} tooltip="Addon after" aria-label="Addon after" key={1} />,
];
const { row } = setup({ logRowMenuIconsBefore, logRowMenuIconsAfter });
await userEvent.click(screen.getByLabelText('Addon before'));
await userEvent.click(screen.getByLabelText('Addon after'));
expect(onBefore).toHaveBeenCalledWith(expect.anything(), row);
expect(onAfter).toHaveBeenCalledWith(expect.anything(), row);
});
});
});
@@ -1,5 +1,5 @@
import { css } from '@emotion/css';
import { memo, useMemo } from 'react';
import { memo, ReactNode, useMemo } from 'react';
import { LogRowModel, Field, LinkModel, DataFrame } from '@grafana/data';
@@ -21,10 +21,23 @@ export interface Props {
pinned?: boolean;
mouseIsOver: boolean;
onBlur: () => void;
logRowMenuIconsBefore?: ReactNode[];
logRowMenuIconsAfter?: ReactNode[];
}
export const LogRowMessageDisplayedFields = memo((props: Props) => {
const { row, detectedFields, getFieldLinks, wrapLogMessage, styles, mouseIsOver, pinned, ...rest } = props;
const {
row,
detectedFields,
getFieldLinks,
wrapLogMessage,
styles,
mouseIsOver,
pinned,
logRowMenuIconsBefore,
logRowMenuIconsAfter,
...rest
} = props;
const wrapClassName = wrapLogMessage ? '' : displayedFieldsStyles.noWrap;
const fields = useMemo(() => getAllFields(row, getFieldLinks), [getFieldLinks, row]);
// only single key/value rows are filterable, so we only need the first field key for filtering
@@ -63,6 +76,8 @@ export const LogRowMessageDisplayedFields = memo((props: Props) => {
styles={styles}
pinned={pinned}
mouseIsOver={mouseIsOver}
addonBefore={logRowMenuIconsBefore}
addonAfter={logRowMenuIconsAfter}
{...rest}
/>
)}