feat: add new expanded state to log options menu (#110725)

* feat: add new expanded state to log options menu
This commit is contained in:
Galen Kistler
2025-09-12 05:37:33 -05:00
committed by GitHub
parent 842ae463b7
commit 076c52e1b2
8 changed files with 623 additions and 192 deletions
@@ -1011,6 +1011,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
hasData && ( hasData && (
<div className={styles.logRowsWrapper} data-testid="logRows"> <div className={styles.logRowsWrapper} data-testid="logRows">
<ControlledLogRows <ControlledLogRows
ref={logsContainerRef}
logsTableFrames={props.logsFrames} logsTableFrames={props.logsFrames}
width={width} width={width}
updatePanelState={updatePanelState} updatePanelState={updatePanelState}
@@ -1,5 +1,5 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { useMemo } from 'react'; import { useMemo, useRef } from 'react';
import { EventBusSrv, GrafanaTheme2 } from '@grafana/data'; import { EventBusSrv, GrafanaTheme2 } from '@grafana/data';
import { useTheme2 } from '@grafana/ui'; import { useTheme2 } from '@grafana/ui';
@@ -8,7 +8,7 @@ import { LogsTableWrap } from '../../explore/Logs/LogsTableWrap';
import { LogRowsComponentProps } from './ControlledLogRows'; import { LogRowsComponentProps } from './ControlledLogRows';
import { useLogListContext } from './panel/LogListContext'; import { useLogListContext } from './panel/LogListContext';
import { LogListControls } from './panel/LogListControls'; import { CONTROLS_WIDTH, CONTROLS_WIDTH_EXPANDED, LogListControls } from './panel/LogListControls';
export const ControlledLogsTable = ({ export const ControlledLogsTable = ({
loading, loading,
@@ -26,8 +26,9 @@ export const ControlledLogsTable = ({
visualisationType, visualisationType,
...rest ...rest
}: LogRowsComponentProps) => { }: LogRowsComponentProps) => {
const { sortOrder } = useLogListContext(); const { sortOrder, controlsExpanded } = useLogListContext();
const eventBus = useMemo(() => new EventBusSrv(), []); const eventBus = useMemo(() => new EventBusSrv(), []);
const ref = useRef(null);
const theme = useTheme2(); const theme = useTheme2();
const styles = getStyles(theme); const styles = getStyles(theme);
@@ -37,8 +38,11 @@ export const ControlledLogsTable = ({
return; return;
} }
const tableWidthExpandedControls = width - (CONTROLS_WIDTH_EXPANDED + 12);
const tableWidth = width - (CONTROLS_WIDTH + 12);
return ( return (
<div className={styles.logRowsContainer}> <div ref={ref} className={styles.logRowsContainer}>
<LogListControls eventBus={eventBus} visualisationType={visualisationType} /> <LogListControls eventBus={eventBus} visualisationType={visualisationType} />
<div className={styles.logRows} data-testid="logRowsTable"> <div className={styles.logRows} data-testid="logRowsTable">
{/* Width should be full width minus logs navigation and padding */} {/* Width should be full width minus logs navigation and padding */}
@@ -47,7 +51,7 @@ export const ControlledLogsTable = ({
range={range} range={range}
splitOpen={splitOpen} splitOpen={splitOpen}
timeZone={rest.timeZone} timeZone={rest.timeZone}
width={width - 45} width={controlsExpanded ? tableWidthExpandedControls : tableWidth}
logsFrames={logsTableFrames ?? []} logsFrames={logsTableFrames ?? []}
onClickFilterLabel={onClickFilterLabel} onClickFilterLabel={onClickFilterLabel}
onClickFilterOutLabel={onClickFilterOutLabel} onClickFilterOutLabel={onClickFilterOutLabel}
@@ -40,6 +40,7 @@ import { getScrollbarWidth, LOG_LIST_CONTROLS_WIDTH, LOG_LIST_MIN_WIDTH } from '
export interface LogListContextData extends Omit<Props, 'containerElement' | 'logs' | 'logsMeta' | 'showControls'> { export interface LogListContextData extends Omit<Props, 'containerElement' | 'logs' | 'logsMeta' | 'showControls'> {
closeDetails: () => void; closeDetails: () => void;
controlsExpanded: boolean;
detailsDisplayed: (log: LogListModel) => boolean; detailsDisplayed: (log: LogListModel) => boolean;
detailsMode: LogLineDetailsMode; detailsMode: LogLineDetailsMode;
detailsWidth: number; detailsWidth: number;
@@ -51,6 +52,7 @@ export interface LogListContextData extends Omit<Props, 'containerElement' | 'lo
hasSampledLogs?: boolean; hasSampledLogs?: boolean;
hasUnescapedContent: boolean; hasUnescapedContent: boolean;
logLineMenuCustomItems?: LogLineMenuCustomItem[]; logLineMenuCustomItems?: LogLineMenuCustomItem[];
setControlsExpanded: (expanded: boolean) => void;
setDedupStrategy: (dedupStrategy: LogsDedupStrategy) => void; setDedupStrategy: (dedupStrategy: LogsDedupStrategy) => void;
setDetailsMode: (mode: LogLineDetailsMode) => void; setDetailsMode: (mode: LogLineDetailsMode) => void;
setDetailsWidth: (width: number) => void; setDetailsWidth: (width: number) => void;
@@ -76,6 +78,7 @@ export interface LogListContextData extends Omit<Props, 'containerElement' | 'lo
export const LogListContext = createContext<LogListContextData>({ export const LogListContext = createContext<LogListContextData>({
app: CoreApp.Unknown, app: CoreApp.Unknown,
closeDetails: () => {}, closeDetails: () => {},
controlsExpanded: false,
dedupStrategy: LogsDedupStrategy.none, dedupStrategy: LogsDedupStrategy.none,
detailsDisplayed: () => false, detailsDisplayed: () => false,
detailsMode: 'sidebar', detailsMode: 'sidebar',
@@ -88,6 +91,7 @@ export const LogListContext = createContext<LogListContextData>({
fontSize: 'default', fontSize: 'default',
hasUnescapedContent: false, hasUnescapedContent: false,
noInteractions: false, noInteractions: false,
setControlsExpanded: () => {},
setDedupStrategy: () => {}, setDedupStrategy: () => {},
setDetailsMode: () => {}, setDetailsMode: () => {},
setDetailsWidth: () => {}, setDetailsWidth: () => {},
@@ -393,6 +397,13 @@ export const LogListContextProvider = ({
})); }));
}, [timestampResolution]); }, [timestampResolution]);
const controlsExpandedFromStore = store.getBool(
`${logOptionsStorageKey}.controlsExpanded`,
getDefaultControlsExpandedMode(containerElement ?? null)
);
// If the user has a large viewport, show the expanded state by default
const [controlsExpanded, setControlsExpanded] = useState<boolean>(controlsExpandedFromStore);
const detailsDisplayed = useCallback( const detailsDisplayed = useCallback(
(log: LogListModel) => !!showDetails.find((shownLog) => shownLog.uid === log.uid), (log: LogListModel) => !!showDetails.find((shownLog) => shownLog.uid === log.uid),
[showDetails] [showDetails]
@@ -594,6 +605,7 @@ export const LogListContextProvider = ({
value={{ value={{
app, app,
closeDetails, closeDetails,
controlsExpanded,
detailsDisplayed, detailsDisplayed,
dedupStrategy: logListState.dedupStrategy, dedupStrategy: logListState.dedupStrategy,
detailsMode, detailsMode,
@@ -628,6 +640,7 @@ export const LogListContextProvider = ({
pinLineButtonTooltipTitle, pinLineButtonTooltipTitle,
pinnedLogs: logListState.pinnedLogs, pinnedLogs: logListState.pinnedLogs,
prettifyJSON, prettifyJSON,
setControlsExpanded,
setDedupStrategy, setDedupStrategy,
setDetailsMode, setDetailsMode,
setDetailsWidth, setDetailsWidth,
@@ -754,3 +767,8 @@ export function getDefaultDetailsMode(container: HTMLDivElement | undefined): Lo
const width = container?.clientWidth ?? window.innerWidth; const width = container?.clientWidth ?? window.innerWidth;
return width > 1440 ? 'sidebar' : 'inline'; return width > 1440 ? 'sidebar' : 'inline';
} }
export function getDefaultControlsExpandedMode(container: HTMLDivElement | null): boolean {
const width = container?.clientWidth ?? window.innerWidth;
return width > 1200;
}
@@ -12,6 +12,35 @@ import { LogListContextProvider } from './LogListContext';
import { LogListControls } from './LogListControls'; import { LogListControls } from './LogListControls';
import { ScrollToLogsEvent } from './virtualization'; import { ScrollToLogsEvent } from './virtualization';
const FILTER_LEVELS_LABEL_COPY = 'Filter levels';
const SCROLL_BOTTOM_LABEL_COPY = 'Scroll to bottom';
const SCROLL_TOP_LABEL_COPY = 'Scroll to top';
const OLDEST_LOGS_LABEL_COPY = 'Oldest logs first';
const DEDUPE_LABEL_COPY = 'Deduplication';
const SHOW_TIMESTAMP_LABEL_COPY = 'Show timestamps';
const WRAP_LINES_LABEL_COPY = 'Wrap lines';
const WRAP_JSON_TOOLTIP_COPY = 'Enable line wrapping and prettify JSON';
const WRAP_JSON_LABEL_COPY = 'Wrap JSON';
const WRAP_DISABLE_LABEL_COPY = 'Disable line wrapping';
const ENABLE_HIGHLIGHTING_LABEL_COPY = 'Enable highlighting';
const EXPANDED_LABEL_COPY = 'Expanded';
const COLLAPSED_LABEL_COPY = 'Collapsed';
const SHOW_UNIQUE_LABELS_LABEL_COPY = 'Show unique labels';
const HIDE_UNIQUE_LABELS_LABEL_COPY = 'Hide unique labels';
const EXPAND_JSON_LOGS_LABEL_COPY = 'Expand JSON logs';
const COLLAPSE_JSON_LOGS_LABEL_COPY = 'Collapse JSON logs';
const ESCAPE_NEWLINES_TOOLTIP_COPY = 'Fix incorrectly escaped newline and tab sequences in log lines';
const REMOVE_ESCAPE_NEWLINES_LABEL_COPY = 'Remove escaping';
const TIMESTAMP_LABEL_COPY = 'Log timestamps';
const TIMESTAMP_HIDE_LABEL_COPY = 'Hide timestamps';
const FONT_SIZE_LARGE_LABEL_COPY = 'Large font';
const FONT_SIZE_LARGE_TOOLTIP_COPY = 'Set large font';
const FONT_SIZE_SMALL_LABEL_COPY = 'Small font';
const FONT_SIZE_SMALL_TOOLTIP_COPY = 'Set small font';
const DOWNLOAD_LOGS_LABEL_COPY = 'Download logs';
const OLDEST_LOGS_LABEL_REGEX = /oldest logs first/;
jest.mock('../../utils', () => ({ jest.mock('../../utils', () => ({
...jest.requireActual('../../utils'), ...jest.requireActual('../../utils'),
downloadLogs: jest.fn(), downloadLogs: jest.fn(),
@@ -42,6 +71,13 @@ const contextProps = {
openAssistantByLog: () => {}, openAssistantByLog: () => {},
}; };
const assertExpandedOptionsCopyVisible = () => {
expect(screen.getByText(EXPANDED_LABEL_COPY)).toBeVisible();
expect(screen.getByText(SCROLL_BOTTOM_LABEL_COPY)).toBeVisible();
expect(screen.getByText(OLDEST_LOGS_LABEL_COPY)).toBeVisible();
expect(screen.getByText(DEDUPE_LABEL_COPY)).toBeVisible();
expect(screen.getByText(SCROLL_TOP_LABEL_COPY)).toBeVisible();
};
describe('LogListControls', () => { describe('LogListControls', () => {
test('Renders without errors', () => { test('Renders without errors', () => {
render( render(
@@ -49,20 +85,18 @@ describe('LogListControls', () => {
<LogListControls eventBus={new EventBusSrv()} /> <LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider> </LogListContextProvider>
); );
expect(screen.getByLabelText('Scroll to bottom')).toBeInTheDocument(); expect(screen.getByLabelText(SCROLL_BOTTOM_LABEL_COPY)).toBeInTheDocument();
expect(screen.getByLabelText(/oldest logs first/)).toBeInTheDocument(); expect(screen.getByLabelText(OLDEST_LOGS_LABEL_REGEX)).toBeInTheDocument();
expect(screen.getByLabelText('Deduplication')).toBeInTheDocument(); expect(screen.getByLabelText(DEDUPE_LABEL_COPY)).toBeInTheDocument();
expect(screen.getByLabelText('Display levels')).toBeInTheDocument(); expect(screen.getByLabelText(FILTER_LEVELS_LABEL_COPY)).toBeInTheDocument();
expect(screen.getByLabelText('Show timestamps')).toBeInTheDocument(); expect(screen.getByLabelText(SHOW_TIMESTAMP_LABEL_COPY)).toBeInTheDocument();
expect(screen.getByLabelText('Wrap lines')).toBeInTheDocument(); expect(screen.getByLabelText(WRAP_LINES_LABEL_COPY)).toBeInTheDocument();
expect(screen.getByLabelText('Enable highlighting')).toBeInTheDocument(); expect(screen.getByLabelText(ENABLE_HIGHLIGHTING_LABEL_COPY)).toBeInTheDocument();
expect(screen.getByLabelText('Scroll to top')).toBeInTheDocument(); expect(screen.getByLabelText(SCROLL_TOP_LABEL_COPY)).toBeInTheDocument();
expect(screen.queryByLabelText('Show unique labels')).not.toBeInTheDocument(); expect(screen.queryByLabelText(SHOW_UNIQUE_LABELS_LABEL_COPY)).not.toBeInTheDocument();
expect(screen.queryByLabelText('Expand JSON logs')).not.toBeInTheDocument(); expect(screen.queryByLabelText(EXPAND_JSON_LOGS_LABEL_COPY)).not.toBeInTheDocument();
expect( expect(screen.queryByLabelText(ESCAPE_NEWLINES_TOOLTIP_COPY)).not.toBeInTheDocument();
screen.queryByLabelText('Fix incorrectly escaped newline and tab sequences in log lines') expect(screen.queryByLabelText(REMOVE_ESCAPE_NEWLINES_LABEL_COPY)).not.toBeInTheDocument();
).not.toBeInTheDocument();
expect(screen.queryByLabelText('Remove escaping')).not.toBeInTheDocument();
}); });
test('Renders legacy controls', () => { test('Renders legacy controls', () => {
@@ -71,8 +105,8 @@ describe('LogListControls', () => {
<LogListControls eventBus={new EventBusSrv()} /> <LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider> </LogListContextProvider>
); );
expect(screen.getByLabelText('Show unique labels')).toBeInTheDocument(); expect(screen.getByLabelText(SHOW_UNIQUE_LABELS_LABEL_COPY)).toBeInTheDocument();
expect(screen.getByLabelText('Expand JSON logs')).toBeInTheDocument(); expect(screen.getByLabelText(EXPAND_JSON_LOGS_LABEL_COPY)).toBeInTheDocument();
}); });
test.each([CoreApp.Dashboard, CoreApp.PanelEditor, CoreApp.PanelViewer])( test.each([CoreApp.Dashboard, CoreApp.PanelEditor, CoreApp.PanelViewer])(
@@ -83,14 +117,14 @@ describe('LogListControls', () => {
<LogListControls eventBus={new EventBusSrv()} /> <LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider> </LogListContextProvider>
); );
expect(screen.getByLabelText('Scroll to bottom')).toBeInTheDocument(); expect(screen.getByLabelText(SCROLL_BOTTOM_LABEL_COPY)).toBeInTheDocument();
expect(screen.getByLabelText('Scroll to top')).toBeInTheDocument(); expect(screen.getByLabelText(SCROLL_TOP_LABEL_COPY)).toBeInTheDocument();
expect(screen.getByLabelText('Display levels')).toBeInTheDocument(); expect(screen.getByLabelText(FILTER_LEVELS_LABEL_COPY)).toBeInTheDocument();
expect(screen.queryByLabelText(/oldest logs first/)).not.toBeInTheDocument(); expect(screen.queryByLabelText(OLDEST_LOGS_LABEL_REGEX)).not.toBeInTheDocument();
expect(screen.queryByLabelText('Deduplication')).not.toBeInTheDocument(); expect(screen.queryByLabelText(DEDUPE_LABEL_COPY)).not.toBeInTheDocument();
expect(screen.queryByLabelText('Show timestamps')).not.toBeInTheDocument(); expect(screen.queryByLabelText(SHOW_TIMESTAMP_LABEL_COPY)).not.toBeInTheDocument();
expect(screen.queryByLabelText('Wrap lines')).not.toBeInTheDocument(); expect(screen.queryByLabelText(WRAP_LINES_LABEL_COPY)).not.toBeInTheDocument();
expect(screen.queryByLabelText('Enable highlighting')).not.toBeInTheDocument(); expect(screen.queryByLabelText(ENABLE_HIGHLIGHTING_LABEL_COPY)).not.toBeInTheDocument();
} }
); );
@@ -100,20 +134,18 @@ describe('LogListControls', () => {
<LogListControls eventBus={new EventBusSrv()} /> <LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider> </LogListContextProvider>
); );
expect(screen.getByLabelText('Scroll to bottom')).toBeInTheDocument(); expect(screen.getByLabelText(SCROLL_BOTTOM_LABEL_COPY)).toBeInTheDocument();
expect(screen.getByLabelText(/oldest logs first/)).toBeInTheDocument(); expect(screen.getByLabelText(OLDEST_LOGS_LABEL_REGEX)).toBeInTheDocument();
expect(screen.getByLabelText('Deduplication')).toBeInTheDocument(); expect(screen.getByLabelText(DEDUPE_LABEL_COPY)).toBeInTheDocument();
expect(screen.getByLabelText('Display levels')).toBeInTheDocument(); expect(screen.getByLabelText(FILTER_LEVELS_LABEL_COPY)).toBeInTheDocument();
expect(screen.getByLabelText('Show timestamps')).toBeInTheDocument(); expect(screen.getByLabelText(SHOW_TIMESTAMP_LABEL_COPY)).toBeInTheDocument();
expect(screen.getByLabelText('Wrap lines')).toBeInTheDocument(); expect(screen.getByLabelText(WRAP_LINES_LABEL_COPY)).toBeInTheDocument();
expect(screen.getByLabelText('Enable highlighting')).toBeInTheDocument(); expect(screen.getByLabelText(ENABLE_HIGHLIGHTING_LABEL_COPY)).toBeInTheDocument();
expect(screen.getByLabelText('Scroll to top')).toBeInTheDocument(); expect(screen.getByLabelText(SCROLL_TOP_LABEL_COPY)).toBeInTheDocument();
expect(screen.queryByLabelText('Show unique labels')).not.toBeInTheDocument(); expect(screen.queryByLabelText(SHOW_UNIQUE_LABELS_LABEL_COPY)).not.toBeInTheDocument();
expect(screen.queryByLabelText('Expand JSON logs')).not.toBeInTheDocument(); expect(screen.queryByLabelText(EXPAND_JSON_LOGS_LABEL_COPY)).not.toBeInTheDocument();
expect( expect(screen.queryByLabelText(ESCAPE_NEWLINES_TOOLTIP_COPY)).not.toBeInTheDocument();
screen.queryByLabelText('Fix incorrectly escaped newline and tab sequences in log lines') expect(screen.queryByLabelText(REMOVE_ESCAPE_NEWLINES_LABEL_COPY)).not.toBeInTheDocument();
).not.toBeInTheDocument();
expect(screen.queryByLabelText('Remove escaping')).not.toBeInTheDocument();
}); });
test('Allows to scroll', async () => { test('Allows to scroll', async () => {
@@ -124,8 +156,8 @@ describe('LogListControls', () => {
<LogListControls eventBus={eventBus} /> <LogListControls eventBus={eventBus} />
</LogListContextProvider> </LogListContextProvider>
); );
await userEvent.click(screen.getByLabelText('Scroll to bottom')); await userEvent.click(screen.getByLabelText(SCROLL_BOTTOM_LABEL_COPY));
await userEvent.click(screen.getByLabelText('Scroll to top')); await userEvent.click(screen.getByLabelText(SCROLL_TOP_LABEL_COPY));
expect(eventBus.publish).toHaveBeenCalledTimes(2); expect(eventBus.publish).toHaveBeenCalledTimes(2);
expect(eventBus.publish).toHaveBeenCalledWith( expect(eventBus.publish).toHaveBeenCalledWith(
new ScrollToLogsEvent({ new ScrollToLogsEvent({
@@ -139,6 +171,45 @@ describe('LogListControls', () => {
); );
}); });
test('Expands options', async () => {
render(
<LogListContextProvider {...contextProps} sortOrder={LogsSortOrder.Ascending}>
<LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider>
);
// Initial state should be collapsed
expect(screen.getByLabelText(COLLAPSED_LABEL_COPY)).toBeVisible();
// Expanded label should not be visible
expect(screen.queryByText(EXPANDED_LABEL_COPY)).not.toBeInTheDocument();
// Expand options
await userEvent.click(screen.getByLabelText(COLLAPSED_LABEL_COPY));
// Verify that the label (state) is not collapsed
expect(screen.queryByLabelText(COLLAPSED_LABEL_COPY)).not.toBeInTheDocument();
expect(screen.getByLabelText(EXPANDED_LABEL_COPY)).toBeVisible();
// Verify the expanded labels are rendered
assertExpandedOptionsCopyVisible();
});
test('Expands options shown by default with container width > 1200', async () => {
const div = document.createElement('div');
const divSpy = jest.spyOn(div, 'clientWidth', 'get');
//@ts-expect-error
divSpy['clientWidth'] = 1201;
render(
//@ts-expect-error
<LogListContextProvider {...contextProps} sortOrder={LogsSortOrder.Ascending} containerElement={divSpy}>
<LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider>
);
// Verify the expanded labels are rendered
assertExpandedOptionsCopyVisible();
// Collapse options
await userEvent.click(screen.getByLabelText(EXPANDED_LABEL_COPY));
// State should be collapsed
expect(screen.getByLabelText(COLLAPSED_LABEL_COPY)).toBeVisible();
});
test('Controls sort order', async () => { test('Controls sort order', async () => {
const onLogOptionsChange = jest.fn(); const onLogOptionsChange = jest.fn();
render( render(
@@ -150,7 +221,7 @@ describe('LogListControls', () => {
<LogListControls eventBus={new EventBusSrv()} /> <LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider> </LogListContextProvider>
); );
await userEvent.click(screen.getByLabelText(/oldest logs first/)); await userEvent.click(screen.getByLabelText(OLDEST_LOGS_LABEL_REGEX));
expect(onLogOptionsChange).toHaveBeenCalledTimes(1); expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
expect(onLogOptionsChange).toHaveBeenCalledWith('sortOrder', LogsSortOrder.Descending); expect(onLogOptionsChange).toHaveBeenCalledWith('sortOrder', LogsSortOrder.Descending);
}); });
@@ -162,7 +233,7 @@ describe('LogListControls', () => {
<LogListControls eventBus={new EventBusSrv()} /> <LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider> </LogListContextProvider>
); );
await userEvent.click(screen.getByLabelText('Deduplication')); await userEvent.click(screen.getByLabelText(DEDUPE_LABEL_COPY));
await userEvent.click(screen.getByText('Numbers')); await userEvent.click(screen.getByText('Numbers'));
expect(onLogOptionsChange).toHaveBeenCalledTimes(1); expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
expect(onLogOptionsChange).toHaveBeenCalledWith('dedupStrategy', LogsDedupStrategy.numbers); expect(onLogOptionsChange).toHaveBeenCalledWith('dedupStrategy', LogsDedupStrategy.numbers);
@@ -175,7 +246,7 @@ describe('LogListControls', () => {
<LogListControls eventBus={new EventBusSrv()} /> <LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider> </LogListContextProvider>
); );
await userEvent.click(screen.getByLabelText('Display levels')); await userEvent.click(screen.getByLabelText(FILTER_LEVELS_LABEL_COPY));
expect(await screen.findByText('All levels')).toBeVisible(); expect(await screen.findByText('All levels')).toBeVisible();
expect(screen.getByText('Info')).toBeVisible(); expect(screen.getByText('Info')).toBeVisible();
expect(screen.getByText('Debug')).toBeVisible(); expect(screen.getByText('Debug')).toBeVisible();
@@ -194,7 +265,7 @@ describe('LogListControls', () => {
<LogListControls eventBus={new EventBusSrv()} /> <LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider> </LogListContextProvider>
); );
await userEvent.click(screen.getByLabelText('Show timestamps')); await userEvent.click(screen.getByLabelText(SHOW_TIMESTAMP_LABEL_COPY));
expect(onLogOptionsChange).toHaveBeenCalledTimes(1); expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
expect(onLogOptionsChange).toHaveBeenCalledWith('showTime', true); expect(onLogOptionsChange).toHaveBeenCalledWith('showTime', true);
}); });
@@ -206,7 +277,7 @@ describe('LogListControls', () => {
<LogListControls eventBus={new EventBusSrv()} /> <LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider> </LogListContextProvider>
); );
await userEvent.click(screen.getByLabelText('Wrap lines')); await userEvent.click(screen.getByLabelText(WRAP_LINES_LABEL_COPY));
expect(onLogOptionsChange).toHaveBeenCalledTimes(1); expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
expect(onLogOptionsChange).toHaveBeenCalledWith('wrapLogMessage', true); expect(onLogOptionsChange).toHaveBeenCalledWith('wrapLogMessage', true);
}); });
@@ -227,21 +298,21 @@ describe('LogListControls', () => {
</LogListContextProvider> </LogListContextProvider>
); );
await userEvent.click(screen.getByLabelText('Log line wrapping')); await userEvent.click(screen.getByLabelText('Wrap disabled'));
await userEvent.click(screen.getByText('Enable line wrapping')); await userEvent.click(screen.getByText('Enable line wrapping'));
expect(onLogOptionsChange).toHaveBeenCalledTimes(2); expect(onLogOptionsChange).toHaveBeenCalledTimes(2);
expect(onLogOptionsChange).toHaveBeenCalledWith('wrapLogMessage', true); expect(onLogOptionsChange).toHaveBeenCalledWith('wrapLogMessage', true);
expect(onLogOptionsChange).toHaveBeenCalledWith('prettifyJSON', false); expect(onLogOptionsChange).toHaveBeenCalledWith('prettifyJSON', false);
await userEvent.click(screen.getByLabelText('Log line wrapping')); await userEvent.click(screen.getByLabelText(WRAP_LINES_LABEL_COPY));
await userEvent.click(screen.getByText('Enable line wrapping and prettify JSON')); await userEvent.click(screen.getByText(WRAP_JSON_TOOLTIP_COPY));
expect(onLogOptionsChange).toHaveBeenCalledTimes(4); expect(onLogOptionsChange).toHaveBeenCalledTimes(4);
expect(onLogOptionsChange).toHaveBeenCalledWith('prettifyJSON', true); expect(onLogOptionsChange).toHaveBeenCalledWith('prettifyJSON', true);
await userEvent.click(screen.getByLabelText('Log line wrapping')); await userEvent.click(screen.getByLabelText(WRAP_JSON_LABEL_COPY));
await userEvent.click(screen.getByText('Disable line wrapping')); await userEvent.click(screen.getByText(WRAP_DISABLE_LABEL_COPY));
expect(onLogOptionsChange).toHaveBeenCalledWith('wrapLogMessage', false); expect(onLogOptionsChange).toHaveBeenCalledWith('wrapLogMessage', false);
expect(onLogOptionsChange).toHaveBeenCalledWith('prettifyJSON', false); expect(onLogOptionsChange).toHaveBeenCalledWith('prettifyJSON', false);
@@ -262,19 +333,19 @@ describe('LogListControls', () => {
</LogListContextProvider> </LogListContextProvider>
); );
await userEvent.click(screen.getByLabelText('Log timestamps')); await userEvent.click(screen.getByLabelText(TIMESTAMP_LABEL_COPY));
await userEvent.click(screen.getByText('Show millisecond timestamps')); await userEvent.click(screen.getByText('Show millisecond timestamps'));
expect(onLogOptionsChange).toHaveBeenCalledTimes(1); expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
expect(onLogOptionsChange).toHaveBeenCalledWith('showTime', true); expect(onLogOptionsChange).toHaveBeenCalledWith('showTime', true);
await userEvent.click(screen.getByLabelText('Log timestamps')); await userEvent.click(screen.getByLabelText(TIMESTAMP_LABEL_COPY));
await userEvent.click(screen.getByText('Show nanosecond timestamps')); await userEvent.click(screen.getByText('Show nanosecond timestamps'));
expect(onLogOptionsChange).toHaveBeenCalledTimes(2); expect(onLogOptionsChange).toHaveBeenCalledTimes(2);
await userEvent.click(screen.getByLabelText('Log timestamps')); await userEvent.click(screen.getByLabelText(TIMESTAMP_LABEL_COPY));
await userEvent.click(screen.getByText('Hide timestamps')); await userEvent.click(screen.getByText(TIMESTAMP_HIDE_LABEL_COPY));
expect(onLogOptionsChange).toHaveBeenCalledTimes(3); expect(onLogOptionsChange).toHaveBeenCalledTimes(3);
expect(onLogOptionsChange).toHaveBeenCalledWith('showTime', false); expect(onLogOptionsChange).toHaveBeenCalledWith('showTime', false);
@@ -289,7 +360,7 @@ describe('LogListControls', () => {
<LogListControls eventBus={new EventBusSrv()} /> <LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider> </LogListContextProvider>
); );
await userEvent.click(screen.getByLabelText('Enable highlighting')); await userEvent.click(screen.getByLabelText(ENABLE_HIGHLIGHTING_LABEL_COPY));
expect(onLogOptionsChange).toHaveBeenCalledTimes(1); expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
expect(onLogOptionsChange).toHaveBeenCalledWith('syntaxHighlighting', true); expect(onLogOptionsChange).toHaveBeenCalledWith('syntaxHighlighting', true);
}); });
@@ -300,13 +371,13 @@ describe('LogListControls', () => {
<LogListControls eventBus={new EventBusSrv()} /> <LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider> </LogListContextProvider>
); );
await userEvent.click(screen.getByLabelText('Show unique labels')); await userEvent.click(screen.getByLabelText(SHOW_UNIQUE_LABELS_LABEL_COPY));
rerender( rerender(
<LogListContextProvider {...contextProps} app={CoreApp.Explore} showUniqueLabels={false}> <LogListContextProvider {...contextProps} app={CoreApp.Explore} showUniqueLabels={false}>
<LogListControls eventBus={new EventBusSrv()} /> <LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider> </LogListContextProvider>
); );
expect(screen.getByLabelText('Hide unique labels')); expect(screen.getByLabelText(HIDE_UNIQUE_LABELS_LABEL_COPY));
}); });
test('Controls Expand JSON logs', async () => { test('Controls Expand JSON logs', async () => {
@@ -315,13 +386,13 @@ describe('LogListControls', () => {
<LogListControls eventBus={new EventBusSrv()} /> <LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider> </LogListContextProvider>
); );
await userEvent.click(screen.getByLabelText('Expand JSON logs')); await userEvent.click(screen.getByLabelText(EXPAND_JSON_LOGS_LABEL_COPY));
rerender( rerender(
<LogListContextProvider {...contextProps} showUniqueLabels={false}> <LogListContextProvider {...contextProps} showUniqueLabels={false}>
<LogListControls eventBus={new EventBusSrv()} /> <LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider> </LogListContextProvider>
); );
expect(screen.getByLabelText('Collapse JSON logs')); expect(screen.getByLabelText(COLLAPSE_JSON_LOGS_LABEL_COPY));
}); });
test('Controls font size', async () => { test('Controls font size', async () => {
@@ -333,11 +404,11 @@ describe('LogListControls', () => {
<LogListControls eventBus={new EventBusSrv()} /> <LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider> </LogListContextProvider>
); );
await userEvent.click(screen.getByLabelText('Use small font size')); await userEvent.click(screen.getByLabelText(FONT_SIZE_LARGE_LABEL_COPY));
await screen.findByLabelText('Use default font size'); await screen.findByLabelText(FONT_SIZE_LARGE_TOOLTIP_COPY);
await userEvent.click(screen.getByLabelText('Use default font size')); await userEvent.click(screen.getByLabelText(FONT_SIZE_SMALL_LABEL_COPY));
await screen.findByLabelText('Use small font size'); await screen.findByLabelText(FONT_SIZE_SMALL_TOOLTIP_COPY);
config.featureToggles.newLogsPanel = originalValue; config.featureToggles.newLogsPanel = originalValue;
}); });
@@ -353,7 +424,7 @@ describe('LogListControls', () => {
<LogListControls eventBus={new EventBusSrv()} /> <LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider> </LogListContextProvider>
); );
await userEvent.click(screen.getByLabelText('Download logs')); await userEvent.click(screen.getByLabelText(DOWNLOAD_LOGS_LABEL_COPY));
await userEvent.click(await screen.findByText(label)); await userEvent.click(await screen.findByText(label));
expect(downloadLogs).toHaveBeenCalledTimes(1); expect(downloadLogs).toHaveBeenCalledTimes(1);
expect(downloadLogs).toHaveBeenCalledWith(format, [], undefined); expect(downloadLogs).toHaveBeenCalledWith(format, [], undefined);
@@ -371,7 +442,7 @@ describe('LogListControls', () => {
<LogListControls eventBus={new EventBusSrv()} /> <LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider> </LogListContextProvider>
); );
await userEvent.click(screen.getByLabelText('Download logs')); await userEvent.click(screen.getByLabelText(DOWNLOAD_LOGS_LABEL_COPY));
await userEvent.click(await screen.findByText('txt')); await userEvent.click(await screen.findByText('txt'));
expect(downloadLogs).toHaveBeenCalledWith('text', filteredLogs, undefined); expect(downloadLogs).toHaveBeenCalledWith('text', filteredLogs, undefined);
}); });
@@ -383,12 +454,12 @@ describe('LogListControls', () => {
<LogListControls eventBus={new EventBusSrv()} /> <LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider> </LogListContextProvider>
); );
await userEvent.click(screen.getByLabelText('Fix incorrectly escaped newline and tab sequences in log lines')); await userEvent.click(screen.getByLabelText(ESCAPE_NEWLINES_TOOLTIP_COPY));
rerender( rerender(
<LogListContextProvider {...contextProps} logs={[log]}> <LogListContextProvider {...contextProps} logs={[log]}>
<LogListControls eventBus={new EventBusSrv()} /> <LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider> </LogListContextProvider>
); );
await userEvent.click(screen.getByLabelText('Remove escaping')); await userEvent.click(screen.getByLabelText(REMOVE_ESCAPE_NEWLINES_LABEL_COPY));
}); });
}); });
@@ -1,17 +1,26 @@
import { css } from '@emotion/css'; import { css, cx } from '@emotion/css';
import { capitalize } from 'lodash'; import { capitalize } from 'lodash';
import { MouseEvent, useCallback, useMemo } from 'react'; import { MouseEvent, useCallback, useMemo } from 'react';
import { CoreApp, EventBus, LogLevel, LogsDedupDescription, LogsDedupStrategy, LogsSortOrder } from '@grafana/data'; import {
CoreApp,
EventBus,
LogLevel,
LogsDedupDescription,
LogsDedupStrategy,
LogsSortOrder,
store,
} from '@grafana/data';
import { GrafanaTheme2 } from '@grafana/data/'; import { GrafanaTheme2 } from '@grafana/data/';
import { t } from '@grafana/i18n'; import { t } from '@grafana/i18n';
import { config, reportInteraction } from '@grafana/runtime'; import { config, reportInteraction } from '@grafana/runtime';
import { Dropdown, Icon, IconButton, Menu, Tooltip, useStyles2 } from '@grafana/ui'; import { Dropdown, Menu, useStyles2 } from '@grafana/ui';
import { LogsVisualisationType } from '../../../explore/Logs/Logs'; import { LogsVisualisationType } from '../../../explore/Logs/Logs';
import { DownloadFormat } from '../../utils'; import { DownloadFormat } from '../../utils';
import { useLogListContext } from './LogListContext'; import { useLogListContext } from './LogListContext';
import { LogListControlsOption, LogListControlsSelectOption } from './LogListControlsOption';
import { useLogListSearchContext } from './LogListSearchContext'; import { useLogListSearchContext } from './LogListSearchContext';
import { ScrollToLogsEvent } from './virtualization'; import { ScrollToLogsEvent } from './virtualization';
@@ -38,9 +47,9 @@ const FILTER_LEVELS: LogLevel[] = [
]; ];
export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props) => { export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props) => {
const styles = useStyles2(getStyles);
const { const {
app, app,
controlsExpanded,
dedupStrategy, dedupStrategy,
downloadLogs, downloadLogs,
filterLevels, filterLevels,
@@ -48,6 +57,7 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
forceEscape, forceEscape,
hasUnescapedContent, hasUnescapedContent,
prettifyJSON, prettifyJSON,
setControlsExpanded,
setDedupStrategy, setDedupStrategy,
setFilterLevels, setFilterLevels,
setFontSize, setFontSize,
@@ -63,9 +73,12 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
sortOrder, sortOrder,
syntaxHighlighting, syntaxHighlighting,
wrapLogMessage, wrapLogMessage,
logOptionsStorageKey,
} = useLogListContext(); } = useLogListContext();
const { hideSearch, searchVisible, showSearch } = useLogListSearchContext(); const { hideSearch, searchVisible, showSearch } = useLogListSearchContext();
const styles = useStyles2(getStyles, controlsExpanded);
const onScrollToTopClick = useCallback(() => { const onScrollToTopClick = useCallback(() => {
reportInteraction('logs_log_list_controls_scroll_top_clicked'); reportInteraction('logs_log_list_controls_scroll_top_clicked');
eventBus.publish( eventBus.publish(
@@ -84,6 +97,12 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
); );
}, [eventBus]); }, [eventBus]);
const onExpandControlsClick = useCallback(() => {
reportInteraction('logs_log_list_controls_expand_controls_clicked');
setControlsExpanded(!controlsExpanded);
store.set(`${logOptionsStorageKey}.controlsExpanded`, !controlsExpanded);
}, [controlsExpanded, logOptionsStorageKey, setControlsExpanded]);
const onForceEscapeClick = useCallback(() => { const onForceEscapeClick = useCallback(() => {
reportInteraction('logs_log_list_controls_force_escape_clicked'); reportInteraction('logs_log_list_controls_force_escape_clicked');
setForceEscape(!forceEscape); setForceEscape(!forceEscape);
@@ -242,22 +261,47 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
return ( return (
<div className={styles.navContainer}> <div className={styles.navContainer}>
{visualisationType === 'logs' && ( <>
<IconButton <LogListControlsOption
name="arrow-down" expanded={controlsExpanded}
className={styles.controlButton} name="arrow-from-right"
className={cx(styles.controlButton, styles.controlsExpandedButton)}
variant="secondary" variant="secondary"
onClick={onScrollToBottomClick} onClick={onExpandControlsClick}
tooltip={t('logs.logs-controls.scroll-bottom', 'Scroll to bottom')} label={
controlsExpanded
? t('logs.logs-controls.label.collapse', 'Expanded')
: t('logs.logs-controls.label.expand', 'Collapsed')
}
tooltip={
controlsExpanded ? t('logs.logs-controls.collapse', 'Collapse') : t('logs.logs-controls.expand', 'Expand')
}
size="lg" size="lg"
/> />
)} {visualisationType === 'logs' && (
<LogListControlsOption
expanded={controlsExpanded}
name="arrow-down"
className={styles.controlButton}
variant="secondary"
onClick={onScrollToBottomClick}
tooltip={t('logs.logs-controls.scroll-bottom', 'Scroll to bottom')}
size="lg"
/>
)}
</>
{!inDashboard ? ( {!inDashboard ? (
<> <>
<IconButton <LogListControlsOption
expanded={controlsExpanded}
name={sortOrder === LogsSortOrder.Descending ? 'sort-amount-up' : 'sort-amount-down'} name={sortOrder === LogsSortOrder.Descending ? 'sort-amount-up' : 'sort-amount-down'}
className={styles.controlButton} className={styles.controlButton}
onClick={onSortOrderClick} onClick={onSortOrderClick}
label={
sortOrder === LogsSortOrder.Descending
? t('logs.logs-controls.labels.newest-first', 'Newest logs first')
: t('logs.logs-controls.labels.oldest-first', 'Oldest logs first')
}
tooltip={ tooltip={
sortOrder === LogsSortOrder.Descending sortOrder === LogsSortOrder.Descending
? t('logs.logs-controls.newest-first', 'Sorted by newest logs first - Click to show oldest first') ? t('logs.logs-controls.newest-first', 'Sorted by newest logs first - Click to show oldest first')
@@ -269,10 +313,16 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
<> <>
<div className={styles.divider} /> <div className={styles.divider} />
{config.featureToggles.newLogsPanel && ( {config.featureToggles.newLogsPanel && (
<IconButton <LogListControlsOption
expanded={controlsExpanded}
name={'search'} name={'search'}
className={searchVisible ? styles.controlButtonActive : styles.controlButton} className={searchVisible ? styles.controlButtonActive : styles.controlButton}
onClick={searchVisible ? hideSearch : showSearch} onClick={searchVisible ? hideSearch : showSearch}
label={
searchVisible
? t('logs.logs-controls.labels.hide-search', 'Close search')
: t('logs.logs-controls.labels.show-search', 'Search logs')
}
tooltip={ tooltip={
searchVisible searchVisible
? t('logs.logs-controls.hide-search', 'Close search') ? t('logs.logs-controls.hide-search', 'Close search')
@@ -282,7 +332,8 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
/> />
)} )}
<Dropdown overlay={deduplicationMenu} placement="auto-end"> <Dropdown overlay={deduplicationMenu} placement="auto-end">
<IconButton <LogListControlsOption
expanded={controlsExpanded}
name={'filter'} name={'filter'}
className={ className={
dedupStrategy !== LogsDedupStrategy.none ? styles.controlButtonActive : styles.controlButton dedupStrategy !== LogsDedupStrategy.none ? styles.controlButtonActive : styles.controlButton
@@ -292,20 +343,23 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
/> />
</Dropdown> </Dropdown>
<Dropdown overlay={filterLevelsMenu} placement="auto-end"> <Dropdown overlay={filterLevelsMenu} placement="auto-end">
<IconButton <LogListControlsOption
expanded={controlsExpanded}
name={'gf-logs'} name={'gf-logs'}
className={ className={
filterLevels && filterLevels.length > 0 ? styles.controlButtonActive : styles.controlButton filterLevels && filterLevels.length > 0 ? styles.controlButtonActive : styles.controlButton
} }
tooltip={t('logs.logs-controls.display-level', 'Display levels')} label={t('logs.logs-controls.filter-levels', 'Filter levels')}
tooltip={t('logs.logs-controls.tooltip.filter-level', 'Filter logs result by level')}
size="lg" size="lg"
/> />
</Dropdown> </Dropdown>
<div className={styles.divider} /> <div className={styles.divider} />
{config.featureToggles.newLogsPanel ? ( {config.featureToggles.newLogsPanel ? (
<TimestampResolutionButton /> <TimestampResolutionButton expanded={controlsExpanded} />
) : ( ) : (
<IconButton <LogListControlsOption
expanded={controlsExpanded}
name="clock-nine" name="clock-nine"
aria-pressed={showTime} aria-pressed={showTime}
className={showTime ? styles.controlButtonActive : styles.controlButton} className={showTime ? styles.controlButtonActive : styles.controlButton}
@@ -320,7 +374,8 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
)} )}
{/* When this is used in a Plugin context, app is unknown */} {/* When this is used in a Plugin context, app is unknown */}
{showUniqueLabels !== undefined && app !== CoreApp.Unknown && ( {showUniqueLabels !== undefined && app !== CoreApp.Unknown && (
<IconButton <LogListControlsOption
expanded={controlsExpanded}
name="tag-alt" name="tag-alt"
aria-pressed={showUniqueLabels} aria-pressed={showUniqueLabels}
className={showUniqueLabels ? styles.controlButtonActive : styles.controlButton} className={showUniqueLabels ? styles.controlButtonActive : styles.controlButton}
@@ -334,9 +389,10 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
/> />
)} )}
{config.featureToggles.newLogsPanel ? ( {config.featureToggles.newLogsPanel ? (
<WrapLogMessageButton /> <WrapLogMessageButton expanded={controlsExpanded} />
) : ( ) : (
<IconButton <LogListControlsOption
expanded={controlsExpanded}
name="wrap-text" name="wrap-text"
className={wrapLogMessage ? styles.controlButtonActive : styles.controlButton} className={wrapLogMessage ? styles.controlButtonActive : styles.controlButton}
aria-pressed={wrapLogMessage} aria-pressed={wrapLogMessage}
@@ -350,7 +406,8 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
/> />
)} )}
{prettifyJSON !== undefined && !config.featureToggles.newLogsPanel && ( {prettifyJSON !== undefined && !config.featureToggles.newLogsPanel && (
<IconButton <LogListControlsOption
expanded={controlsExpanded}
name="brackets-curly" name="brackets-curly"
aria-pressed={prettifyJSON} aria-pressed={prettifyJSON}
className={prettifyJSON ? styles.controlButtonActive : styles.controlButton} className={prettifyJSON ? styles.controlButtonActive : styles.controlButton}
@@ -364,39 +421,57 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
/> />
)} )}
{syntaxHighlighting !== undefined && ( {syntaxHighlighting !== undefined && (
<IconButton <LogListControlsOption
expanded={controlsExpanded}
name="brackets-curly" name="brackets-curly"
className={syntaxHighlighting ? styles.controlButtonActive : styles.controlButton} className={syntaxHighlighting ? styles.controlButtonActive : styles.controlButton}
aria-pressed={syntaxHighlighting} aria-pressed={syntaxHighlighting}
onClick={onSyntaxHightlightingClick} onClick={onSyntaxHightlightingClick}
label={
syntaxHighlighting
? t('logs.logs-controls.label.disable-highlighting', 'Highlight text')
: t('logs.logs-controls.label.enable-highlighting', 'Plain text')
}
tooltip={ tooltip={
syntaxHighlighting syntaxHighlighting
? t('logs.logs-controls.disable-highlighting', 'Disable highlighting') ? t('logs.logs-controls.tooltip.disable-highlighting', 'Disable highlighting')
: t('logs.logs-controls.enable-highlighting', 'Enable highlighting') : t('logs.logs-controls.tooltip.enable-highlighting', 'Enable highlighting')
} }
size="lg" size="lg"
/> />
)} )}
{config.featureToggles.newLogsPanel && ( {config.featureToggles.newLogsPanel && (
<IconButton <LogListControlsOption
expanded={controlsExpanded}
name="text-fields" name="text-fields"
className={fontSize === 'small' ? styles.controlButtonActive : styles.controlButton} className={fontSize === 'small' ? styles.controlButtonActive : styles.controlButton}
aria-pressed={Boolean(fontSize)} aria-pressed={Boolean(fontSize)}
onClick={onFontSizeClick} onClick={onFontSizeClick}
label={
fontSize === 'default'
? t('logs.logs-controls.labels.font-large', 'Large font')
: t('logs.logs-controls.labels.font-small', 'Small font')
}
tooltip={ tooltip={
fontSize === 'default' fontSize === 'default'
? t('logs.logs-controls.font-size-default', 'Use small font size') ? t('logs.logs-controls.font-small', 'Set small font')
: t('logs.logs-controls.font-size-small', 'Use default font size') : t('logs.logs-controls.font-large', 'Set large font')
} }
size="lg" size="lg"
/> />
)} )}
{hasUnescapedContent && ( {hasUnescapedContent && (
<IconButton <LogListControlsOption
expanded={controlsExpanded}
name="enter" name="enter"
aria-pressed={forceEscape} aria-pressed={forceEscape}
className={forceEscape ? styles.controlButtonActive : styles.controlButton} className={forceEscape ? styles.controlButtonActive : styles.controlButton}
onClick={onForceEscapeClick} onClick={onForceEscapeClick}
label={
forceEscape
? t('logs.logs-controls.remove-escaping', 'Remove escaping')
: t('logs.logs-controls.label.escape-newlines', 'Escape newlines')
}
tooltip={ tooltip={
forceEscape forceEscape
? t('logs.logs-controls.remove-escaping', 'Remove escaping') ? t('logs.logs-controls.remove-escaping', 'Remove escaping')
@@ -414,10 +489,12 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
<> <>
<div className={styles.divider} /> <div className={styles.divider} />
<Dropdown overlay={downloadMenu} placement="auto-end"> <Dropdown overlay={downloadMenu} placement="auto-end">
<IconButton <LogListControlsOption
expanded={controlsExpanded}
name="download-alt" name="download-alt"
className={styles.controlButton} className={styles.controlButton}
tooltip={t('logs.logs-controls.download', 'Download logs')} label={t('logs.logs-controls.download', 'Download logs')}
tooltip={t('logs.logs-controls.tooltip.download', 'Download')}
size="lg" size="lg"
/> />
</Dropdown> </Dropdown>
@@ -427,10 +504,16 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
) : ( ) : (
<> <>
{config.featureToggles.newLogsPanel && ( {config.featureToggles.newLogsPanel && (
<IconButton <LogListControlsOption
expanded={controlsExpanded}
name={'search'} name={'search'}
className={searchVisible ? styles.controlButtonActive : styles.controlButton} className={searchVisible ? styles.controlButtonActive : styles.controlButton}
onClick={searchVisible ? hideSearch : showSearch} onClick={searchVisible ? hideSearch : showSearch}
label={
searchVisible
? t('logs.logs-controls.labels.hide-search', 'Close search')
: t('logs.logs-controls.labels.show-search', 'Search logs')
}
tooltip={ tooltip={
searchVisible searchVisible
? t('logs.logs-controls.hide-search', 'Close search') ? t('logs.logs-controls.hide-search', 'Close search')
@@ -440,19 +523,27 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
/> />
)} )}
<Dropdown overlay={filterLevelsMenu} placement="auto-end"> <Dropdown overlay={filterLevelsMenu} placement="auto-end">
<IconButton <LogListControlsOption
expanded={controlsExpanded}
name={'gf-logs'} name={'gf-logs'}
className={filterLevels && filterLevels.length > 0 ? styles.controlButtonActive : styles.controlButton} className={filterLevels && filterLevels.length > 0 ? styles.controlButtonActive : styles.controlButton}
tooltip={t('logs.logs-controls.display-level', 'Display levels')} label={t('logs.logs-controls.filter-levels', 'Filter levels')}
tooltip={t('logs.logs-controls.tooltip.filter-level', 'Filter logs result by level')}
size="lg" size="lg"
/> />
</Dropdown> </Dropdown>
{visualisationType === 'logs' && hasUnescapedContent && ( {visualisationType === 'logs' && hasUnescapedContent && (
<IconButton <LogListControlsOption
expanded={controlsExpanded}
name="enter" name="enter"
aria-pressed={forceEscape} aria-pressed={forceEscape}
className={forceEscape ? styles.controlButtonActive : styles.controlButton} className={forceEscape ? styles.controlButtonActive : styles.controlButton}
onClick={onForceEscapeClick} onClick={onForceEscapeClick}
label={
forceEscape
? t('logs.logs-controls.remove-escaping', 'Remove escaping')
: t('logs.logs-controls.label.escape-newlines', 'Escape newlines')
}
tooltip={ tooltip={
forceEscape forceEscape
? t('logs.logs-controls.remove-escaping', 'Remove escaping') ? t('logs.logs-controls.remove-escaping', 'Remove escaping')
@@ -467,7 +558,9 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
</> </>
)} )}
{visualisationType === 'logs' && ( {visualisationType === 'logs' && (
<IconButton <LogListControlsOption
stickToBottom={true}
expanded={controlsExpanded}
name="arrow-up" name="arrow-up"
data-testid="scrollToTop" data-testid="scrollToTop"
className={styles.scrollToTopButton} className={styles.scrollToTopButton}
@@ -481,8 +574,12 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
); );
}; };
const TimestampResolutionButton = () => { interface LogSelectOptionProps {
const styles = useStyles2(getStyles); expanded: boolean;
}
const TimestampResolutionButton = ({ expanded }: LogSelectOptionProps) => {
const styles = useStyles2(getWrapButtonStyles, expanded);
const { setTimestampResolution, setShowTime, showTime, timestampResolution } = useLogListContext(); const { setTimestampResolution, setShowTime, showTime, timestampResolution } = useLogListContext();
const hide = useCallback(() => { const hide = useCallback(() => {
@@ -533,33 +630,32 @@ const TimestampResolutionButton = () => {
[hide, showMs, showNs, showTime, styles.menuItemActive, timestampResolution] [hide, showMs, showNs, showTime, styles.menuItemActive, timestampResolution]
); );
const labelText = !showTime
? t('logs.logs-controls.timestamp.label-hide', 'Hide timestamps')
: timestampResolution === 'ms'
? t('logs.logs-controls.timestamp.label-ms', 'Display ms')
: t('logs.logs-controls.timestamp.label-ns', 'Display ns');
const customTagText =
timestampResolution === 'ms'
? t('logs.logs-controls.resolution-ms', 'ms')
: t('logs.logs-controls.resolution-ns', 'ns');
return ( return (
<Dropdown overlay={timestampMenu} placement="auto-end"> <LogListControlsSelectOption
<div> expanded={expanded}
<Tooltip content={t('logs.logs-controls.timestamp.label', 'Log timestamps')}> name={'clock-nine'}
<button isActive={showTime}
aria-pressed={showTime} dropdown={timestampMenu}
aria-label={t('logs.logs-controls.timestamp.label', 'Log timestamps')} tooltip={t('logs.logs-controls.timestamp.tooltip', 'Set timestamp format')}
className={`${styles.customControlButton} ${showTime ? styles.controlButtonActive : styles.controlButton}`} label={labelText}
type="button" buttonAriaLabel={t('logs.logs-controls.timestamp.label', 'Log timestamps')}
> customTagText={customTagText}
<Icon name="clock-nine" size="lg" className={styles.customControlIcon} /> />
{showTime && (
<span className={styles.customControlTag}>
{timestampResolution === 'ms'
? t('logs.logs-controls.resolution-ms', 'ms')
: t('logs.logs-controls.resolution-ns', 'ns')}
</span>
)}
</button>
</Tooltip>
</div>
</Dropdown>
); );
}; };
const WrapLogMessageButton = ({ expanded }: LogSelectOptionProps) => {
const WrapLogMessageButton = () => { const styles = useStyles2(getWrapButtonStyles, expanded);
const styles = useStyles2(getStyles);
const { prettifyJSON, setPrettifyJSON, setWrapLogMessage, wrapLogMessage } = useLogListContext(); const { prettifyJSON, setPrettifyJSON, setWrapLogMessage, wrapLogMessage } = useLogListContext();
/** /**
@@ -622,39 +718,62 @@ const WrapLogMessageButton = () => {
[disable, prettifyJSON, styles.menuItemActive, wrap, wrapAndPrettify, wrapLogMessage] [disable, prettifyJSON, styles.menuItemActive, wrap, wrapAndPrettify, wrapLogMessage]
); );
const wrapStateText = !wrapLogMessage
? t('logs.logs-controls.line-wrapping.state.hide', 'Wrap disabled')
: wrapLogMessage && !prettifyJSON
? t('logs.logs-controls.line-wrapping.state.wrap', 'Wrap lines')
: t('logs.logs-controls.line-wrapping.state.json', 'Wrap JSON');
const tooltip = t('logs.logs-controls.line-wrapping.tooltip', 'Set line wrap');
return ( return (
<Dropdown overlay={wrappingMenu} placement="auto-end"> <LogListControlsSelectOption
<div> expanded={expanded}
<Tooltip content={t('logs.logs-controls.line-wrapping.label', 'Log line wrapping')}> name={'wrap-text'}
<button isActive={wrapLogMessage}
aria-label={t('logs.logs-controls.line-wrapping.label', 'Log line wrapping')} dropdown={wrappingMenu}
aria-pressed={wrapLogMessage} tooltip={tooltip}
className={`${styles.customControlButton} ${wrapLogMessage ? styles.controlButtonActive : styles.controlButton}`} label={wrapStateText}
type="button" buttonAriaLabel={tooltip}
> customTagText={'+'}
<Icon name="wrap-text" size="lg" className={styles.customControlIcon} /> />
{prettifyJSON && <span className={styles.customControlTag}>+</span>}
</button>
</Tooltip>
</div>
</Dropdown>
); );
}; };
const getStyles = (theme: GrafanaTheme2) => { const getWrapButtonStyles = (theme: GrafanaTheme2, expanded: boolean) => {
return {
menuItemActive: css({
'&:before': {
content: '""',
position: 'absolute',
left: 0,
top: theme.spacing(0.5),
height: `calc(100% - ${theme.spacing(1)})`,
width: '2px',
backgroundColor: theme.colors.warning.main,
},
}),
};
};
export const CONTROLS_WIDTH = 35;
export const CONTROLS_WIDTH_EXPANDED = 176;
const getStyles = (theme: GrafanaTheme2, controlsExpanded: boolean) => {
return { return {
navContainer: css({ navContainer: css({
maxHeight: '100%', maxHeight: '100%',
display: 'flex', display: 'flex',
flex: '1 0 auto',
gap: theme.spacing(3), gap: theme.spacing(3),
flexDirection: 'column', flexDirection: 'column',
justifyContent: 'flex-start', justifyContent: 'flex-start',
width: theme.spacing(4), width: controlsExpanded ? CONTROLS_WIDTH_EXPANDED : CONTROLS_WIDTH,
paddingTop: theme.spacing(0.75), paddingTop: theme.spacing(0.75),
paddingLeft: theme.spacing(1), paddingLeft: theme.spacing(1),
borderLeft: `solid 1px ${theme.colors.border.medium}`, borderLeft: `solid 1px ${theme.colors.border.medium}`,
overflow: 'hidden',
minWidth: theme.spacing(4), minWidth: theme.spacing(4),
backgroundColor: theme.colors.background.primary,
}), }),
scrollToTopButton: css({ scrollToTopButton: css({
margin: 0, margin: 0,
@@ -662,6 +781,9 @@ const getStyles = (theme: GrafanaTheme2) => {
color: theme.colors.text.secondary, color: theme.colors.text.secondary,
height: theme.spacing(2), height: theme.spacing(2),
}), }),
controlsExpandedButton: css({
transform: !controlsExpanded ? 'rotate(180deg)' : '',
}),
controlButton: css({ controlButton: css({
margin: 0, margin: 0,
color: theme.colors.text.secondary, color: theme.colors.text.secondary,
@@ -685,7 +807,7 @@ const getStyles = (theme: GrafanaTheme2) => {
borderRadius: theme.shape.radius.default, borderRadius: theme.shape.radius.default,
bottom: theme.spacing(-1), bottom: theme.spacing(-1),
backgroundImage: theme.colors.gradients.brandHorizontal, backgroundImage: theme.colors.gradients.brandHorizontal,
width: '95%', width: theme.spacing(2.25),
opacity: 1, opacity: 1,
}, },
}), }),
@@ -700,32 +822,5 @@ const getStyles = (theme: GrafanaTheme2) => {
backgroundColor: theme.colors.warning.main, backgroundColor: theme.colors.warning.main,
}, },
}), }),
customControlButton: css({
position: 'relative',
zIndex: 0,
margin: 0,
boxShadow: 'none',
border: 'none',
display: 'flex',
background: 'transparent',
justifyContent: 'center',
alignItems: 'center',
padding: 0,
overflow: 'visible',
width: '100%',
}),
customControlIcon: css({
verticalAlign: 'baseline',
}),
customControlTag: css({
color: theme.colors.primary.text,
fontSize: 10,
position: 'absolute',
bottom: -4,
right: 1,
lineHeight: '10px',
backgroundColor: theme.colors.background.primary,
paddingLeft: 2,
}),
}; };
}; };
@@ -0,0 +1,211 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Dropdown, Icon, IconButton, Tooltip, useStyles2 } from '@grafana/ui';
interface LogControlOptionProps {
label?: string;
expanded: boolean;
tooltip: string;
stickToBottom?: boolean;
}
export type Props = React.ComponentProps<typeof IconButton> & LogControlOptionProps;
export const LogListControlsOption = React.forwardRef<HTMLButtonElement, Props>(
(
{
stickToBottom,
expanded,
label,
tooltip,
className: iconButtonClassName,
name: iconButtonName,
...iconButtonProps
}: Props,
ref
) => {
const styles = useStyles2(getStyles, expanded);
return (
<div className={`${styles.container} ${stickToBottom ? styles.marginTopAuto : ''}`}>
<label className={styles.label}>
<span className={styles.labelText}>{label ?? tooltip}</span>
<span className={styles.iconContainer}>
<IconButton
name={iconButtonName}
tooltip={tooltip}
className={iconButtonClassName}
ref={ref}
{...iconButtonProps}
/>
</span>
</label>
</div>
);
}
);
interface LogControlSelectOptionProps {
label?: string;
expanded: boolean;
tooltip: string;
stickToBottom?: boolean;
dropdown: JSX.Element;
isActive: boolean;
customTagText: string;
buttonAriaLabel: string;
}
export type SelectProps = React.ComponentProps<typeof Icon> & LogControlSelectOptionProps;
export const LogListControlsSelectOption = React.forwardRef<SVGElement, SelectProps>(
(
{
stickToBottom,
expanded,
label,
tooltip,
className: iconButtonClassName,
name: iconButtonName,
dropdown,
isActive: isActive,
customTagText,
buttonAriaLabel,
...iconButtonProps
}: SelectProps,
ref
) => {
const styles = useStyles2(getStyles, expanded);
return (
<div className={styles.container}>
<label className={styles.label}>
<span className={styles.labelText}>{label ?? tooltip}</span>
<span>
<Dropdown overlay={dropdown} placement="auto-end">
<div className={styles.iconContainer}>
<Tooltip content={tooltip}>
<button
aria-pressed={isActive}
aria-label={buttonAriaLabel}
className={`${styles.customControlButton} ${isActive ? styles.controlButtonActive : styles.controlButton}`}
type="button"
>
<Icon
{...iconButtonProps}
ref={ref}
name={iconButtonName}
size="lg"
className={styles.customControlIcon}
/>
{isActive && <span className={styles.customControlTag}>{customTagText}</span>}
</button>
</Tooltip>
</div>
</Dropdown>
</span>
</label>
</div>
);
}
);
LogListControlsSelectOption.displayName = 'LogListControlsSelectOption';
const getStyles = (theme: GrafanaTheme2, expanded: boolean) => {
const hoverSize = '26';
return {
customControlTag: css({
color: theme.colors.primary.text,
fontSize: 10,
position: 'absolute',
bottom: -4,
right: 1,
lineHeight: '10px',
backgroundColor: theme.colors.background.primary,
paddingLeft: 2,
}),
customControlIcon: css({
verticalAlign: 'baseline',
}),
customControlButton: css({
position: 'relative',
zIndex: 0,
margin: 0,
boxShadow: 'none',
border: 'none',
display: 'flex',
background: 'transparent',
justifyContent: 'center',
alignItems: 'center',
padding: 0,
overflow: 'visible',
width: '100%',
}),
controlButtonActive: css({
margin: 0,
color: theme.colors.text.secondary,
height: theme.spacing(2),
'&:hover': {
'&:before': {
backgroundColor: theme.colors.action.hover,
opacity: 1,
},
},
'&:before': {
zIndex: -1,
position: 'absolute',
opacity: 0,
width: `${hoverSize}px`,
height: `${hoverSize}px`,
borderRadius: theme.shape.radius.default,
content: '""',
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
transitionDuration: '0.2s',
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
transitionProperty: 'opacity',
},
},
'&:after': {
display: 'block',
content: '" "',
position: 'absolute',
height: 2,
borderRadius: theme.shape.radius.default,
bottom: theme.spacing(-1),
backgroundImage: theme.colors.gradients.brandHorizontal,
width: theme.spacing(2.25),
opacity: 1,
},
}),
controlButton: css({
margin: 0,
color: theme.colors.text.secondary,
height: theme.spacing(2),
}),
marginTopAuto: css({
marginTop: 'auto',
marginBottom: theme.spacing(1),
}),
labelText: css({
display: expanded ? 'block' : 'none',
}),
iconContainer: css({
display: 'flex',
alignItems: 'center',
height: '16px',
}),
container: css({
fontSize: theme.typography.pxToRem(12),
height: theme.spacing(2),
width: 'auto',
}),
label: css({
display: 'flex',
justifyContent: expanded ? 'space-between' : 'center',
marginRight: expanded ? '2.5px' : 0,
}),
};
};
LogListControlsOption.displayName = 'LogListControlsOption';
@@ -3,7 +3,6 @@ import { createContext, useContext } from 'react';
import { CoreApp, LogsDedupStrategy, LogsSortOrder } from '@grafana/data'; import { CoreApp, LogsDedupStrategy, LogsSortOrder } from '@grafana/data';
import { checkLogsError, checkLogsSampled } from 'app/features/logs/utils'; import { checkLogsError, checkLogsSampled } from 'app/features/logs/utils';
import { LogLineDetailsMode } from '../LogLineDetails';
import { LogListContextData, Props } from '../LogListContext'; import { LogListContextData, Props } from '../LogListContext';
import { LogListModel } from '../processing'; import { LogListModel } from '../processing';
@@ -49,11 +48,11 @@ export const LogListContext = createContext<LogListContextData>({
toggleDetails: () => {}, toggleDetails: () => {},
wrapLogMessage: false, wrapLogMessage: false,
detailsMode: 'sidebar', detailsMode: 'sidebar',
setDetailsMode: function (mode: LogLineDetailsMode): void { setDetailsMode: () => {},
throw new Error('Function not implemented.');
},
isAssistantAvailable: false, isAssistantAvailable: false,
openAssistantByLog: () => {}, openAssistantByLog: () => {},
controlsExpanded: false,
setControlsExpanded: () => {},
}); });
export const useLogListContextData = (key: keyof LogListContextData) => { export const useLogListContextData = (key: keyof LogListContextData) => {
@@ -110,8 +109,10 @@ export const defaultValue: LogListContextData = {
sortOrder: LogsSortOrder.Ascending, sortOrder: LogsSortOrder.Ascending,
wrapLogMessage: false, wrapLogMessage: false,
isAssistantAvailable: false, isAssistantAvailable: false,
openAssistantByLog: () => {}, openAssistantByLog: jest.fn(),
timestampResolution: 'ns', timestampResolution: 'ns',
controlsExpanded: false,
setControlsExpanded: jest.fn(),
}; };
export const defaultProps: Props = { export const defaultProps: Props = {
+37 -7
View File
@@ -9633,10 +9633,9 @@
} }
}, },
"logs-controls": { "logs-controls": {
"collapse": "Collapse",
"deduplication": "Deduplication", "deduplication": "Deduplication",
"disable-highlighting": "Disable highlighting",
"disable-prettify-json": "Collapse JSON logs", "disable-prettify-json": "Collapse JSON logs",
"display-level": "Display levels",
"display-level-all": "All levels", "display-level-all": "All levels",
"download": "Download logs", "download": "Download logs",
"download-logs": { "download-logs": {
@@ -9644,18 +9643,39 @@
"json": "json", "json": "json",
"txt": "txt" "txt": "txt"
}, },
"enable-highlighting": "Enable highlighting",
"escape-newlines": "Fix incorrectly escaped newline and tab sequences in log lines", "escape-newlines": "Fix incorrectly escaped newline and tab sequences in log lines",
"font-size-default": "Use small font size", "expand": "Expand",
"font-size-small": "Use default font size", "filter-levels": "Filter levels",
"font-large": "Set large font",
"font-small": "Set small font",
"hide-search": "Close search", "hide-search": "Close search",
"hide-timestamps": "Hide timestamps", "hide-timestamps": "Hide timestamps",
"hide-unique-labels": "Hide unique labels", "hide-unique-labels": "Hide unique labels",
"label": {
"collapse": "Expanded",
"disable-highlighting": "Highlight text",
"enable-highlighting": "Plain text",
"escape-newlines": "Escape newlines",
"expand": "Collapsed"
},
"labels": {
"font-large": "Large font",
"font-small": "Small font",
"hide-search": "Close search",
"newest-first": "Newest logs first",
"oldest-first": "Oldest logs first",
"show-search": "Search logs"
},
"line-wrapping": { "line-wrapping": {
"enable": "Enable line wrapping", "enable": "Enable line wrapping",
"enable-prettify": "Enable line wrapping and prettify JSON", "enable-prettify": "Enable line wrapping and prettify JSON",
"hide": "Disable line wrapping", "hide": "Disable line wrapping",
"label": "Log line wrapping" "state": {
"hide": "Wrap disabled",
"json": "Wrap JSON",
"wrap": "Wrap lines"
},
"tooltip": "Set line wrap"
}, },
"newest-first": "Sorted by newest logs first - Click to show oldest first", "newest-first": "Sorted by newest logs first - Click to show oldest first",
"oldest-first": "Sorted by oldest logs first - Click to show newest first", "oldest-first": "Sorted by oldest logs first - Click to show newest first",
@@ -9671,8 +9691,18 @@
"timestamp": { "timestamp": {
"hide": "Hide timestamps", "hide": "Hide timestamps",
"label": "Log timestamps", "label": "Log timestamps",
"label-hide": "Hide timestamps",
"label-ms": "Display ms",
"label-ns": "Display ns",
"milliseconds": "Show millisecond timestamps", "milliseconds": "Show millisecond timestamps",
"nanoseconds": "Show nanosecond timestamps" "nanoseconds": "Show nanosecond timestamps",
"tooltip": "Set timestamp format"
},
"tooltip": {
"disable-highlighting": "Disable highlighting",
"download": "Download",
"enable-highlighting": "Enable highlighting",
"filter-level": "Filter logs result by level"
}, },
"unwrap-lines": "Unwrap lines", "unwrap-lines": "Unwrap lines",
"wrap-lines": "Wrap lines" "wrap-lines": "Wrap lines"