From 778d0b2da49b53e8278e8eb09c25f47509df0c5d Mon Sep 17 00:00:00 2001 From: Matias Chomicki Date: Thu, 10 Apr 2025 20:47:17 +0200 Subject: [PATCH] Logs: Clean up Explore meta information (#103801) * LogLabels: add displayMax property * MetaInfoText: prevent label wrapping * LogsMetaRow: remove unescaped content and max characters * LogsMetaRow: use font weight normal * LogLabels: add button to collapse back * LogsMetaRow: set line height * LogsMetaRow: save common logs state * LogsMetaRow: update unit tests * LogLabels: update unit test * LogListContext: add forceEscape option * LogListContext: move forceEscape and hasUnescapedContents * LogListControls: move forceEscape to controls * LogList: read forceEscape from context * i18n * LogListControls: test new control * Update betterer * logsModel: update test * LogLabels: replace characters with icons * Prettier * LogLabels: add aria labels * i18n * Update tests * Update test --- public/app/features/explore/Logs/Logs.tsx | 12 +- .../explore/Logs/LogsMetaRow.test.tsx | 115 ++++++++++------- .../app/features/explore/Logs/LogsMetaRow.tsx | 69 ++++------ .../app/features/explore/Logs/utils/logs.ts | 1 + public/app/features/explore/MetaInfoText.tsx | 1 + .../logs/components/ControlledLogRows.tsx | 17 ++- .../logs/components/LogLabels.test.tsx | 54 ++++++++ .../features/logs/components/LogLabels.tsx | 121 ++++++++++++------ .../logs/components/panel/LogList.tsx | 10 +- .../logs/components/panel/LogListContext.tsx | 30 ++++- .../components/panel/LogListControls.test.tsx | 19 +++ .../logs/components/panel/LogListControls.tsx | 25 ++++ .../panel/__mocks__/LogListContext.tsx | 3 + public/app/features/logs/logsModel.test.ts | 76 +++++------ public/app/features/logs/logsModel.ts | 15 ++- public/locales/en-US/grafana.json | 6 + 16 files changed, 382 insertions(+), 192 deletions(-) diff --git a/public/app/features/explore/Logs/Logs.tsx b/public/app/features/explore/Logs/Logs.tsx index 8cf6381aa6a..3c1b6c997b0 100644 --- a/public/app/features/explore/Logs/Logs.tsx +++ b/public/app/features/explore/Logs/Logs.tsx @@ -209,7 +209,6 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { ); const [isFlipping, setIsFlipping] = useState(false); const [displayedFields, setDisplayedFields] = useState([]); - const [forceEscape, setForceEscape] = useState(false); const [contextOpen, setContextOpen] = useState(false); const [contextRow, setContextRow] = useState(undefined); const [pinLineButtonTooltipTitle, setPinLineButtonTooltipTitle] = useState(PINNED_LOGS_MESSAGE); @@ -497,10 +496,6 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { [sortOrderChanged] ); - const onEscapeNewlines = useCallback(() => { - setForceEscape(!forceEscape); - }, [forceEscape]); - const onChangeVisualisation = useCallback( (visualisation: LogsVisualisationType) => { setVisualisationType(visualisation); @@ -1000,10 +995,7 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { meta={logsMeta || []} dedupStrategy={dedupStrategy} dedupCount={dedupCount} - hasUnescapedContent={hasUnescapedContent} - forceEscape={forceEscape} displayedFields={displayedFields} - onEscapeNewlines={onEscapeNewlines} clearDetectedFields={clearDetectedFields} /> @@ -1051,7 +1043,6 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { showLabels={showLabels} showTime={showTime} enableLogDetails={true} - forceEscape={forceEscape} wrapLogMessage={wrapLogMessage} prettifyLogMessage={prettifyLogMessage} timeZone={timeZone} @@ -1075,6 +1066,7 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { logsMeta={logsMeta} logOptionsStorageKey={SETTING_KEY_ROOT} onLogOptionsChange={onLogOptionsChange} + hasUnescapedContent={hasUnescapedContent} /> )} @@ -1110,7 +1102,6 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { showLabels={showLabels} showTime={showTime} enableLogDetails={true} - forceEscape={forceEscape} wrapLogMessage={wrapLogMessage} prettifyLogMessage={prettifyLogMessage} timeZone={timeZone} @@ -1159,7 +1150,6 @@ const UnthemedLogs: React.FunctionComponent = (props: Props) => { dedupStrategy={dedupStrategy} displayedFields={displayedFields} filterLevels={filterLevels} - forceEscape={forceEscape} getFieldLinks={getFieldLinks} getRowContextQuery={getRowContextQuery} loadMore={loadMoreLogs} diff --git a/public/app/features/explore/Logs/LogsMetaRow.test.tsx b/public/app/features/explore/Logs/LogsMetaRow.test.tsx index da0277fbcf4..618be846511 100644 --- a/public/app/features/explore/Logs/LogsMetaRow.test.tsx +++ b/public/app/features/explore/Logs/LogsMetaRow.test.tsx @@ -3,11 +3,19 @@ import userEvent from '@testing-library/user-event'; import saveAs from 'file-saver'; import { ComponentProps } from 'react'; -import { FieldType, LogLevel, LogsDedupStrategy, standardTransformersRegistry, toDataFrame } from '@grafana/data'; +import { + FieldType, + LogLevel, + LogsDedupStrategy, + LogsMetaItem, + LogsMetaKind, + standardTransformersRegistry, + store, + toDataFrame, +} from '@grafana/data'; import { organizeFieldsTransformer } from '@grafana/data/internal'; import { config } from '@grafana/runtime'; -import { MAX_CHARACTERS } from '../../logs/components/LogRowMessage'; import { logRowsToReadableJson } from '../../logs/utils'; import { extractFieldsTransformer } from '../../transformers/extractFields/extractFields'; @@ -26,10 +34,7 @@ const defaultProps: LogsMetaRowProps = { dedupStrategy: LogsDedupStrategy.none, dedupCount: 0, displayedFields: [], - hasUnescapedContent: false, - forceEscape: false, logRows: [], - onEscapeNewlines: jest.fn(), clearDetectedFields: jest.fn(), }; @@ -49,13 +54,6 @@ describe('LogsMetaRow', () => { expect(await screen.findByText('1234')).toBeInTheDocument(); }); - it('renders a highlighting warning', async () => { - setup({ logRows: [{ entry: 'A'.repeat(MAX_CHARACTERS + 1) }] }); - expect( - await screen.findByText('Logs with more than 100,000 characters could not be parsed and highlighted') - ).toBeInTheDocument(); - }); - it('renders the show original line button', () => { setup({ displayedFields: ['test'] }); expect( @@ -65,7 +63,7 @@ describe('LogsMetaRow', () => { ).toBeInTheDocument(); }); - it('renders the displayedfield', async () => { + it('renders the displayed fields', async () => { setup({ displayedFields: ['testField1234'] }); expect(await screen.findByText('testField1234')).toBeInTheDocument(); }); @@ -85,39 +83,6 @@ describe('LogsMetaRow', () => { expect(clearSpy).toBeCalled(); }); - it('renders a button to remove escaping', () => { - setup({ hasUnescapedContent: true, forceEscape: true }); - expect( - screen.getByRole('button', { - name: 'Remove escaping', - }) - ).toBeInTheDocument(); - }); - - it('renders a button to remove escaping', () => { - setup({ hasUnescapedContent: true, forceEscape: false }); - expect( - screen.getByRole('button', { - name: 'Escape newlines', - }) - ).toBeInTheDocument(); - }); - - it('renders a button to remove escaping', () => { - const escapeSpy = jest.fn(); - setup({ hasUnescapedContent: true, forceEscape: false, onEscapeNewlines: escapeSpy }); - fireEvent( - screen.getByRole('button', { - name: 'Escape newlines', - }), - new MouseEvent('click', { - bubbles: true, - cancelable: true, - }) - ); - expect(escapeSpy).toBeCalled(); - }); - it('renders a button to show the download menu', () => { setup(); expect(screen.getByText('Download').closest('button')).toBeInTheDocument(); @@ -317,4 +282,62 @@ describe('LogsMetaRow', () => { const text = await blob.text(); expect(text).toBe(`"Date","time","message bar"\r\n1970-01-01T00:00:00.001Z,1,INFO 1`); }); + + it('renders common labels', async () => { + const meta: LogsMetaItem[] = [ + { + label: 'Common labels', + value: { + exporter: 'OTLP', + job: 'cicd-o11y/grafana-deployment-tools', + }, + kind: LogsMetaKind.LabelsMap, + }, + ]; + setup({ meta }); + expect(await screen.findByText(/Common labels/)).toBeInTheDocument(); + expect(await screen.findByText('exporter=OTLP')).toBeInTheDocument(); + expect(await screen.findByText('job=cicd-o11y/grafana-deployment-tools')).toBeInTheDocument(); + }); + + it('renders collapsed common labels', async () => { + const meta: LogsMetaItem[] = [ + { + label: 'Common labels', + value: { + exporter: 'OTLP', + job: 'cicd-o11y/grafana-deployment-tools', + service_name: 'grafana', + service_namespace: 'cicd-o11y', + }, + kind: LogsMetaKind.LabelsMap, + }, + ]; + setup({ meta }); + expect(await screen.findByText(/Common labels/)).toBeInTheDocument(); + expect(await screen.findByText('exporter=OTLP')).toBeInTheDocument(); + expect(await screen.findByText('job=cicd-o11y/grafana-deployment-tools')).toBeInTheDocument(); + expect(await screen.findByLabelText('Expand labels')).toBeInTheDocument(); + }); + + it('renders expanded common labels', async () => { + jest.spyOn(store, 'getBool').mockReturnValue(true); + const meta: LogsMetaItem[] = [ + { + label: 'Common labels', + value: { + exporter: 'OTLP', + job: 'cicd-o11y/grafana-deployment-tools', + service_name: 'grafana', + service_namespace: 'cicd-o11y', + }, + kind: LogsMetaKind.LabelsMap, + }, + ]; + setup({ meta }); + expect(await screen.findByText(/Common labels/)).toBeInTheDocument(); + expect(await screen.findByText('exporter=OTLP')).toBeInTheDocument(); + expect(await screen.findByText('job=cicd-o11y/grafana-deployment-tools')).toBeInTheDocument(); + expect(await screen.findByLabelText('Collapse labels')).toBeInTheDocument(); + }); }); diff --git a/public/app/features/explore/Logs/LogsMetaRow.tsx b/public/app/features/explore/Logs/LogsMetaRow.tsx index 96aa09fa933..ab893d02933 100644 --- a/public/app/features/explore/Logs/LogsMetaRow.tsx +++ b/public/app/features/explore/Logs/LogsMetaRow.tsx @@ -1,21 +1,26 @@ import { css } from '@emotion/css'; import { memo } from 'react'; -import { LogsDedupStrategy, LogsMetaItem, LogsMetaKind, LogRowModel, CoreApp, Labels } from '@grafana/data'; +import { LogsDedupStrategy, LogsMetaItem, LogsMetaKind, LogRowModel, CoreApp, Labels, store } from '@grafana/data'; import { config, reportInteraction } from '@grafana/runtime'; -import { Button, Dropdown, Menu, ToolbarButton, Tooltip, useStyles2 } from '@grafana/ui'; +import { Button, Dropdown, Menu, ToolbarButton, useStyles2 } from '@grafana/ui'; import { Trans } from 'app/core/internationalization'; -import { LogLabels, LogLabelsList } from '../../logs/components/LogLabels'; -import { MAX_CHARACTERS } from '../../logs/components/LogRowMessage'; +import { LogLabels, LogLabelsList, Props as LogLabelsProps } from '../../logs/components/LogLabels'; import { DownloadFormat, downloadLogs } from '../../logs/utils'; import { MetaInfoText, MetaItemProps } from '../MetaInfoText'; +import { SETTINGS_KEYS } from './utils/logs'; + const getStyles = () => ({ metaContainer: css({ flex: 1, display: 'flex', flexWrap: 'wrap', + '& span': { + fontWeight: 'normal', + lineHeight: '1.25em', + }, }), }); @@ -24,25 +29,12 @@ export type Props = { dedupStrategy: LogsDedupStrategy; dedupCount: number; displayedFields: string[]; - hasUnescapedContent: boolean; - forceEscape: boolean; logRows: LogRowModel[]; - onEscapeNewlines: () => void; clearDetectedFields: () => void; }; export const LogsMetaRow = memo( - ({ - meta, - dedupStrategy, - dedupCount, - displayedFields, - clearDetectedFields, - hasUnescapedContent, - forceEscape, - onEscapeNewlines, - logRows, - }: Props) => { + ({ meta, dedupStrategy, dedupCount, displayedFields, clearDetectedFields, logRows }: Props) => { const style = useStyles2(getStyles); const logsMetaItem: Array = [...meta]; @@ -55,14 +47,6 @@ export const LogsMetaRow = memo( kind: LogsMetaKind.Number, }); } - // Add info about limit for highlighting - if (logRows.some((r) => r.entry.length > MAX_CHARACTERS)) { - logsMetaItem.push({ - label: 'Info', - value: 'Logs with more than 100,000 characters could not be parsed and highlighted', - kind: LogsMetaKind.String, - }); - } // Add detected fields info if (displayedFields?.length > 0) { @@ -91,22 +75,6 @@ export const LogsMetaRow = memo( downloadLogs(format, logRows, meta); } - // Add unescaped content info - if (hasUnescapedContent) { - logsMetaItem.push({ - label: 'Your logs might have incorrectly escaped content', - value: ( - - - - ), - }); - } const downloadMenu = ( {/* eslint-disable-next-line @grafana/no-untranslated-strings */} @@ -117,6 +85,17 @@ export const LogsMetaRow = memo( download(DownloadFormat.CSV)} /> ); + + const onCommonLabelsToggle = (state: boolean) => { + store.set(SETTINGS_KEYS.commonLabels, state); + }; + + const commonLabelsProps = { + onDisplayMaxToggle: onCommonLabelsToggle, + displayMax: 3, + displayAll: store.getBool(SETTINGS_KEYS.commonLabels, false), + }; + return ( <> {logsMetaItem && ( @@ -125,7 +104,7 @@ export const LogsMetaRow = memo( metaItems={logsMetaItem.map((item) => { return { label: item.label, - value: 'kind' in item ? renderMetaItem(item.value, item.kind) : item.value, + value: 'kind' in item ? renderMetaItem(item.value, item.kind, commonLabelsProps) : item.value, }; })} /> @@ -145,12 +124,12 @@ export const LogsMetaRow = memo( LogsMetaRow.displayName = 'LogsMetaRow'; -function renderMetaItem(value: string | number | Labels, kind: LogsMetaKind) { +function renderMetaItem(value: string | number | Labels, kind: LogsMetaKind, logLabelsProps: Partial) { if (typeof value === 'string' || typeof value === 'number') { return <>{value}; } if (kind === LogsMetaKind.LabelsMap) { - return ; + return ; } if (kind === LogsMetaKind.Error) { return {value.toString()}; diff --git a/public/app/features/explore/Logs/utils/logs.ts b/public/app/features/explore/Logs/utils/logs.ts index 1e8cd037436..51926b33c87 100644 --- a/public/app/features/explore/Logs/utils/logs.ts +++ b/public/app/features/explore/Logs/utils/logs.ts @@ -5,6 +5,7 @@ export const SETTINGS_KEYS = { prettifyLogMessage: 'grafana.explore.logs.prettifyLogMessage', logsSortOrder: 'grafana.explore.logs.sortOrder', logContextWrapLogMessage: 'grafana.explore.logs.logContext.wrapLogMessage', + commonLabels: 'grafana.explore.logs.commonLabels', }; export const SETTING_KEY_ROOT = 'grafana.explore.logs'; diff --git a/public/app/features/explore/MetaInfoText.tsx b/public/app/features/explore/MetaInfoText.tsx index 906ff0c2c9c..d2106cb155d 100644 --- a/public/app/features/explore/MetaInfoText.tsx +++ b/public/app/features/explore/MetaInfoText.tsx @@ -26,6 +26,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ marginRight: `calc(${theme.spacing(2)} / 2)`, fontSize: theme.typography.bodySmall.fontSize, fontWeight: theme.typography.fontWeightMedium, + whiteSpace: 'nowrap', }), metaValue: css({ fontFamily: theme.typography.fontFamilyMonospace, diff --git a/public/app/features/logs/components/ControlledLogRows.tsx b/public/app/features/logs/components/ControlledLogRows.tsx index dba49b9df09..a08a7955f95 100644 --- a/public/app/features/logs/components/ControlledLogRows.tsx +++ b/public/app/features/logs/components/ControlledLogRows.tsx @@ -25,6 +25,7 @@ import { LogListControls } from './panel/LogListControls'; import { ScrollToLogsEvent } from './panel/virtualization'; export interface ControlledLogRowsProps extends Omit { + hasUnescapedContent?: boolean; loading: boolean; logsMeta?: LogsMetaItem[]; loadMoreLogs?: (range: AbsoluteTimeRange) => void; @@ -50,6 +51,7 @@ export type LogRowsComponentProps = Omit< export const ControlledLogRows = ({ deduplicatedRows, dedupStrategy, + hasUnescapedContent, showLabels, showTime, logsMeta, @@ -65,6 +67,7 @@ export const ControlledLogRows = ({ app={rest.app || CoreApp.Unknown} displayedFields={[]} dedupStrategy={dedupStrategy} + hasUnescapedContent={hasUnescapedContent} logOptionsStorageKey={logOptionsStorageKey} logs={deduplicatedRows ?? []} logsMeta={logsMeta} @@ -83,8 +86,17 @@ export const ControlledLogRows = ({ }; const LogRowsComponent = ({ loading, loadMoreLogs, deduplicatedRows = [], range, ...rest }: LogRowsComponentProps) => { - const { app, dedupStrategy, filterLevels, prettifyJSON, sortOrder, showTime, showUniqueLabels, wrapLogMessage } = - useLogListContext(); + const { + app, + dedupStrategy, + filterLevels, + forceEscape, + prettifyJSON, + sortOrder, + showTime, + showUniqueLabels, + wrapLogMessage, + } = useLogListContext(); const eventBus = useMemo(() => new EventBusSrv(), []); const scrollElementRef = useRef(null); @@ -124,6 +136,7 @@ const LogRowsComponent = ({ loading, loadMoreLogs, deduplicatedRows = [], range, app={app} dedupStrategy={dedupStrategy} deduplicatedRows={filteredLogs} + forceEscape={forceEscape} logRows={filteredLogs} logsSortOrder={sortOrder} scrollElement={scrollElementRef.current} diff --git a/public/app/features/logs/components/LogLabels.test.tsx b/public/app/features/logs/components/LogLabels.test.tsx index f736b0a595c..b70af572021 100644 --- a/public/app/features/logs/components/LogLabels.test.tsx +++ b/public/app/features/logs/components/LogLabels.test.tsx @@ -35,6 +35,60 @@ describe('', () => { await userEvent.hover(screen.getByText('foo=bar')); expect(screen.getAllByText('foo=bar')).toHaveLength(1); }); + describe('displayMax', () => { + it('renders up to displayMax labels', () => { + render(); + expect(screen.getByText('foo=bar')).toBeInTheDocument(); + expect(screen.getByLabelText('Expand labels')).toBeInTheDocument(); + expect(screen.queryByText('baz=42')).not.toBeInTheDocument(); + }); + + it('allows to render all labels', async () => { + const onDisplayMaxToggle = jest.fn(); + const { rerender } = render( + + ); + + await userEvent.click(screen.getByLabelText('Expand labels')); + expect(onDisplayMaxToggle).toHaveBeenCalledTimes(1); + expect(onDisplayMaxToggle).toHaveBeenCalledWith(true); + + rerender(); + + expect(screen.getByText('foo=bar')).toBeInTheDocument(); + expect(screen.getByText('baz=42')).toBeInTheDocument(); + expect(screen.getByLabelText('Collapse labels')).toBeInTheDocument(); + }); + + it('allows to collapse labels', async () => { + const onDisplayMaxToggle = jest.fn(); + const { rerender } = render( + + ); + + await userEvent.click(screen.getByLabelText('Collapse labels')); + expect(onDisplayMaxToggle).toHaveBeenCalledTimes(1); + expect(onDisplayMaxToggle).toHaveBeenCalledWith(false); + + rerender( + + ); + + expect(screen.getByText('foo=bar')).toBeInTheDocument(); + expect(screen.getByLabelText('Expand labels')).toBeInTheDocument(); + expect(screen.queryByText('baz=42')).not.toBeInTheDocument(); + }); + }); }); describe('', () => { diff --git a/public/app/features/logs/components/LogLabels.tsx b/public/app/features/logs/components/LogLabels.tsx index fa675287a72..6b363383906 100644 --- a/public/app/features/logs/components/LogLabels.tsx +++ b/public/app/features/logs/components/LogLabels.tsx @@ -1,54 +1,101 @@ -import { css, cx } from '@emotion/css'; -import { memo, forwardRef, useMemo } from 'react'; +import { css } from '@emotion/css'; +import { memo, forwardRef, useMemo, useState } from 'react'; import { GrafanaTheme2, Labels } from '@grafana/data'; -import { Tooltip, useStyles2 } from '@grafana/ui'; +import { Button, Icon, Tooltip, useStyles2 } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; import { LOG_LINE_BODY_FIELD_NAME } from './LogDetailsBody'; // Levels are already encoded in color, filename is a Loki-ism const HIDDEN_LABELS = ['detected_level', 'level', 'lvl', 'filename']; -interface Props { +export interface Props { labels: Labels; emptyMessage?: string; addTooltip?: boolean; + displayMax?: number; + displayAll?: boolean; + onDisplayMaxToggle?(state: boolean): void; } -export const LogLabels = memo(({ labels, emptyMessage, addTooltip = true }: Props) => { - const styles = useStyles2(getStyles); - const displayLabels = useMemo( - () => - Object.keys(labels) - .filter((label) => !label.startsWith('_') && !HIDDEN_LABELS.includes(label) && labels[label]) - .map((label) => `${label}=${labels[label]}`), - [labels] - ); +export const LogLabels = memo( + ({ + labels, + emptyMessage, + addTooltip = true, + displayMax, + onDisplayMaxToggle, + displayAll: initialDisplayAll = false, + }: Props) => { + const [displayAll, setDisplayAll] = useState(displayMax ? initialDisplayAll : undefined); + const styles = useStyles2(getStyles); + const allLabels = useMemo( + () => + Object.keys(labels) + .filter((label) => !label.startsWith('_') && !HIDDEN_LABELS.includes(label) && labels[label]) + .map((label) => `${label}=${labels[label]}`), + [labels] + ); + const displayLabels = useMemo( + () => allLabels.slice(0, !displayAll && displayMax ? displayMax : Infinity), + [allLabels, displayAll, displayMax] + ); + + if (displayLabels.length === 0 && emptyMessage) { + return ( + + {emptyMessage} + + ); + } - if (displayLabels.length === 0 && emptyMessage) { return ( - - {emptyMessage} + + {displayLabels.map((labelValue) => { + return addTooltip ? ( + + {labelValue} + + ) : ( + + {labelValue} + + ); + })} + {displayLabels.length < allLabels.length && !displayAll && ( + + )} + {displayAll === true && ( + + )} ); } - - return ( - - {displayLabels.map((labelValue) => { - return addTooltip ? ( - - {labelValue} - - ) : ( - - {labelValue} - - ); - })} - - ); -}); +); LogLabels.displayName = 'LogLabels'; interface LogLabelsArrayProps { @@ -58,7 +105,7 @@ interface LogLabelsArrayProps { export const LogLabelsList = memo(({ labels }: LogLabelsArrayProps) => { const styles = useStyles2(getStyles); return ( - + {labels.map((label) => ( {label === LOG_LINE_BODY_FIELD_NAME ? 'log line' : label} @@ -77,8 +124,8 @@ interface LogLabelProps { const LogLabel = forwardRef(({ styles, tooltip, children }: LogLabelProps, ref) => { return ( - - + + {children} @@ -92,6 +139,7 @@ const getStyles = (theme: GrafanaTheme2) => { display: 'flex', flexWrap: 'wrap', fontSize: theme.typography.size.xs, + alignItems: 'center', }), logsLabel: css({ label: 'logs-label', @@ -103,6 +151,7 @@ const getStyles = (theme: GrafanaTheme2) => { textOverflow: 'ellipsis', whiteSpace: 'nowrap', overflow: 'hidden', + maxHeight: theme.spacing(2), }), logsLabelValue: css({ label: 'logs-label__value', diff --git a/public/app/features/logs/components/panel/LogList.tsx b/public/app/features/logs/components/panel/LogList.tsx index 366293af4ae..f74acfdf181 100644 --- a/public/app/features/logs/components/panel/LogList.tsx +++ b/public/app/features/logs/components/panel/LogList.tsx @@ -47,7 +47,6 @@ interface Props { displayedFields: string[]; eventBus?: EventBus; filterLevels?: LogLevel[]; - forceEscape?: boolean; getFieldLinks?: GetFieldLinksFn; getRowContextQuery?: GetRowContextQueryFn; grammar?: Grammar; @@ -89,7 +88,6 @@ export const LogList = ({ dedupStrategy, eventBus, filterLevels, - forceEscape = false, getFieldLinks, getRowContextQuery, grammar, @@ -143,7 +141,6 @@ export const LogList = ({ { - const { app, displayedFields, filterLevels, showTime, sortOrder, wrapLogMessage } = useLogListContext(); + const { app, displayedFields, filterLevels, forceEscape, showTime, sortOrder, wrapLogMessage } = useLogListContext(); const [processedLogs, setProcessedLogs] = useState([]); const [listHeight, setListHeight] = useState( app === CoreApp.Explore ? window.innerHeight * 0.75 : containerElement.clientHeight @@ -197,7 +193,9 @@ const LogListComponent = ({ }, [eventBus, logs.length]); useEffect(() => { - setProcessedLogs(preProcessLogs(logs, { getFieldLinks, escape: forceEscape, order: sortOrder, timeZone }, grammar)); + setProcessedLogs( + preProcessLogs(logs, { getFieldLinks, escape: forceEscape ?? false, order: sortOrder, timeZone }, grammar) + ); }, [forceEscape, getFieldLinks, grammar, logs, sortOrder, timeZone]); useEffect(() => { diff --git a/public/app/features/logs/components/panel/LogListContext.tsx b/public/app/features/logs/components/panel/LogListContext.tsx index 38de4bfab75..7a1269d28be 100644 --- a/public/app/features/logs/components/panel/LogListContext.tsx +++ b/public/app/features/logs/components/panel/LogListContext.tsx @@ -28,9 +28,11 @@ import { GetRowContextQueryFn } from './LogLineMenu'; export interface LogListContextData extends Omit { downloadLogs: (format: DownloadFormat) => void; filterLevels: LogLevel[]; + hasUnescapedContent?: boolean; setDedupStrategy: (dedupStrategy: LogsDedupStrategy) => void; setDisplayedFields: (displayedFields: string[]) => void; setFilterLevels: (filterLevels: LogLevel[]) => void; + setForceEscape: (forceEscape: boolean) => void; setLogListState: Dispatch>; setPinnedLogs: (pinnedlogs: string[]) => void; setPrettifyJSON: (prettifyJSON: boolean) => void; @@ -47,9 +49,11 @@ export const LogListContext = createContext({ displayedFields: [], downloadLogs: () => {}, filterLevels: [], + hasUnescapedContent: false, setDedupStrategy: () => {}, setDisplayedFields: () => {}, setFilterLevels: () => {}, + setForceEscape: () => {}, setLogListState: () => {}, setPinnedLogs: () => {}, setPrettifyJSON: () => {}, @@ -82,7 +86,9 @@ export type LogListState = Pick< LogListContextData, | 'dedupStrategy' | 'displayedFields' + | 'forceEscape' | 'filterLevels' + | 'hasUnescapedContent' | 'pinnedLogs' | 'prettifyJSON' | 'showUniqueLabels' @@ -98,6 +104,8 @@ export interface Props { dedupStrategy: LogsDedupStrategy; displayedFields: string[]; filterLevels?: LogLevel[]; + forceEscape?: boolean; + hasUnescapedContent?: boolean; getRowContextQuery?: GetRowContextQueryFn; logs: LogRowModel[]; logsMeta?: LogsMetaItem[]; @@ -125,11 +133,13 @@ export const LogListContextProvider = ({ children, dedupStrategy, displayedFields, + filterLevels, + forceEscape = false, + hasUnescapedContent, getRowContextQuery, logs, logsMeta, logOptionsStorageKey, - filterLevels, logSupportsContext, onLogOptionsChange, onLogLineHover, @@ -152,6 +162,8 @@ export const LogListContextProvider = ({ displayedFields, filterLevels: filterLevels ?? (logOptionsStorageKey ? store.getObject(`${logOptionsStorageKey}.filterLevels`, []) : []), + forceEscape, + hasUnescapedContent, pinnedLogs, prettifyJSON, showTime, @@ -205,6 +217,12 @@ export const LogListContextProvider = ({ } }, [filterLevels, logListState]); + useEffect(() => { + if (logListState.hasUnescapedContent !== hasUnescapedContent) { + setLogListState({ ...logListState, hasUnescapedContent }); + } + }, [hasUnescapedContent, logListState]); + const setDedupStrategy = useCallback( (dedupStrategy: LogsDedupStrategy) => { setLogListState({ ...logListState, dedupStrategy }); @@ -221,6 +239,13 @@ export const LogListContextProvider = ({ [logListState, onLogOptionsChange] ); + const setForceEscape = useCallback( + (forceEscape: boolean) => { + setLogListState({ ...logListState, forceEscape }); + }, + [logListState] + ); + const setFilterLevels = useCallback( (filterLevels: LogLevel[]) => { setLogListState({ ...logListState, filterLevels }); @@ -322,6 +347,8 @@ export const LogListContextProvider = ({ displayedFields: logListState.displayedFields, downloadLogs, filterLevels: logListState.filterLevels, + forceEscape: logListState.forceEscape, + hasUnescapedContent: logListState.hasUnescapedContent, getRowContextQuery, logSupportsContext, onLogLineHover, @@ -335,6 +362,7 @@ export const LogListContextProvider = ({ setDedupStrategy, setDisplayedFields, setFilterLevels, + setForceEscape, setLogListState, setPinnedLogs, setPrettifyJSON, diff --git a/public/app/features/logs/components/panel/LogListControls.test.tsx b/public/app/features/logs/components/panel/LogListControls.test.tsx index ec35cf0fa84..2471506d8d5 100644 --- a/public/app/features/logs/components/panel/LogListControls.test.tsx +++ b/public/app/features/logs/components/panel/LogListControls.test.tsx @@ -41,6 +41,10 @@ describe('LogListControls', () => { expect(screen.getByLabelText('Scroll to top')).toBeInTheDocument(); expect(screen.queryByLabelText('Show unique labels')).not.toBeInTheDocument(); expect(screen.queryByLabelText('Expand JSON logs')).not.toBeInTheDocument(); + expect( + screen.queryByLabelText('Fix incorrectly escaped newline and tab sequences in log lines') + ).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Remove escaping')).not.toBeInTheDocument(); }); test('Renders legacy controls', () => { @@ -242,4 +246,19 @@ describe('LogListControls', () => { await userEvent.click(await screen.findByText('txt')); expect(downloadLogs).toHaveBeenCalledWith('text', filteredLogs, undefined); }); + + test('Controls new lines', async () => { + const { rerender } = render( + + + + ); + await userEvent.click(screen.getByLabelText('Fix incorrectly escaped newline and tab sequences in log lines')); + rerender( + + + + ); + await userEvent.click(screen.getByLabelText('Remove escaping')); + }); }); diff --git a/public/app/features/logs/components/panel/LogListControls.tsx b/public/app/features/logs/components/panel/LogListControls.tsx index 2eccad4c4e3..23159176475 100644 --- a/public/app/features/logs/components/panel/LogListControls.tsx +++ b/public/app/features/logs/components/panel/LogListControls.tsx @@ -42,9 +42,12 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props) dedupStrategy, downloadLogs, filterLevels, + forceEscape, + hasUnescapedContent, prettifyJSON, setDedupStrategy, setFilterLevels, + setForceEscape, setPrettifyJSON, setShowTime, setShowUniqueLabels, @@ -76,6 +79,11 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props) ); }, [eventBus]); + const onForceEscapeClick = useCallback(() => { + reportInteraction('logs_log_list_controls_force_escape_clicked'); + setForceEscape(!forceEscape); + }, [forceEscape, setForceEscape]); + const onFilterLevelClick = useCallback( (level?: LogLevel) => { reportInteraction('logs_log_list_controls_level_clicked'); @@ -310,6 +318,23 @@ export const LogListControls = ({ eventBus, visualisationType = 'logs' }: Props) size="lg" /> )} + {hasUnescapedContent && ( + + )} )} {!config.exploreHideLogsDownload && ( diff --git a/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx b/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx index e514463374c..9cff7a3367b 100644 --- a/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx +++ b/public/app/features/logs/components/panel/__mocks__/LogListContext.tsx @@ -13,6 +13,7 @@ export const LogListContext = createContext({ setDedupStrategy: () => {}, setDisplayedFields: () => {}, setFilterLevels: () => {}, + setForceEscape: () => {}, setLogListState: () => {}, setPinnedLogs: () => {}, setPrettifyJSON: () => {}, @@ -57,6 +58,7 @@ export const defaultProps = { setDedupStrategy: jest.fn(), setDisplayedFields: jest.fn(), setFilterLevels: jest.fn(), + setForceEscape: jest.fn(), setLogListState: jest.fn(), setPinnedLogs: jest.fn(), setShowTime: jest.fn(), @@ -108,6 +110,7 @@ export const LogListContextProvider = ({ setDedupStrategy: jest.fn(), setDisplayedFields: jest.fn(), setFilterLevels: jest.fn(), + setForceEscape: jest.fn(), setLogListState: jest.fn(), setPinnedLogs: jest.fn(), setPrettifyJSON: jest.fn(), diff --git a/public/app/features/logs/logsModel.test.ts b/public/app/features/logs/logsModel.test.ts index 69bf270c891..3c7b599c7f6 100644 --- a/public/app/features/logs/logsModel.test.ts +++ b/public/app/features/logs/logsModel.test.ts @@ -348,6 +348,11 @@ describe('dataFrameToLogsModel', () => { ]); expect(logsModel.meta).toHaveLength(2); expect(logsModel.meta![0]).toMatchObject({ + label: '', + value: `2 lines returned`, + kind: LogsMetaKind.String, + }); + expect(logsModel.meta![1]).toMatchObject({ label: COMMON_LABELS, value: { filename: '/var/log/grafana/grafana.log', @@ -355,11 +360,6 @@ describe('dataFrameToLogsModel', () => { }, kind: LogsMetaKind.LabelsMap, }); - expect(logsModel.meta![1]).toMatchObject({ - label: '', - value: `2 lines returned`, - kind: LogsMetaKind.String, - }); }); it('given one series should return expected logs model with detected_level', () => { @@ -430,17 +430,17 @@ describe('dataFrameToLogsModel', () => { ]); expect(logsModel.meta).toHaveLength(2); expect(logsModel.meta![0]).toMatchObject({ + label: '', + value: `2 lines returned`, + kind: LogsMetaKind.String, + }); + expect(logsModel.meta![1]).toMatchObject({ label: COMMON_LABELS, value: { job: 'grafana', }, kind: LogsMetaKind.LabelsMap, }); - expect(logsModel.meta![1]).toMatchObject({ - label: '', - value: `2 lines returned`, - kind: LogsMetaKind.String, - }); }); it('with infinite scrolling enabled it should return expected logs model', () => { @@ -485,7 +485,7 @@ describe('dataFrameToLogsModel', () => { it('given one series with limit as custom meta property should return correct limit', () => { const series: DataFrame[] = getTestDataFrame(); const logsModel = dataFrameToLogsModel(series, 1); - expect(logsModel.meta![1]).toMatchObject({ + expect(logsModel.meta![0]).toMatchObject({ label: '', value: `2 lines returned`, kind: LogsMetaKind.String, @@ -516,16 +516,16 @@ describe('dataFrameToLogsModel', () => { queries ); expect(logsModel.meta).toEqual([ - { - label: 'Common labels', - value: { filename: '/var/log/grafana/grafana.log', job: 'grafana' }, - kind: 2, - }, { label: '', value: '2 lines shown — 21.85% (5h 14min 40sec) of 24h', kind: 1, }, + { + label: 'Common labels', + value: { filename: '/var/log/grafana/grafana.log', job: 'grafana' }, + kind: 2, + }, ]); }); @@ -554,16 +554,16 @@ describe('dataFrameToLogsModel', () => { queries ); expect(logsModel.meta).toEqual([ - { - label: 'Common labels', - value: { filename: '/var/log/grafana/grafana.log', job: 'grafana' }, - kind: 2, - }, { label: '', value: '2 reached', kind: 1, }, + { + label: 'Common labels', + value: { filename: '/var/log/grafana/grafana.log', job: 'grafana' }, + kind: 2, + }, ]); }); @@ -649,15 +649,15 @@ describe('dataFrameToLogsModel', () => { ]); expect(logsModel.meta).toHaveLength(2); expect(logsModel.meta![0]).toMatchObject({ - label: COMMON_LABELS, - value: { filename: '/var/log/grafana/grafana.log', job: 'grafana' }, - kind: LogsMetaKind.LabelsMap, - }); - expect(logsModel.meta![1]).toMatchObject({ label: '', value: `2 lines returned`, kind: LogsMetaKind.String, }); + expect(logsModel.meta![1]).toMatchObject({ + label: COMMON_LABELS, + value: { filename: '/var/log/grafana/grafana.log', job: 'grafana' }, + kind: LogsMetaKind.LabelsMap, + }); }); it('given one series with labels-field it should work regardless the label-fields position', () => { @@ -768,20 +768,20 @@ describe('dataFrameToLogsModel', () => { expect(logsModel.series).toHaveLength(2); expect(logsModel.meta).toHaveLength(3); expect(logsModel.meta![0]).toMatchObject({ - label: COMMON_LABELS, - value: series[0].fields[1].labels, - kind: LogsMetaKind.LabelsMap, - }); - expect(logsModel.meta![1]).toMatchObject({ label: '', value: `2 lines returned`, kind: LogsMetaKind.String, }); - expect(logsModel.meta![2]).toMatchObject({ + expect(logsModel.meta![1]).toMatchObject({ label: '', value: 'Error when parsing some of the logs', kind: LogsMetaKind.Error, }); + expect(logsModel.meta![2]).toMatchObject({ + label: COMMON_LABELS, + value: series[0].fields[1].labels, + kind: LogsMetaKind.LabelsMap, + }); }); it('given one series without labels should return expected logs model', () => { @@ -1110,15 +1110,15 @@ describe('dataFrameToLogsModel', () => { const logsModel = dataFrameToLogsModel(series, 1, { from: 1556270591353, to: 1556289770991 }); expect(logsModel.meta).toHaveLength(2); expect(logsModel.meta![0]).toMatchObject({ - label: COMMON_LABELS, - value: series[0].fields[1].labels, - kind: LogsMetaKind.LabelsMap, - }); - expect(logsModel.meta![1]).toMatchObject({ label: '', value: `2 lines shown — 98.44% (5h 14min 40sec) of 5h 19min 40sec`, kind: LogsMetaKind.String, }); + expect(logsModel.meta![1]).toMatchObject({ + label: COMMON_LABELS, + value: series[0].fields[1].labels, + kind: LogsMetaKind.LabelsMap, + }); }); it('should fallback to row index if no id', () => { @@ -1271,9 +1271,9 @@ describe('logSeriesToLogsModel', () => { const logsModel = dataFrameToLogsModel(logSeries, 0); expect(logsModel.meta).toMatchObject([ - { kind: 2, label: COMMON_LABELS, value: { foo: 'bar', level: 'dbug' } }, { kind: 0, label: LIMIT_LABEL, value: 2000 }, { kind: 1, label: 'Total bytes processed', value: '194 kB' }, + { kind: 2, label: COMMON_LABELS, value: { foo: 'bar', level: 'dbug' } }, ]); expect(logsModel.rows).toHaveLength(3); expect(logsModel.rows).toMatchObject([ diff --git a/public/app/features/logs/logsModel.ts b/public/app/features/logs/logsModel.ts index 12aebd4368e..7e75684d423 100644 --- a/public/app/features/logs/logsModel.ts +++ b/public/app/features/logs/logsModel.ts @@ -473,13 +473,6 @@ export function logSeriesToLogsModel( // Meta data to display in status const meta: LogsMetaItem[] = []; - if (size(commonLabels) > 0) { - meta.push({ - label: COMMON_LABELS, - value: commonLabels, - kind: LogsMetaKind.LabelsMap, - }); - } // Data sources that set up searchWords on backend use meta.custom.limit. // Data sources that set up searchWords through frontend can use meta.limit. const limits = logSeries.filter((series) => series?.meta?.custom?.limit ?? series?.meta?.limit); @@ -538,6 +531,14 @@ export function logSeriesToLogsModel( }); } + if (size(commonLabels) > 0) { + meta.push({ + label: COMMON_LABELS, + value: commonLabels, + kind: LogsMetaKind.LabelsMap, + }); + } + return { hasUniqueLabels, meta, diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index de8e8e8e608..b40b2e2a119 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -5269,6 +5269,10 @@ "tooltip-show-log-line": "Show log line" } }, + "log-labels": { + "collapse": "Collapse labels", + "expand": "Expand labels" + }, "log-line-menu": { "copy-link": "Copy link to log line", "copy-log": "Copy log line", @@ -5321,11 +5325,13 @@ "txt": "txt" }, "enable-highlighting": "Enable highlighting", + "escape-newlines": "Fix incorrectly escaped newline and tab sequences in log lines", "hide-timestamps": "Hide timestamps", "hide-unique-labels": "Hide unique labels", "newest-first": "Newest logs first", "oldest-first": "Oldest logs first", "prettify-json": "Expand JSON logs", + "remove-escaping": "Remove escaping", "scroll-bottom": "Scroll to bottom", "scroll-top": "Scroll to top", "show-timestamps": "Show timestamps",