diff --git a/public/app/features/explore/Logs/Logs.tsx b/public/app/features/explore/Logs/Logs.tsx index 1d542268cd0..3b04394faf8 100644 --- a/public/app/features/explore/Logs/Logs.tsx +++ b/public/app/features/explore/Logs/Logs.tsx @@ -703,7 +703,7 @@ const UnthemedLogs: React.FunctionComponent = (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)) { diff --git a/public/app/features/logs/components/ControlledLogRows.tsx b/public/app/features/logs/components/ControlledLogRows.tsx index a77f8ff4326..13e57b3781f 100644 --- a/public/app/features/logs/components/ControlledLogRows.tsx +++ b/public/app/features/logs/components/ControlledLogRows.tsx @@ -30,7 +30,7 @@ export interface ControlledLogRowsProps extends Omit { 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[]; diff --git a/public/app/features/logs/components/panel/LogList.tsx b/public/app/features/logs/components/panel/LogList.tsx index 67765c5d307..5ff50f22286 100644 --- a/public/app/features/logs/components/panel/LogList.tsx +++ b/public/app/features/logs/components/panel/LogList.tsx @@ -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; 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); diff --git a/public/app/features/logs/components/panel/LogListContext.tsx b/public/app/features/logs/components/panel/LogListContext.tsx index 7e5c4154213..472fd7859fe 100644 --- a/public/app/features/logs/components/panel/LogListContext.tsx +++ b/public/app/features/logs/components/panel/LogListContext.tsx @@ -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; 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({ 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([]); const [detailsWidth, setDetailsWidthState] = useState( @@ -260,6 +260,8 @@ export const LogListContextProvider = ({ ); const [detailsMode, setDetailsMode] = useState(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, }} diff --git a/public/app/features/logs/components/panel/LogListControls.test.tsx b/public/app/features/logs/components/panel/LogListControls.test.tsx index 2acdf38abe0..40782778f79 100644 --- a/public/app/features/logs/components/panel/LogListControls.test.tsx +++ b/public/app/features/logs/components/panel/LogListControls.test.tsx @@ -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( + + + + ); + + 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( diff --git a/public/app/features/logs/components/panel/LogListControls.tsx b/public/app/features/logs/components/panel/LogListControls.tsx index edc1bee1613..372b135e48f 100644 --- a/public/app/features/logs/components/panel/LogListControls.tsx +++ b/public/app/features/logs/components/panel/LogListControls.tsx @@ -312,19 +312,23 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props) size="lg" /> )} - - {prettifyJSON !== undefined && ( + {config.featureToggles.newLogsPanel ? ( + + ) : ( + + )} + {prettifyJSON !== undefined && !config.featureToggles.newLogsPanel && ( { + + ); +}; + 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'); +} diff --git a/public/app/features/logs/components/panel/processing.test.ts b/public/app/features/logs/components/panel/processing.test.ts index 4b8c42dcf15..2537a46c2f3 100644 --- a/public/app/features/logs/components/panel/processing.test.ts +++ b/public/app/features/logs/components/panel/processing.test.ts @@ -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); diff --git a/public/app/features/logs/components/panel/processing.ts b/public/app/features/logs/components/panel/processing.ts index 3d9a6853701..60d9909a52f 100644 --- a/public/app/features/logs/components/panel/processing.ts +++ b/public/app/features/logs/components/panel/processing.ts @@ -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; diff --git a/public/app/plugins/panel/logs-new/types.ts b/public/app/plugins/panel/logs-new/types.ts index c772acaef60..a9a0ad8c9e0 100644 --- a/public/app/plugins/panel/logs-new/types.ts +++ b/public/app/plugins/panel/logs-new/types.ts @@ -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'; diff --git a/public/app/plugins/panel/logs/LogsPanel.tsx b/public/app/plugins/panel/logs/LogsPanel.tsx index b5405d81bc3..d57527535cb 100644 --- a/public/app/plugins/panel/logs/LogsPanel.tsx +++ b/public/app/plugins/panel/logs/LogsPanel.tsx @@ -114,7 +114,7 @@ interface LogsPanelProps extends PanelProps { * 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. diff --git a/public/app/plugins/panel/logs/types.ts b/public/app/plugins/panel/logs/types.ts index 326a0168383..bd369daa388 100644 --- a/public/app/plugins/panel/logs/types.ts +++ b/public/app/plugins/panel/logs/types.ts @@ -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 = ( diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 92fb3f6423d..f06cd5d7e7b 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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": {