New Logs Panel: Extend line wrapping to control JSON formatting (#110224)

* LogListControls: add third state to line wrapping

* LogListContext: decouple wrapLogMessage and prettifyJSON from state object

* Processing: reformat JSON according to prettifyJSON

* LogListControls: update class names and colors

* onLogOptionsChangeType: update type signature

* Update type

* Update tests

* Dont translate the plus sign

* Comments
This commit is contained in:
Matias Chomicki
2025-08-27 17:34:14 +00:00
committed by GitHub
parent cfe73925cd
commit e78f6b6b37
12 changed files with 180 additions and 51 deletions
+1 -1
View File
@@ -703,7 +703,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
const visibilityChangedRef = useRef(true);
const onLogOptionsChange = useCallback(
(option: keyof LogListControlOptions, value: string | string[] | boolean) => {
(option: LogListControlOptions, value: string | string[] | boolean) => {
if (option === 'sortOrder' && isLogsSortOrder(value)) {
sortOrderChanged(value);
} else if (option === 'dedupStrategy' && isDedupStrategy(value)) {
@@ -30,7 +30,7 @@ export interface ControlledLogRowsProps extends Omit<Props, 'scrollElement'> {
logsMeta?: LogsMetaItem[];
loadMoreLogs?: (range: AbsoluteTimeRange) => void;
logOptionsStorageKey?: string;
onLogOptionsChange?: (option: keyof LogListControlOptions, value: string | boolean | string[]) => void;
onLogOptionsChange?: (option: LogListControlOptions, value: string | boolean | string[]) => void;
range: TimeRange;
filterLevels?: LogLevel[];
@@ -66,7 +66,7 @@ export interface Props {
onClickFilterOutString?: (value: string, refId?: string) => void;
onClickShowField?: (key: string) => void;
onClickHideField?: (key: string) => void;
onLogOptionsChange?: (option: keyof LogListControlOptions, value: string | boolean | string[]) => void;
onLogOptionsChange?: (option: LogListControlOptions, value: string | boolean | string[]) => void;
onLogLineHover?: (row?: LogRowModel) => void;
onPermalinkClick?: (row: LogRowModel) => Promise<void>;
onPinLine?: (row: LogRowModel) => void;
@@ -88,7 +88,7 @@ export interface Props {
export type LogListFontSize = 'default' | 'small';
export type LogListControlOptions = LogListState;
export type LogListControlOptions = keyof LogListState | 'wrapLogMessage' | 'prettifyJSON';
type LogListComponentProps = Omit<
Props,
@@ -241,6 +241,7 @@ const LogListComponent = ({
onClickFilterString,
onClickFilterOutString,
permalinkedLogId,
prettifyJSON,
showDetails,
showTime,
sortOrder,
@@ -315,13 +316,21 @@ const LogListComponent = ({
setProcessedLogs(
preProcessLogs(
logs,
{ getFieldLinks, escape: forceEscape ?? false, order: sortOrder, timeZone, virtualization, wrapLogMessage },
{
getFieldLinks,
escape: forceEscape ?? false,
prettifyJSON,
order: sortOrder,
timeZone,
virtualization,
wrapLogMessage,
},
grammar
)
);
virtualization.resetLogLineSizes();
listRef.current?.resetAfterIndex(0);
}, [forceEscape, getFieldLinks, grammar, logs, sortOrder, timeZone, virtualization, wrapLogMessage]);
}, [forceEscape, getFieldLinks, grammar, logs, prettifyJSON, sortOrder, timeZone, virtualization, wrapLogMessage]);
useEffect(() => {
listRef.current?.resetAfterIndex(0);
@@ -33,7 +33,7 @@ import { getDisplayedFieldsForLogs } from '../otel/formats';
import { LogLineTimestampResolution } from './LogLine';
import { LogLineDetailsMode } from './LogLineDetails';
import { GetRowContextQueryFn, LogLineMenuCustomItem } from './LogLineMenu';
import { LogListFontSize } from './LogList';
import { LogListControlOptions, LogListFontSize } from './LogList';
import { reportInteractionOnce } from './analytics';
import { LogListModel } from './processing';
import { getScrollbarWidth, LOG_LIST_CONTROLS_WIDTH, LOG_LIST_MIN_WIDTH } from './virtualization';
@@ -140,15 +140,15 @@ export type LogListState = Pick<
| 'forceEscape'
| 'filterLevels'
| 'pinnedLogs'
| 'prettifyJSON'
| 'showUniqueLabels'
| 'showTime'
| 'sortOrder'
| 'syntaxHighlighting'
| 'timestampResolution'
| 'wrapLogMessage'
>;
export type LogListOption = keyof LogListState | 'wrapLogMessage' | 'prettifyJSON';
export interface Props {
app: CoreApp;
children?: ReactNode;
@@ -174,7 +174,7 @@ export interface Props {
onClickFilterOutString?: (value: string, refId?: string) => void;
onClickShowField?: (key: string) => void;
onClickHideField?: (key: string) => void;
onLogOptionsChange?: (option: keyof LogListState, value: string | boolean | string[]) => void;
onLogOptionsChange?: (option: LogListControlOptions, value: string | boolean | string[]) => void;
onLogLineHover?: (row?: LogRowModel) => void;
onPermalinkClick?: (row: LogRowModel) => Promise<void>;
onPinLine?: (row: LogRowModel) => void;
@@ -227,7 +227,9 @@ export const LogListContextProvider = ({
permalinkedLogId,
pinLineButtonTooltipTitle,
pinnedLogs,
prettifyJSON,
prettifyJSON: prettifyJSONProp = logOptionsStorageKey
? store.getBool(`${logOptionsStorageKey}.prettifyLogMessage`, true)
: true,
setDisplayedFields,
showControls,
showTime,
@@ -237,7 +239,7 @@ export const LogListContextProvider = ({
timestampResolution = logOptionsStorageKey
? (store.get(`${logOptionsStorageKey}.timestampResolution`) ?? 'ms')
: 'ms',
wrapLogMessage,
wrapLogMessage: wrapLogMessageProp,
}: Props) => {
const [logListState, setLogListState] = useState<LogListState>({
dedupStrategy,
@@ -246,13 +248,11 @@ export const LogListContextProvider = ({
fontSize,
forceEscape: logOptionsStorageKey ? store.getBool(`${logOptionsStorageKey}.forceEscape`, false) : false,
pinnedLogs,
prettifyJSON,
showTime,
showUniqueLabels,
sortOrder,
syntaxHighlighting,
timestampResolution,
wrapLogMessage,
});
const [showDetails, setShowDetails] = useState<LogListModel[]>([]);
const [detailsWidth, setDetailsWidthState] = useState(
@@ -260,6 +260,8 @@ export const LogListContextProvider = ({
);
const [detailsMode, setDetailsMode] = useState<LogLineDetailsMode>(detailsModeProp ?? 'sidebar');
const [isAssistantAvailable, openAssistant] = useAssistant();
const [prettifyJSON, setPrettifyJSONState] = useState(prettifyJSONProp);
const [wrapLogMessage, setWrapLogMessageState] = useState(wrapLogMessageProp);
useEffect(() => {
if (noInteractions) {
@@ -406,8 +408,9 @@ export const LogListContextProvider = ({
store.set(`${logOptionsStorageKey}.fontSize`, fontSize);
}
setLogListState((logListState) => ({ ...logListState, fontSize }));
onLogOptionsChange?.('fontSize', fontSize);
},
[logOptionsStorageKey]
[logOptionsStorageKey, onLogOptionsChange]
);
const setForceEscape = useCallback(
@@ -463,13 +466,13 @@ export const LogListContextProvider = ({
const setPrettifyJSON = useCallback(
(prettifyJSON: boolean) => {
setLogListState({ ...logListState, prettifyJSON });
onLogOptionsChange?.('prettifyJSON', prettifyJSON);
setPrettifyJSONState(prettifyJSON);
if (logOptionsStorageKey) {
store.set(`${logOptionsStorageKey}.prettifyLogMessage`, prettifyJSON);
}
onLogOptionsChange?.('prettifyJSON', prettifyJSON);
},
[logListState, logOptionsStorageKey, onLogOptionsChange]
[logOptionsStorageKey, onLogOptionsChange]
);
const setSyntaxHighlighting = useCallback(
@@ -496,13 +499,13 @@ export const LogListContextProvider = ({
const setWrapLogMessage = useCallback(
(wrapLogMessage: boolean) => {
setLogListState({ ...logListState, wrapLogMessage });
onLogOptionsChange?.('wrapLogMessage', wrapLogMessage);
setWrapLogMessageState(wrapLogMessage);
if (logOptionsStorageKey) {
store.set(`${logOptionsStorageKey}.wrapLogMessage`, wrapLogMessage);
}
onLogOptionsChange?.('wrapLogMessage', wrapLogMessage);
},
[logListState, logOptionsStorageKey, onLogOptionsChange]
[logOptionsStorageKey, onLogOptionsChange]
);
const downloadLogs = useCallback(
@@ -620,7 +623,7 @@ export const LogListContextProvider = ({
permalinkedLogId,
pinLineButtonTooltipTitle,
pinnedLogs: logListState.pinnedLogs,
prettifyJSON: logListState.prettifyJSON,
prettifyJSON,
setDedupStrategy,
setDetailsMode,
setDetailsWidth,
@@ -644,7 +647,7 @@ export const LogListContextProvider = ({
syntaxHighlighting: logListState.syntaxHighlighting,
timestampResolution: logListState.timestampResolution,
toggleDetails,
wrapLogMessage: logListState.wrapLogMessage,
wrapLogMessage,
isAssistantAvailable,
openAssistantByLog,
}}
@@ -211,6 +211,42 @@ describe('LogListControls', () => {
expect(onLogOptionsChange).toHaveBeenCalledWith('wrapLogMessage', true);
});
test('Controls line wrapping and prettify JSON', async () => {
const originalFlagState = config.featureToggles.newLogsPanel;
config.featureToggles.newLogsPanel = true;
const onLogOptionsChange = jest.fn();
render(
<LogListContextProvider
{...contextProps}
wrapLogMessage={false}
onLogOptionsChange={onLogOptionsChange}
prettifyJSON={false}
>
<LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider>
);
await userEvent.click(screen.getByLabelText('Wrap lines'));
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);
expect(onLogOptionsChange).toHaveBeenCalledWith('prettifyJSON', true);
await userEvent.click(screen.getByLabelText('Unwrap lines'));
expect(onLogOptionsChange).toHaveBeenCalledWith('wrapLogMessage', false);
expect(onLogOptionsChange).toHaveBeenCalledWith('prettifyJSON', false);
expect(onLogOptionsChange).toHaveBeenCalledTimes(5);
config.featureToggles.newLogsPanel = originalFlagState;
});
test('Controls syntax highlighting', async () => {
const onLogOptionsChange = jest.fn();
render(
@@ -312,19 +312,23 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props)
size="lg"
/>
)}
<IconButton
name="wrap-text"
className={wrapLogMessage ? styles.controlButtonActive : styles.controlButton}
aria-pressed={wrapLogMessage}
onClick={onWrapLogMessageClick}
tooltip={
wrapLogMessage
? t('logs.logs-controls.unwrap-lines', 'Unwrap lines')
: t('logs.logs-controls.wrap-lines', 'Wrap lines')
}
size="lg"
/>
{prettifyJSON !== undefined && (
{config.featureToggles.newLogsPanel ? (
<WrapLogMessageButton />
) : (
<IconButton
name="wrap-text"
className={wrapLogMessage ? styles.controlButtonActive : styles.controlButton}
aria-pressed={wrapLogMessage}
onClick={onWrapLogMessageClick}
tooltip={
wrapLogMessage
? t('logs.logs-controls.unwrap-lines', 'Unwrap lines')
: t('logs.logs-controls.wrap-lines', 'Wrap lines')
}
size="lg"
/>
)}
{prettifyJSON !== undefined && !config.featureToggles.newLogsPanel && (
<IconButton
name="brackets-curly"
aria-pressed={prettifyJSON}
@@ -480,13 +484,13 @@ const TimestampResolutionButton = () => {
<button
aria-label={getTimestampTooltip(showTime, timestampResolution)}
aria-pressed={showTime}
className={`${styles.timestampResolutionButton} ${showTime ? styles.controlButtonActive : styles.controlButton}`}
className={`${styles.customControlButton} ${showTime ? styles.controlButtonActive : styles.controlButton}`}
type="button"
onClick={onShowTimestampsClick}
>
<Icon name="clock-nine" size="lg" className={styles.timestampResolutionIcon} />
<Icon name="clock-nine" size="lg" className={styles.customControlIcon} />
{showTime && (
<span className={styles.resolutionText}>
<span className={styles.customControlTag}>
{timestampResolution === 'ms'
? t('logs.logs-controls.resolution-ms', 'ms')
: t('logs.logs-controls.resolution-ns', 'ns')}
@@ -497,6 +501,51 @@ const TimestampResolutionButton = () => {
);
};
const WrapLogMessageButton = () => {
const styles = useStyles2(getStyles);
const { prettifyJSON, setPrettifyJSON, setWrapLogMessage, wrapLogMessage } = useLogListContext();
/**
* This component currently controls two internal states: line wrapping and JSON formatting.
* The state transition is as follows:
* - Line wrapping and JSON formatting disabled.
* - Line wrapping enabled.
* - Line wrapping and JSON formatting enabled.
*
* 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);
}
reportInteraction('logs_log_list_controls_wrap_clicked', {
state: !wrapLogMessage,
});
}, [prettifyJSON, setPrettifyJSON, setWrapLogMessage, 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>
);
};
const getStyles = (theme: GrafanaTheme2) => {
return {
navContainer: css({
@@ -556,7 +605,7 @@ const getStyles = (theme: GrafanaTheme2) => {
backgroundColor: theme.colors.warning.main,
},
}),
timestampResolutionButton: css({
customControlButton: css({
position: 'relative',
zIndex: 0,
margin: 0,
@@ -569,17 +618,17 @@ const getStyles = (theme: GrafanaTheme2) => {
padding: 0,
overflow: 'visible',
}),
timestampResolutionIcon: css({
customControlIcon: css({
verticalAlign: 'baseline',
}),
resolutionText: css({
color: theme.colors.text.primary,
customControlTag: css({
color: theme.colors.primary.text,
fontSize: 10,
position: 'absolute',
bottom: -4,
right: 0,
right: 1,
lineHeight: '10px',
backgroundColor: theme.colors.background.elevated,
backgroundColor: theme.colors.background.primary,
paddingLeft: 2,
}),
};
@@ -594,3 +643,12 @@ function getTimestampTooltip(showTime: boolean, timestampResolution: LogLineTime
}
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');
}
@@ -157,6 +157,22 @@ describe('preProcessLogs', () => {
expect(logListModel.body).toBe(entry);
});
test('Does not modify wrapped JSON', () => {
const entry = '{"key": "value", "otherKey": "otherValue"}';
const logListModel = createLogLine(
{ entry },
{
escape: false,
order: LogsSortOrder.Descending,
timeZone: 'browser',
wrapLogMessage: false, // unwrapped
prettifyJSON: false,
}
);
expect(logListModel.entry).toBe(entry);
expect(logListModel.body).toBe(entry);
});
test('Prettifies wrapped JSON', () => {
const entry = '{"key": "value", "otherKey": "otherValue"}';
const logListModel = createLogLine(
@@ -166,6 +182,7 @@ describe('preProcessLogs', () => {
order: LogsSortOrder.Descending,
timeZone: 'browser',
wrapLogMessage: true, // wrapped
prettifyJSON: true,
}
);
expect(logListModel.entry).toBe(entry);
@@ -61,13 +61,14 @@ export class LogListModel implements LogRowModel {
private _highlightedBody: string | undefined = undefined;
private _fields: FieldDef[] | undefined = undefined;
private _getFieldLinks: GetFieldLinksFn | undefined = undefined;
private _prettifyJSON: boolean;
private _virtualization?: LogLineVirtualization;
private _wrapLogMessage: boolean;
private _json = false;
constructor(
log: LogRowModel,
{ escape, getFieldLinks, grammar, timeZone, virtualization, wrapLogMessage }: PreProcessLogOptions
{ escape, getFieldLinks, grammar, prettifyJSON, timeZone, virtualization, wrapLogMessage }: PreProcessLogOptions
) {
// LogRowModel
this.datasourceType = log.datasourceType;
@@ -98,6 +99,7 @@ export class LogListModel implements LogRowModel {
this.displayLevel = logLevelToDisplayLevel(log.logLevel);
this._getFieldLinks = getFieldLinks;
this._grammar = grammar;
this._prettifyJSON = Boolean(prettifyJSON);
this.timestamp = dateTimeFormat(log.timeEpochMs, {
timeZone,
// YYYY-MM-DD HH:mm:ss.SSS
@@ -129,7 +131,7 @@ export class LogListModel implements LogRowModel {
if (typeof parsed === 'object' && parsed !== null && !(parsed instanceof LosslessNumber)) {
this._json = true;
}
const reStringified = this._wrapLogMessage ? stringify(parsed, undefined, 2) : this.raw;
const reStringified = this._wrapLogMessage && this._prettifyJSON ? stringify(parsed, undefined, 2) : this.raw;
if (reStringified) {
this.raw = reStringified;
}
@@ -243,6 +245,7 @@ export interface PreProcessOptions {
escape: boolean;
getFieldLinks?: GetFieldLinksFn;
order: LogsSortOrder;
prettifyJSON?: boolean;
timeZone: string;
virtualization?: LogLineVirtualization;
wrapLogMessage: boolean;
@@ -250,7 +253,7 @@ export interface PreProcessOptions {
export const preProcessLogs = (
logs: LogRowModel[],
{ escape, getFieldLinks, order, timeZone, virtualization, wrapLogMessage }: PreProcessOptions,
{ escape, getFieldLinks, order, prettifyJSON, timeZone, virtualization, wrapLogMessage }: PreProcessOptions,
grammar?: Grammar
): LogListModel[] => {
const orderedLogs = sortLogRows(logs, order);
@@ -259,6 +262,7 @@ export const preProcessLogs = (
escape,
getFieldLinks,
grammar,
prettifyJSON,
timeZone,
virtualization,
wrapLogMessage,
@@ -270,6 +274,7 @@ interface PreProcessLogOptions {
escape: boolean;
getFieldLinks?: GetFieldLinksFn;
grammar?: Grammar;
prettifyJSON?: boolean;
timeZone: string;
virtualization?: LogLineVirtualization;
wrapLogMessage: boolean;
+1 -1
View File
@@ -4,7 +4,7 @@ import { CoreApp, DataFrame } from '@grafana/data';
import { LogListControlOptions } from 'app/features/logs/components/panel/LogList';
type onNewLogsReceivedType = (allLogs: DataFrame[], newLogs: DataFrame[]) => void;
type onLogOptionsChangeType = (option: keyof LogListControlOptions, value: string | boolean | string[]) => void;
type onLogOptionsChangeType = (option: LogListControlOptions, value: string | boolean | string[]) => void;
export function isOnNewLogsReceivedType(callback: unknown): callback is onNewLogsReceivedType {
return typeof callback === 'function';
+1 -1
View File
@@ -114,7 +114,7 @@ interface LogsPanelProps extends PanelProps<Options> {
* controlsStorageKey?: string
*
* If controls are enabled, this function is called when a change is made in one of the options from the controls.
* onLogOptionsChange?: (option: keyof LogListControlOptions, value: string | boolean | string[]) => void;
* onLogOptionsChange?: (option: LogListControlOptions, value: string | boolean | string[]) => void;
*
* When the feature toggle newLogsPanel is enabled, you can pass extra options to the LogLineMenu component.
* These options are an array of items with { label, onClick } or { divider: true } for dividers.
+1 -1
View File
@@ -14,7 +14,7 @@ type filterLabelActiveType = (key: string, value: string, refId?: string) => Pro
type onClickShowFieldType = (value: string) => void;
type onClickHideFieldType = (value: string) => void;
export type onNewLogsReceivedType = (allLogs: DataFrame[], newLogs: DataFrame[]) => void;
type onLogOptionsChangeType = (option: keyof LogListControlOptions, value: string | boolean | string[]) => void;
type onLogOptionsChangeType = (option: LogListControlOptions, value: string | boolean | string[]) => void;
type setDisplayedFieldsType = (fields: string[]) => void;
export type GetFieldLinksFn = (
+1
View File
@@ -9644,6 +9644,7 @@
"show-timestamps": "Show timestamps",
"show-unique-labels": "Show unique labels",
"unwrap-lines": "Unwrap lines",
"wrap-json-lines": "Wrap lines and expand JSON",
"wrap-lines": "Wrap lines"
},
"logs-navigation": {