Log Controls: Add dropdown menu for timestamp and line wrapping (#110698)

* LogListControls: add dropdown menu for timestamps

* LogListControls: add dropdown menu for line wrapping

* Update styles

* Translations

* Update tests

* Update test

* LogListControls: all events
This commit is contained in:
Matias Chomicki
2025-09-05 17:43:34 +00:00
committed by GitHub
parent dc1c5a610c
commit 46258ac2c1
4 changed files with 215 additions and 95 deletions
@@ -359,17 +359,16 @@ describe('LogList', () => {
render(<LogList {...defaultProps} showTime showControls logs={logs} />);
expect(screen.getByText('2025-08-06 03:35:19.504')).toBeInTheDocument();
expect(screen.getByLabelText('Show nanosecond timestamps')).toBeInTheDocument();
await userEvent.click(screen.getByLabelText('Show nanosecond timestamps'));
await userEvent.click(screen.getByLabelText('Log timestamps'));
await userEvent.click(screen.getByText('Show nanosecond timestamps'));
expect(screen.getByText('2025-08-06 03:35:19.504133766')).toBeInTheDocument();
expect(screen.getByLabelText('Hide timestamps')).toBeInTheDocument();
await userEvent.click(screen.getByLabelText('Hide timestamps'));
await userEvent.click(screen.getByLabelText('Log timestamps'));
await userEvent.click(screen.getByText('Hide timestamps'));
expect(screen.queryByText(/2025-08-06 03:35:19/)).not.toBeInTheDocument();
expect(screen.getByLabelText('Show millisecond timestamps')).toBeInTheDocument();
});
});
describe('Interactions', () => {
@@ -227,22 +227,57 @@ describe('LogListControls', () => {
</LogListContextProvider>
);
await userEvent.click(screen.getByLabelText('Wrap lines'));
await userEvent.click(screen.getByLabelText('Log line wrapping'));
await userEvent.click(screen.getByText('Enable line wrapping'));
expect(onLogOptionsChange).toHaveBeenCalledTimes(2);
expect(onLogOptionsChange).toHaveBeenCalledWith('wrapLogMessage', true);
expect(onLogOptionsChange).toHaveBeenCalledWith('prettifyJSON', false);
await userEvent.click(screen.getByLabelText('Wrap lines and expand JSON'));
expect(onLogOptionsChange).toHaveBeenCalledTimes(3);
await userEvent.click(screen.getByLabelText('Log line wrapping'));
await userEvent.click(screen.getByText('Enable line wrapping and prettify JSON'));
expect(onLogOptionsChange).toHaveBeenCalledTimes(4);
expect(onLogOptionsChange).toHaveBeenCalledWith('prettifyJSON', true);
await userEvent.click(screen.getByLabelText('Unwrap lines'));
await userEvent.click(screen.getByLabelText('Log line wrapping'));
await userEvent.click(screen.getByText('Disable line wrapping'));
expect(onLogOptionsChange).toHaveBeenCalledWith('wrapLogMessage', false);
expect(onLogOptionsChange).toHaveBeenCalledWith('prettifyJSON', false);
expect(onLogOptionsChange).toHaveBeenCalledTimes(5);
expect(onLogOptionsChange).toHaveBeenCalledTimes(6);
config.featureToggles.newLogsPanel = originalFlagState;
});
test('Controls line wrapping and prettify JSON', async () => {
const originalFlagState = config.featureToggles.newLogsPanel;
config.featureToggles.newLogsPanel = true;
const onLogOptionsChange = jest.fn();
render(
<LogListContextProvider {...contextProps} showTime={false} onLogOptionsChange={onLogOptionsChange}>
<LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider>
);
await userEvent.click(screen.getByLabelText('Log timestamps'));
await userEvent.click(screen.getByText('Show millisecond timestamps'));
expect(onLogOptionsChange).toHaveBeenCalledTimes(1);
expect(onLogOptionsChange).toHaveBeenCalledWith('showTime', true);
await userEvent.click(screen.getByLabelText('Log timestamps'));
await userEvent.click(screen.getByText('Show nanosecond timestamps'));
expect(onLogOptionsChange).toHaveBeenCalledTimes(2);
await userEvent.click(screen.getByLabelText('Log timestamps'));
await userEvent.click(screen.getByText('Hide timestamps'));
expect(onLogOptionsChange).toHaveBeenCalledTimes(3);
expect(onLogOptionsChange).toHaveBeenCalledWith('showTime', false);
config.featureToggles.newLogsPanel = originalFlagState;
});
@@ -11,7 +11,6 @@ import { Dropdown, Icon, IconButton, Menu, Tooltip, useStyles2 } from '@grafana/
import { LogsVisualisationType } from '../../../explore/Logs/Logs';
import { DownloadFormat } from '../../utils';
import { LogLineTimestampResolution } from './LogLine';
import { useLogListContext } from './LogListContext';
import { useLogListSearchContext } from './LogListSearchContext';
import { ScrollToLogsEvent } from './virtualization';
@@ -92,7 +91,9 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
const onFilterLevelClick = useCallback(
(level?: LogLevel) => {
reportInteraction('logs_log_list_controls_level_clicked');
reportInteraction('logs_log_list_controls_level_clicked', {
level,
});
if (level === undefined) {
setFilterLevels([]);
} else if (!filterLevels.includes(level)) {
@@ -167,7 +168,12 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
className={dedupStrategy === option ? styles.menuItemActive : undefined}
description={LogsDedupDescription[option]}
label={capitalize(option)}
onClick={() => setDedupStrategy(option)}
onClick={() => {
setDedupStrategy(option);
reportInteraction('logs_log_list_controls_deduplication_clicked', {
option,
});
}}
/>
))}
</Menu>
@@ -202,15 +208,30 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
<Menu>
<Menu.Item
label={t('logs.logs-controls.download-logs.txt', 'txt')}
onClick={() => downloadLogs(DownloadFormat.Text)}
onClick={() => {
downloadLogs(DownloadFormat.Text);
reportInteraction('logs_log_list_controls_downloaded_logs', {
format: DownloadFormat.Text,
});
}}
/>
<Menu.Item
label={t('logs.logs-controls.download-logs.json', 'json')}
onClick={() => downloadLogs(DownloadFormat.Json)}
onClick={() => {
downloadLogs(DownloadFormat.Json);
reportInteraction('logs_log_list_controls_downloaded_logs', {
format: DownloadFormat.Json,
});
}}
/>
<Menu.Item
label={t('logs.logs-controls.download-logs.csv', 'csv')}
onClick={() => downloadLogs(DownloadFormat.CSV)}
onClick={() => {
downloadLogs(DownloadFormat.CSV);
reportInteraction('logs_log_list_controls_downloaded_logs', {
format: DownloadFormat.CSV,
});
}}
/>
</Menu>
),
@@ -464,40 +485,76 @@ const TimestampResolutionButton = () => {
const styles = useStyles2(getStyles);
const { setTimestampResolution, setShowTime, showTime, timestampResolution } = useLogListContext();
const onShowTimestampsClick = useCallback(() => {
if (!config.featureToggles.newLogsPanel) {
reportInteraction('logs_log_list_controls_show_time_clicked', {
show_time: !showTime,
});
setShowTime(!showTime);
return;
}
if (!showTime || timestampResolution === 'ns') {
setShowTime(!showTime);
} else if (timestampResolution === 'ms') {
setTimestampResolution('ns');
}
}, [setShowTime, setTimestampResolution, showTime, timestampResolution]);
const hide = useCallback(() => {
setShowTime(false);
reportInteraction('logs_log_list_controls_show_time_clicked', {
show_time: false,
});
}, [setShowTime]);
const showMs = useCallback(() => {
setShowTime(true);
setTimestampResolution('ms');
reportInteraction('logs_log_list_controls_show_time_clicked', {
show_time: false,
resolution: 'ms',
});
}, [setShowTime, setTimestampResolution]);
const showNs = useCallback(() => {
setShowTime(true);
setTimestampResolution('ns');
reportInteraction('logs_log_list_controls_show_time_clicked', {
show_time: false,
resolution: 'ns',
});
}, [setShowTime, setTimestampResolution]);
const timestampMenu = useMemo(
() => (
<Menu>
<Menu.Item
label={t('logs.logs-controls.timestamp.hide', 'Hide timestamps')}
className={!showTime ? styles.menuItemActive : undefined}
onClick={hide}
/>
<Menu.Item
label={t('logs.logs-controls.timestamp.milliseconds', 'Show millisecond timestamps')}
className={showTime && timestampResolution === 'ms' ? styles.menuItemActive : undefined}
onClick={showMs}
/>
<Menu.Item
label={t('logs.logs-controls.timestamp.nanoseconds', 'Show nanosecond timestamps')}
className={showTime && timestampResolution === 'ns' ? styles.menuItemActive : undefined}
onClick={showNs}
/>
</Menu>
),
[hide, showMs, showNs, showTime, styles.menuItemActive, timestampResolution]
);
return (
<Tooltip content={getTimestampTooltip(showTime, timestampResolution)}>
<button
aria-label={getTimestampTooltip(showTime, timestampResolution)}
aria-pressed={showTime}
className={`${styles.customControlButton} ${showTime ? styles.controlButtonActive : styles.controlButton}`}
type="button"
onClick={onShowTimestampsClick}
>
<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>
<Dropdown overlay={timestampMenu} placement="auto-end">
<div>
<Tooltip content={t('logs.logs-controls.timestamp.label', 'Log timestamps')}>
<button
aria-pressed={showTime}
aria-label={t('logs.logs-controls.timestamp.label', 'Log timestamps')}
className={`${styles.customControlButton} ${showTime ? styles.controlButtonActive : styles.controlButton}`}
type="button"
>
<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>
);
};
@@ -515,34 +572,72 @@ const WrapLogMessageButton = () => {
* Line wrapping also controls JSON formatting, because with line wrapping disabled,
* JSON formatting has no effect, so one is related with the other.
*/
const onWrapLogMessageClick = useCallback(() => {
if (!wrapLogMessage) {
setWrapLogMessage(true);
setPrettifyJSON(false);
} else if (!prettifyJSON) {
setPrettifyJSON(true);
} else {
setWrapLogMessage(false);
setPrettifyJSON(false);
}
const disable = useCallback(() => {
setWrapLogMessage(false);
setPrettifyJSON(false);
reportInteraction('logs_log_list_controls_wrap_clicked', {
state: !wrapLogMessage,
state: false,
prettify: false,
});
}, [prettifyJSON, setPrettifyJSON, setWrapLogMessage, wrapLogMessage]);
}, [setPrettifyJSON, setWrapLogMessage]);
const wrap = useCallback(() => {
setWrapLogMessage(true);
setPrettifyJSON(false);
reportInteraction('logs_log_list_controls_wrap_clicked', {
state: true,
prettify: false,
});
}, [setPrettifyJSON, setWrapLogMessage]);
const wrapAndPrettify = useCallback(() => {
setWrapLogMessage(true);
setPrettifyJSON(true);
reportInteraction('logs_log_list_controls_wrap_clicked', {
state: true,
prettify: true,
});
}, [setPrettifyJSON, setWrapLogMessage]);
const wrappingMenu = useMemo(
() => (
<Menu>
<Menu.Item
label={t('logs.logs-controls.line-wrapping.hide', 'Disable line wrapping')}
className={!wrapLogMessage ? styles.menuItemActive : undefined}
onClick={disable}
/>
<Menu.Item
label={t('logs.logs-controls.line-wrapping.enable', 'Enable line wrapping')}
className={wrapLogMessage && !prettifyJSON ? styles.menuItemActive : undefined}
onClick={wrap}
/>
<Menu.Item
label={t('logs.logs-controls.line-wrapping.enable-prettify', 'Enable line wrapping and prettify JSON')}
className={wrapLogMessage && prettifyJSON ? styles.menuItemActive : undefined}
onClick={wrapAndPrettify}
/>
</Menu>
),
[disable, prettifyJSON, styles.menuItemActive, wrap, wrapAndPrettify, wrapLogMessage]
);
return (
<Tooltip content={getWrapLogMessageTooltip(wrapLogMessage, prettifyJSON)}>
<button
aria-label={getWrapLogMessageTooltip(wrapLogMessage, prettifyJSON)}
aria-pressed={wrapLogMessage}
className={`${styles.customControlButton} ${wrapLogMessage ? styles.controlButtonActive : styles.controlButton}`}
type="button"
onClick={onWrapLogMessageClick}
>
<Icon name="wrap-text" size="lg" className={styles.customControlIcon} />
{prettifyJSON && <span className={styles.customControlTag}>+</span>}
</button>
</Tooltip>
<Dropdown overlay={wrappingMenu} placement="auto-end">
<div>
<Tooltip content={t('logs.logs-controls.line-wrapping.label', 'Log line wrapping')}>
<button
aria-label={t('logs.logs-controls.line-wrapping.label', 'Log line wrapping')}
aria-pressed={wrapLogMessage}
className={`${styles.customControlButton} ${wrapLogMessage ? styles.controlButtonActive : styles.controlButton}`}
type="button"
>
<Icon name="wrap-text" size="lg" className={styles.customControlIcon} />
{prettifyJSON && <span className={styles.customControlTag}>+</span>}
</button>
</Tooltip>
</div>
</Dropdown>
);
};
@@ -611,12 +706,13 @@ const getStyles = (theme: GrafanaTheme2) => {
margin: 0,
boxShadow: 'none',
border: 'none',
display: 'inline-flex',
display: 'flex',
background: 'transparent',
justifyContent: 'center',
alignItems: 'center',
padding: 0,
overflow: 'visible',
width: '100%',
}),
customControlIcon: css({
verticalAlign: 'baseline',
@@ -633,22 +729,3 @@ const getStyles = (theme: GrafanaTheme2) => {
}),
};
};
function getTimestampTooltip(showTime: boolean, timestampResolution: LogLineTimestampResolution) {
if (!showTime) {
return t('logs.logs-controls.show-ms-timestamps', 'Show millisecond timestamps');
}
if (timestampResolution === 'ms') {
return t('logs.logs-controls.show-ns-timestamps', 'Show nanosecond timestamps');
}
return t('logs.logs-controls.hide-timestamps', 'Hide timestamps');
}
function getWrapLogMessageTooltip(wrapLogMessage: boolean, prettifyJSON: boolean | undefined) {
if (!wrapLogMessage) {
return t('logs.logs-controls.wrap-lines', 'Wrap lines');
}
return prettifyJSON
? t('logs.logs-controls.unwrap-lines', 'Unwrap lines')
: t('logs.logs-controls.wrap-json-lines', 'Wrap lines and expand JSON');
}
+12 -3
View File
@@ -9644,6 +9644,12 @@
"hide-search": "Close search",
"hide-timestamps": "Hide timestamps",
"hide-unique-labels": "Hide unique labels",
"line-wrapping": {
"enable": "Enable line wrapping",
"enable-prettify": "Enable line wrapping and prettify JSON",
"hide": "Disable line wrapping",
"label": "Log line wrapping"
},
"newest-first": "Sorted by newest logs first - Click to show oldest first",
"oldest-first": "Sorted by oldest logs first - Click to show newest first",
"prettify-json": "Expand JSON logs",
@@ -9652,13 +9658,16 @@
"resolution-ns": "ns",
"scroll-bottom": "Scroll to bottom",
"scroll-top": "Scroll to top",
"show-ms-timestamps": "Show millisecond timestamps",
"show-ns-timestamps": "Show nanosecond timestamps",
"show-search": "Search in logs result",
"show-timestamps": "Show timestamps",
"show-unique-labels": "Show unique labels",
"timestamp": {
"hide": "Hide timestamps",
"label": "Log timestamps",
"milliseconds": "Show millisecond timestamps",
"nanoseconds": "Show nanosecond timestamps"
},
"unwrap-lines": "Unwrap lines",
"wrap-json-lines": "Wrap lines and expand JSON",
"wrap-lines": "Wrap lines"
},
"logs-navigation": {