New logs Panel: Log details improvements + default details value update (#108065)

* LogLineDetails: prettify json field values

* processing: prettify json according to wrap status

* LogLineDetailsField: better column width for keys

* LogsPanel: let app have automatic details mode

* Fix prop name

* LogLineDetails: memoize

* LogLineDisplayedFields: add icons to organize

* Displayed fields: add cursor move

* LogLineDetailsFields: improve key columns

* Remove test

* Translations

* Formatting

* LogLineDetailsFields: minor refactor and fix

* LogLineDetails: remember inline scroll position

* LogLineDetails: test displayed fields reorg

* Update mock

* LogLineDetailsComponent: add missing disable actions

* Log details copy button: gap = 0

* LogLineDetailsDisplayedFields: use fixed size for inline fields
This commit is contained in:
Matias Chomicki
2025-07-15 16:17:20 +00:00
committed by GitHub
parent 14cb6aea92
commit b839090de5
12 changed files with 207 additions and 59 deletions
@@ -83,6 +83,7 @@ const getStyles = memoizeOne((theme: GrafanaTheme2) => {
}),
copyButton: css({
'& > button': {
gap: 0,
color: theme.colors.text.secondary,
padding: 0,
justifyContent: 'center',
@@ -490,5 +490,29 @@ describe('LogLineDetails', () => {
expect(onClickHideField).toHaveBeenCalledWith('key1');
});
test('Exposes buttons to reorder displayed fields', async () => {
const setDisplayedFields = jest.fn();
const onClickHideField = jest.fn();
setup(
undefined,
{ labels: { key1: 'label1', key2: 'label2' } },
{ displayedFields: ['key1', 'key2', 'key3'], setDisplayedFields, onClickHideField }
);
await userEvent.click(screen.getByText('Organize displayed fields'));
expect(screen.getAllByLabelText('Remove field')).toHaveLength(3);
expect(screen.getAllByLabelText('Move down')).toHaveLength(3);
expect(screen.getAllByLabelText('Move up')).toHaveLength(3);
await userEvent.click(screen.getAllByLabelText('Move down')[0]);
expect(setDisplayedFields).toHaveBeenCalledWith(['key2', 'key1', 'key3']);
await userEvent.click(screen.getAllByLabelText('Move up')[2]);
expect(setDisplayedFields).toHaveBeenCalledWith(['key1', 'key3', 'key2']);
});
});
});
@@ -1,12 +1,12 @@
import { css } from '@emotion/css';
import { Resizable } from 're-resizable';
import { useCallback, useEffect, useRef } from 'react';
import { memo, useCallback, useEffect, useRef } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { getDragStyles, useStyles2 } from '@grafana/ui';
import { LogLineDetailsComponent } from './LogLineDetailsComponent';
import { useLogListContext } from './LogListContext';
import { getDetailsScrollPosition, saveDetailsScrollPosition, useLogListContext } from './LogListContext';
import { LogListModel } from './processing';
import { LOG_LIST_MIN_WIDTH } from './virtualization';
@@ -67,9 +67,21 @@ export interface InlineLogLineDetailsProps {
logs: LogListModel[];
}
export const InlineLogLineDetails = ({ logs }: InlineLogLineDetailsProps) => {
export const InlineLogLineDetails = memo(({ logs }: InlineLogLineDetailsProps) => {
const { showDetails } = useLogListContext();
const styles = useStyles2(getStyles, 'inline');
const scrollRef = useRef<HTMLDivElement | null>(null);
const saveScroll = useCallback(() => {
saveDetailsScrollPosition(showDetails[0], scrollRef.current?.scrollTop ?? 0);
}, [showDetails]);
useEffect(() => {
if (!scrollRef.current) {
return;
}
scrollRef.current.scrollTop = getDetailsScrollPosition(showDetails[0]);
}, [showDetails]);
if (!showDetails.length) {
return null;
@@ -78,13 +90,14 @@ export const InlineLogLineDetails = ({ logs }: InlineLogLineDetailsProps) => {
return (
<div className={`${styles.inlineWrapper} log-line-inline-details`}>
<div className={styles.container}>
<div className={styles.scrollContainer}>
<div className={styles.scrollContainer} ref={scrollRef} onScroll={saveScroll}>
<LogLineDetailsComponent log={showDetails[0]} logs={logs} />
</div>
</div>
</div>
);
};
});
InlineLogLineDetails.displayName = 'InlineLogLineDetails';
export const LOG_LINE_DETAILS_HEIGHT = 35;
@@ -1,6 +1,6 @@
import { css } from '@emotion/css';
import { camelCase, groupBy } from 'lodash';
import { startTransition, useCallback, useMemo, useRef, useState } from 'react';
import { memo, startTransition, useCallback, useMemo, useRef, useState } from 'react';
import { DataFrameType, GrafanaTheme2, store } from '@grafana/data';
import { t, Trans } from '@grafana/i18n';
@@ -22,7 +22,7 @@ interface LogLineDetailsComponentProps {
logs: LogListModel[];
}
export const LogLineDetailsComponent = ({ log, logs }: LogLineDetailsComponentProps) => {
export const LogLineDetailsComponent = memo(({ log, logs }: LogLineDetailsComponentProps) => {
const { displayedFields, logOptionsStorageKey, setDisplayedFields } = useLogListContext();
const [search, setSearch] = useState('');
const inputRef = useRef('');
@@ -123,7 +123,7 @@ export const LogLineDetailsComponent = ({ log, logs }: LogLineDetailsComponentPr
isOpen={linksOpen}
onToggle={(isOpen: boolean) => handleToggle('linksOpen', isOpen)}
>
<LogLineDetailsFields log={log} logs={logs} fields={fieldsWithLinks.links} search={search} />
<LogLineDetailsFields disableActions log={log} logs={logs} fields={fieldsWithLinks.links} search={search} />
<LogLineDetailsFields
disableActions
log={log}
@@ -175,7 +175,8 @@ export const LogLineDetailsComponent = ({ log, logs }: LogLineDetailsComponentPr
</div>
</>
);
};
});
LogLineDetailsComponent.displayName = 'LogLineDetailsComponent';
function groupOptionName(group: string) {
return `${camelCase(group)}Open`;
@@ -1,5 +1,5 @@
import { css } from '@emotion/css';
import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd';
import { DragDropContext, Draggable, DraggableProvided, Droppable, DropResult } from '@hello-pangea/dnd';
import { useCallback } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
@@ -8,41 +8,52 @@ import { Card, IconButton, useStyles2 } from '@grafana/ui';
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { LogLineDetailsMode } from './LogLineDetails';
import { useLogListContext } from './LogListContext';
export const LogLineDetailsDisplayedFields = () => {
const { displayedFields, setDisplayedFields } = useLogListContext();
const onDragEnd = useCallback(
(result: DropResult) => {
if (result.destination == null) {
return;
}
const reorganizeDisplayedFields = useCallback(
(srcIndex: number, destIndex: number) => {
const newDisplayedFields = [...displayedFields];
const element = displayedFields[result.source.index];
newDisplayedFields.splice(result.source.index, 1);
newDisplayedFields.splice(result.destination.index, 0, element);
const element = displayedFields[srcIndex];
newDisplayedFields.splice(srcIndex, 1);
newDisplayedFields.splice(destIndex, 0, element);
setDisplayedFields?.(newDisplayedFields);
},
[displayedFields, setDisplayedFields]
);
const onDragEnd = useCallback(
(result: DropResult) => {
if (result.destination == null) {
return;
}
reorganizeDisplayedFields(result.source.index, result.destination.index);
},
[reorganizeDisplayedFields]
);
return (
<div>
<DragDropContext onDragEnd={onDragEnd}>
<Droppable ignoreContainerClipping={true} droppableId="displayed-fields" direction="vertical">
<Droppable droppableId="displayed-fields" direction="vertical">
{(provided) => {
return (
<>
<div ref={provided.innerRef} {...provided.droppableProps}>
{displayedFields.map((field, index) => (
<DraggableDisplayedField key={field} field={field} index={index} />
))}
</div>
<div ref={provided.innerRef} {...provided.droppableProps}>
{displayedFields.map((field, index) => (
<DraggableDisplayedField
key={field}
field={field}
index={index}
moveField={reorganizeDisplayedFields}
/>
))}
{provided.placeholder}
</>
</div>
);
}}
</Droppable>
@@ -54,38 +65,80 @@ export const LogLineDetailsDisplayedFields = () => {
interface DraggableDisplayedFieldProps {
field: string;
index: number;
moveField: (srcIndex: number, destIndex: number) => void;
}
const DraggableDisplayedField = ({ field, index }: DraggableDisplayedFieldProps) => {
const { onClickHideField } = useLogListContext();
const styles = useStyles2(getStyles);
const DraggableDisplayedField = ({ field, index, moveField }: DraggableDisplayedFieldProps) => {
return (
<Draggable draggableId={field} index={index}>
{(provided) => (
<div ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps}>
<Card noMargin className={styles.fieldCard}>
<div>
{field === LOG_LINE_BODY_FIELD_NAME ? t('logs.log-line-details.log-line-field', 'Log line') : field}
</div>
{onClickHideField && (
<IconButton
name="times"
onClick={() => onClickHideField(field)}
tooltip={t('logs.log-line-details.remove-displayed-field', 'Remove field')}
/>
)}
</Card>
</div>
<DisplayedField key={field} field={field} index={index} moveField={moveField} provided={provided} />
)}
</Draggable>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
const DisplayedField = ({
field,
index,
moveField,
provided,
}: DraggableDisplayedFieldProps & { provided: DraggableProvided }) => {
const { detailsMode, displayedFields, onClickHideField } = useLogListContext();
const styles = useStyles2(getStyles, detailsMode);
const nextIndex = index === displayedFields.length - 1 ? 0 : index + 1;
const prevIndex = index === 0 ? displayedFields.length - 1 : index - 1;
return (
<div ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps}>
<Card noMargin className={styles.fieldCard}>
<div className={styles.fieldWrapper}>
<div className={styles.field}>
{field === LOG_LINE_BODY_FIELD_NAME ? t('logs.log-line-details.log-line-field', 'Log line') : field}
</div>
{displayedFields.length > 1 && (
<>
<IconButton
name="arrow-down"
onClick={() => moveField(index, nextIndex)}
tooltip={t('logs.log-line-details.move-displayed-field-down', 'Move down')}
/>
<IconButton
name="arrow-up"
onClick={() => moveField(index, prevIndex)}
tooltip={t('logs.log-line-details.move-displayed-field-up', 'Move up')}
/>
</>
)}
{onClickHideField && (
<IconButton
name="times"
onClick={() => onClickHideField(field)}
tooltip={t('logs.log-line-details.remove-displayed-field', 'Remove field')}
/>
)}
</div>
</Card>
</div>
);
};
const getStyles = (theme: GrafanaTheme2, detailsMode: LogLineDetailsMode) => ({
fieldCard: css({
cursor: 'move',
display: 'block',
padding: theme.spacing(1),
marginBottom: theme.spacing(1),
width: detailsMode === 'inline' ? '30vw' : undefined,
overflow: 'hidden',
textOverflow: 'ellipsis',
wordBreak: 'break-word',
}),
fieldWrapper: css({
cursor: 'move',
display: 'flex',
gap: theme.spacing(0.5),
justifyContent: 'space-evenly',
}),
field: css({
flex: 1,
}),
});
@@ -1,7 +1,7 @@
import { css } from '@emotion/css';
import { isEqual } from 'lodash';
import { useCallback, useEffect, useMemo, useState } from 'react';
import * as React from 'react';
import { parse, stringify } from 'lossless-json';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { CoreApp, Field, fuzzySearch, GrafanaTheme2, IconName, LinkModel, LogLabelStatsModel } from '@grafana/data';
import { t } from '@grafana/i18n';
@@ -24,7 +24,7 @@ interface LogLineDetailsFieldsProps {
search?: string;
}
export const LogLineDetailsFields = ({ disableActions, fields, log, logs, search }: LogLineDetailsFieldsProps) => {
export const LogLineDetailsFields = memo(({ disableActions, fields, log, logs, search }: LogLineDetailsFieldsProps) => {
if (!fields.length) {
return null;
}
@@ -52,7 +52,8 @@ export const LogLineDetailsFields = ({ disableActions, fields, log, logs, search
))}
</div>
);
};
});
LogLineDetailsFields.displayName = 'LogLineDetailsFields';
interface LinkModelWithIcon extends LinkModel<Field> {
icon?: IconName;
@@ -104,12 +105,12 @@ const getFieldsStyles = (theme: GrafanaTheme2) => ({
fieldsTable: css({
display: 'grid',
gap: theme.spacing(1),
gridTemplateColumns: `${theme.spacing(11.5)} minmax(15%, 30%) 1fr`,
gridTemplateColumns: `${theme.spacing(11.5)} auto 1fr`,
}),
fieldsTableNoActions: css({
display: 'grid',
gap: theme.spacing(1),
gridTemplateColumns: `minmax(15%, 30%) 1fr`,
gridTemplateColumns: `auto 1fr`,
}),
});
@@ -148,6 +149,7 @@ export const LogLineDetailsField = ({
onClickHideField,
onPinLine,
pinLineButtonTooltipTitle,
syntaxHighlighting,
} = useLogListContext();
const styles = useStyles2(getFieldStyles);
@@ -309,8 +311,11 @@ export const LogLineDetailsField = ({
<div className={styles.label}>{singleKey ? keys[0] : <MultipleValue values={keys} />}</div>
<div className={styles.value}>
<div className={styles.valueContainer}>
{singleValue ? values[0] : <MultipleValue showCopy={true} values={values} />}
{singleValue && <ClipboardButtonWrapper value={values[0]} />}
{singleValue ? (
<SingleValue value={values[0]} syntaxHighlighting={syntaxHighlighting} />
) : (
<MultipleValue showCopy={true} values={values} />
)}
</div>
</div>
</div>
@@ -406,10 +411,11 @@ const getFieldStyles = (theme: GrafanaTheme2) => ({
}),
valueContainer: css({
display: 'flex',
alignItems: 'center',
lineHeight: theme.typography.body.lineHeight,
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
maxHeight: '50vh',
overflow: 'auto',
}),
});
@@ -433,6 +439,7 @@ const getClipboardButtonStyles = (theme: GrafanaTheme2) => ({
button: css({
'& > button': {
color: theme.colors.text.secondary,
gap: 0,
padding: 0,
justifyContent: 'center',
borderRadius: theme.shape.radius.circle,
@@ -472,6 +479,28 @@ const MultipleValue = ({ showCopy, values = [] }: { showCopy?: boolean; values:
);
};
const SingleValue = ({ value: originalValue, syntaxHighlighting }: { value: string; syntaxHighlighting?: boolean }) => {
const value = useMemo(() => {
if (!syntaxHighlighting) {
return originalValue;
}
try {
const parsed = stringify(parse(originalValue), undefined, 2);
if (parsed) {
return parsed;
}
} catch (error) {}
return originalValue;
}, [originalValue, syntaxHighlighting]);
return (
<>
{value}
<ClipboardButtonWrapper value={value} />
</>
);
};
interface AsyncIconButtonProps extends Pick<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onClick'> {
name: IconName;
isActive(): Promise<boolean>;
@@ -442,8 +442,11 @@ export const LogListContextProvider = ({
);
const closeDetails = useCallback(() => {
if (showDetails.length) {
removeDetailsScrollPosition(showDetails[0]);
}
setShowDetails([]);
}, []);
}, [showDetails]);
const toggleDetails = useCallback(
(log: LogListModel) => {
@@ -585,3 +588,17 @@ function getDetailsWidth(
}
return detailsWidth;
}
const detailsScrollMap = new Map<string, number>();
export function saveDetailsScrollPosition(log: LogListModel, position: number) {
detailsScrollMap.set(log.uid, position);
}
export function getDetailsScrollPosition(log: LogListModel) {
return detailsScrollMap.get(log.uid) ?? 0;
}
export function removeDetailsScrollPosition(log: LogListModel) {
detailsScrollMap.delete(log.uid);
}
@@ -191,3 +191,9 @@ export const LogListContextProvider = ({
</LogListContext.Provider>
);
};
export const saveDetailsScrollPosition = jest.fn();
export const getDetailsScrollPosition = jest.fn();
export const removeDetailsScrollPosition = jest.fn();
@@ -110,7 +110,7 @@ export class LogListModel implements LogRowModel {
get body(): string {
if (this._body === undefined) {
try {
const parsed = stringify(parse(this.raw), undefined, 2);
const parsed = stringify(parse(this.raw), undefined, this._wrapLogMessage ? 2 : 1);
if (parsed) {
this.raw = parsed;
}
+4 -1
View File
@@ -159,7 +159,7 @@ export const LogsPanel = ({
onNewLogsReceived,
fontSize,
syntaxHighlighting,
detailsMode,
detailsMode: detailsModeProp,
...options
},
id,
@@ -518,6 +518,9 @@ export const LogsPanel = ({
const onClickShowField = isOnClickShowField(options.onClickShowField) ? options.onClickShowField : showField;
const onClickHideField = isOnClickHideField(options.onClickHideField) ? options.onClickHideField : hideField;
// In Dashboards, default to inline. Otherwise, let apps control or have automatic behavior.
const detailsMode = detailsModeProp ? detailsModeProp : app === CoreApp.Dashboard ? 'inline' : undefined;
return (
<>
{contextRow && (
-1
View File
@@ -123,7 +123,6 @@ export const plugin = new PanelPlugin<Options>(LogsPanel)
},
],
},
defaultValue: 'inline',
});
}
+2
View File
@@ -8811,6 +8811,8 @@
"links-section": "Links",
"log-line-field": "Log line",
"log-line-section": "Log line",
"move-displayed-field-down": "Move down",
"move-displayed-field-up": "Move up",
"no-details": "No fields to display.",
"pin-line": "Pin log",
"remove-displayed-field": "Remove field",