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
This commit is contained in:
Matias Chomicki
2025-04-10 19:47:17 +01:00
committed by GitHub
parent 828cd05f18
commit 778d0b2da4
16 changed files with 382 additions and 192 deletions
+1 -11
View File
@@ -209,7 +209,6 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
);
const [isFlipping, setIsFlipping] = useState<boolean>(false);
const [displayedFields, setDisplayedFields] = useState<string[]>([]);
const [forceEscape, setForceEscape] = useState<boolean>(false);
const [contextOpen, setContextOpen] = useState<boolean>(false);
const [contextRow, setContextRow] = useState<LogRowModel | undefined>(undefined);
const [pinLineButtonTooltipTitle, setPinLineButtonTooltipTitle] = useState<PopoverContent>(PINNED_LOGS_MESSAGE);
@@ -497,10 +496,6 @@ const UnthemedLogs: React.FunctionComponent<Props> = (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: Props) => {
meta={logsMeta || []}
dedupStrategy={dedupStrategy}
dedupCount={dedupCount}
hasUnescapedContent={hasUnescapedContent}
forceEscape={forceEscape}
displayedFields={displayedFields}
onEscapeNewlines={onEscapeNewlines}
clearDetectedFields={clearDetectedFields}
/>
</div>
@@ -1051,7 +1043,6 @@ const UnthemedLogs: React.FunctionComponent<Props> = (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: Props) => {
logsMeta={logsMeta}
logOptionsStorageKey={SETTING_KEY_ROOT}
onLogOptionsChange={onLogOptionsChange}
hasUnescapedContent={hasUnescapedContent}
/>
</div>
)}
@@ -1110,7 +1102,6 @@ const UnthemedLogs: React.FunctionComponent<Props> = (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: Props) => {
dedupStrategy={dedupStrategy}
displayedFields={displayedFields}
filterLevels={filterLevels}
forceEscape={forceEscape}
getFieldLinks={getFieldLinks}
getRowContextQuery={getRowContextQuery}
loadMore={loadMoreLogs}
@@ -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();
});
});
@@ -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<LogsMetaItem | MetaItemProps> = [...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: (
<Tooltip
content="Fix incorrectly escaped newline and tab sequences in log lines. Manually review the results to confirm that the replacements are correct."
placement="right"
>
<Button variant="secondary" size="sm" onClick={onEscapeNewlines}>
{forceEscape ? 'Remove escaping' : 'Escape newlines'}
</Button>
</Tooltip>
),
});
}
const downloadMenu = (
<Menu>
{/* eslint-disable-next-line @grafana/no-untranslated-strings */}
@@ -117,6 +85,17 @@ export const LogsMetaRow = memo(
<Menu.Item label="csv" onClick={() => download(DownloadFormat.CSV)} />
</Menu>
);
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<LogLabelsProps>) {
if (typeof value === 'string' || typeof value === 'number') {
return <>{value}</>;
}
if (kind === LogsMetaKind.LabelsMap) {
return <LogLabels labels={value} />;
return <LogLabels labels={value} {...logLabelsProps} />;
}
if (kind === LogsMetaKind.Error) {
return <span className="logs-meta-item__error">{value.toString()}</span>;
@@ -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';
@@ -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,
@@ -25,6 +25,7 @@ import { LogListControls } from './panel/LogListControls';
import { ScrollToLogsEvent } from './panel/virtualization';
export interface ControlledLogRowsProps extends Omit<Props, 'scrollElement'> {
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<HTMLDivElement | null>(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}
@@ -35,6 +35,60 @@ describe('<LogLabels />', () => {
await userEvent.hover(screen.getByText('foo=bar'));
expect(screen.getAllByText('foo=bar')).toHaveLength(1);
});
describe('displayMax', () => {
it('renders up to displayMax labels', () => {
render(<LogLabels labels={{ foo: 'bar', baz: '42' }} displayMax={1} />);
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(
<LogLabels labels={{ foo: 'bar', baz: '42' }} displayMax={1} onDisplayMaxToggle={onDisplayMaxToggle} />
);
await userEvent.click(screen.getByLabelText('Expand labels'));
expect(onDisplayMaxToggle).toHaveBeenCalledTimes(1);
expect(onDisplayMaxToggle).toHaveBeenCalledWith(true);
rerender(<LogLabels labels={{ foo: 'bar', baz: '42' }} displayMax={1} onDisplayMaxToggle={onDisplayMaxToggle} />);
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(
<LogLabels
labels={{ foo: 'bar', baz: '42' }}
displayMax={1}
displayAll
onDisplayMaxToggle={onDisplayMaxToggle}
/>
);
await userEvent.click(screen.getByLabelText('Collapse labels'));
expect(onDisplayMaxToggle).toHaveBeenCalledTimes(1);
expect(onDisplayMaxToggle).toHaveBeenCalledWith(false);
rerender(
<LogLabels
labels={{ foo: 'bar', baz: '42' }}
displayMax={1}
displayAll
onDisplayMaxToggle={onDisplayMaxToggle}
/>
);
expect(screen.getByText('foo=bar')).toBeInTheDocument();
expect(screen.getByLabelText('Expand labels')).toBeInTheDocument();
expect(screen.queryByText('baz=42')).not.toBeInTheDocument();
});
});
});
describe('<LogLabelsList />', () => {
@@ -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<boolean | undefined>(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 (
<span className={styles.logsLabels}>
<span className={styles.logsLabel}>{emptyMessage}</span>
</span>
);
}
if (displayLabels.length === 0 && emptyMessage) {
return (
<span className={cx([styles.logsLabels])}>
<span className={cx([styles.logsLabel])}>{emptyMessage}</span>
<span className={styles.logsLabels}>
{displayLabels.map((labelValue) => {
return addTooltip ? (
<Tooltip content={labelValue} key={labelValue} placement="top">
<LogLabel styles={styles}>{labelValue}</LogLabel>
</Tooltip>
) : (
<LogLabel styles={styles} tooltip={labelValue} key={labelValue}>
{labelValue}
</LogLabel>
);
})}
{displayLabels.length < allLabels.length && !displayAll && (
<Button
size="sm"
fill="outline"
variant="secondary"
aria-label={t('logs.log-labels.expand', 'Expand labels')}
onClick={() => {
setDisplayAll(true);
onDisplayMaxToggle?.(true);
}}
>
<Icon name="plus" size="xs" />
{allLabels.length - displayLabels.length}
</Button>
)}
{displayAll === true && (
<Button
size="sm"
fill="outline"
variant="secondary"
aria-label={t('logs.log-labels.collapse', 'Collapse labels')}
onClick={() => {
setDisplayAll(false);
onDisplayMaxToggle?.(false);
}}
>
<Icon name="minus" size="xs" />
</Button>
)}
</span>
);
}
return (
<span className={cx([styles.logsLabels])}>
{displayLabels.map((labelValue) => {
return addTooltip ? (
<Tooltip content={labelValue} key={labelValue} placement="top">
<LogLabel styles={styles}>{labelValue}</LogLabel>
</Tooltip>
) : (
<LogLabel styles={styles} tooltip={labelValue} key={labelValue}>
{labelValue}
</LogLabel>
);
})}
</span>
);
});
);
LogLabels.displayName = 'LogLabels';
interface LogLabelsArrayProps {
@@ -58,7 +105,7 @@ interface LogLabelsArrayProps {
export const LogLabelsList = memo(({ labels }: LogLabelsArrayProps) => {
const styles = useStyles2(getStyles);
return (
<span className={cx([styles.logsLabels])}>
<span className={styles.logsLabels}>
{labels.map((label) => (
<LogLabel key={label} styles={styles} tooltip={label}>
{label === LOG_LINE_BODY_FIELD_NAME ? 'log line' : label}
@@ -77,8 +124,8 @@ interface LogLabelProps {
const LogLabel = forwardRef<HTMLSpanElement, LogLabelProps>(({ styles, tooltip, children }: LogLabelProps, ref) => {
return (
<span className={cx([styles.logsLabel])} ref={ref}>
<span className={cx([styles.logsLabelValue])} title={tooltip}>
<span className={styles.logsLabel} ref={ref}>
<span className={styles.logsLabelValue} title={tooltip}>
{children}
</span>
</span>
@@ -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',
@@ -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 = ({
<LogListComponent
containerElement={containerElement}
eventBus={eventBus}
forceEscape={forceEscape}
getFieldLinks={getFieldLinks}
grammar={grammar}
initialScrollPosition={initialScrollPosition}
@@ -160,7 +157,6 @@ export const LogList = ({
const LogListComponent = ({
containerElement,
eventBus = new EventBusSrv(),
forceEscape = false,
getFieldLinks,
grammar,
initialScrollPosition = 'top',
@@ -170,7 +166,7 @@ const LogListComponent = ({
timeRange,
timeZone,
}: LogListComponentProps) => {
const { app, displayedFields, filterLevels, showTime, sortOrder, wrapLogMessage } = useLogListContext();
const { app, displayedFields, filterLevels, forceEscape, showTime, sortOrder, wrapLogMessage } = useLogListContext();
const [processedLogs, setProcessedLogs] = useState<LogListModel[]>([]);
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(() => {
@@ -28,9 +28,11 @@ import { GetRowContextQueryFn } from './LogLineMenu';
export interface LogListContextData extends Omit<Props, 'logs' | 'logsMeta' | 'showControls'> {
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<SetStateAction<LogListState>>;
setPinnedLogs: (pinnedlogs: string[]) => void;
setPrettifyJSON: (prettifyJSON: boolean) => void;
@@ -47,9 +49,11 @@ export const LogListContext = createContext<LogListContextData>({
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,
@@ -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(
<LogListContextProvider {...contextProps} hasUnescapedContent>
<LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider>
);
await userEvent.click(screen.getByLabelText('Fix incorrectly escaped newline and tab sequences in log lines'));
rerender(
<LogListContextProvider {...contextProps} hasUnescapedContent>
<LogListControls eventBus={new EventBusSrv()} />
</LogListContextProvider>
);
await userEvent.click(screen.getByLabelText('Remove escaping'));
});
});
@@ -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 && (
<IconButton
name="enter"
aria-pressed={forceEscape}
className={forceEscape ? styles.controlButtonActive : styles.controlButton}
onClick={onForceEscapeClick}
tooltip={
forceEscape
? t('logs.logs-controls.remove-escaping', 'Remove escaping')
: t(
'logs.logs-controls.escape-newlines',
'Fix incorrectly escaped newline and tab sequences in log lines'
)
}
size="lg"
/>
)}
</>
)}
{!config.exploreHideLogsDownload && (
@@ -13,6 +13,7 @@ export const LogListContext = createContext<LogListContextData>({
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(),
+38 -38
View File
@@ -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([
+8 -7
View File
@@ -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,
+6
View File
@@ -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",