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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -123,7 +123,6 @@ export const plugin = new PanelPlugin<Options>(LogsPanel)
|
||||
},
|
||||
],
|
||||
},
|
||||
defaultValue: 'inline',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user