New Log Details: Add support to sort displayed fields (#107635)

* Create LogLineDetailsDisplayedFields

* Log labels: properly implement plurals

* Pluralize

* LogListContext: pass setDisplayedFields

* LogLineDetailsDisplayedFields: update displayed fields

* LogLineDetails: scroll to item after opening details

* LogListContext: serve logOptionsStorageKey

* Update test

* Missing translations

* LogLineDetailsDisplayedFields: update styles

* LogLineDetails: update text selectors

* Update tests

* Reorganize log details: improve discoverability

* Update test
This commit is contained in:
Matias Chomicki
2025-07-08 11:57:28 +02:00
committed by GitHub
parent 509990ca89
commit 89e8a03859
10 changed files with 197 additions and 30 deletions
@@ -1144,6 +1144,7 @@ const UnthemedLogs: React.FunctionComponent<Props> = (props: Props) => {
permalinkedLogId={panelState?.logs?.id}
pinLineButtonTooltipTitle={pinLineButtonTooltipTitle}
pinnedLogs={pinnedLogs}
setDisplayedFields={setDisplayedFields}
showControls
showTime={showTime}
sortOrder={logsSortOrder}
@@ -40,6 +40,7 @@ const setup = (
const props: Props = {
containerElement: document.createElement('div'),
focusLogLine: jest.fn(),
logs,
onResize: jest.fn(),
...(propOverrides || {}),
@@ -198,8 +199,8 @@ describe('LogLineDetails', () => {
setup(undefined, { entry: '' });
expect(screen.queryByText('Fields')).not.toBeInTheDocument();
expect(screen.queryByText('Links')).not.toBeInTheDocument();
expect(screen.queryByText('Indexed labels')).not.toBeInTheDocument();
expect(screen.queryByText('Parsed fields')).not.toBeInTheDocument();
expect(screen.queryByText(/Indexed label/)).not.toBeInTheDocument();
expect(screen.queryByText(/Parsed field/)).not.toBeInTheDocument();
expect(screen.queryByText('Structured metadata')).not.toBeInTheDocument();
});
});
@@ -400,8 +401,8 @@ describe('LogLineDetails', () => {
expect(screen.getByText('value2')).toBeInTheDocument();
expect(screen.getByText('label3')).toBeInTheDocument();
expect(screen.getByText('value3')).toBeInTheDocument();
expect(screen.getByText('Indexed labels')).toBeInTheDocument();
expect(screen.getByText('Parsed fields')).toBeInTheDocument();
expect(screen.getByText(/Indexed label/)).toBeInTheDocument();
expect(screen.getByText(/Parsed field/)).toBeInTheDocument();
expect(screen.getByText('Structured metadata')).toBeInTheDocument();
});
test('should not show label types if they are unavailable or not supported', () => {
@@ -427,8 +428,8 @@ describe('LogLineDetails', () => {
expect(screen.getByText('value3')).toBeInTheDocument();
expect(screen.getByText('Fields')).toBeInTheDocument();
expect(screen.queryByText('Indexed labels')).not.toBeInTheDocument();
expect(screen.queryByText('Parsed fields')).not.toBeInTheDocument();
expect(screen.queryByText(/Indexed label/)).not.toBeInTheDocument();
expect(screen.queryByText(/Parsed field/)).not.toBeInTheDocument();
expect(screen.queryByText('Structured metadata')).not.toBeInTheDocument();
});
@@ -457,4 +458,37 @@ describe('LogLineDetails', () => {
expect(screen.getAllByText('No results to display.')).toHaveLength(3);
});
});
describe('Label types', () => {
test('Does not show displayed fields controls if not present', () => {
setup(undefined, { labels: { key1: 'label1', key2: 'label2' } });
expect(screen.queryByText('Displayed fields')).not.toBeInTheDocument();
});
test('Does not show displayed fields controls if required props are not present', () => {
setup(undefined, { labels: { key1: 'label1', key2: 'label2' } }, { displayedFields: ['key1', 'key2'] });
expect(screen.queryByText('Displayed fields')).not.toBeInTheDocument();
});
test('Shows displayed fields controls if required props are present', async () => {
const setDisplayedFields = jest.fn();
const onClickHideField = jest.fn();
setup(
undefined,
{ labels: { key1: 'label1', key2: 'label2' } },
{ displayedFields: ['key1', 'key2'], setDisplayedFields, onClickHideField }
);
expect(screen.getByText('Organize displayed fields')).toBeInTheDocument();
expect(screen.queryAllByLabelText('Remove field')).toHaveLength(0);
await userEvent.click(screen.getByText('Organize displayed fields'));
expect(screen.getAllByLabelText('Remove field')).toHaveLength(2);
await userEvent.click(screen.getAllByLabelText('Remove field')[0]);
expect(onClickHideField).toHaveBeenCalledWith('key1');
});
});
});
@@ -1,6 +1,6 @@
import { css } from '@emotion/css';
import { Resizable } from 're-resizable';
import { useCallback, useRef } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { getDragStyles, useStyles2 } from '@grafana/ui';
@@ -12,17 +12,23 @@ import { LOG_LIST_MIN_WIDTH } from './virtualization';
export interface Props {
containerElement: HTMLDivElement;
logOptionsStorageKey?: string;
focusLogLine: (log: LogListModel) => void;
logs: LogListModel[];
onResize(): void;
}
export const LogLineDetails = ({ containerElement, logOptionsStorageKey, logs, onResize }: Props) => {
const { detailsWidth, setDetailsWidth, showDetails } = useLogListContext();
export const LogLineDetails = ({ containerElement, focusLogLine, logs, onResize }: Props) => {
const { detailsWidth, logOptionsStorageKey, setDetailsWidth, showDetails } = useLogListContext();
const styles = useStyles2(getStyles);
const dragStyles = useStyles2(getDragStyles);
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
focusLogLine(showDetails[0]);
// Just once
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleResize = useCallback(() => {
if (containerRef.current) {
setDetailsWidth(containerRef.current.clientWidth);
@@ -10,8 +10,10 @@ import { getLabelTypeFromRow } from '../../utils';
import { useAttributesExtensionLinks } from '../LogDetails';
import { createLogLineLinks } from '../logParser';
import { LogLineDetailsDisplayedFields } from './LogLineDetailsDisplayedFields';
import { LabelWithLinks, LogLineDetailsFields, LogLineDetailsLabelFields } from './LogLineDetailsFields';
import { LogLineDetailsHeader } from './LogLineDetailsHeader';
import { useLogListContext } from './LogListContext';
import { LogListModel } from './processing';
interface LogLineDetailsComponentProps {
@@ -21,6 +23,7 @@ interface LogLineDetailsComponentProps {
}
export const LogLineDetailsComponent = ({ log, logOptionsStorageKey, logs }: LogLineDetailsComponentProps) => {
const { displayedFields, setDisplayedFields } = useLogListContext();
const [search, setSearch] = useState('');
const inputRef = useRef('');
const styles = useStyles2(getStyles);
@@ -65,10 +68,12 @@ export const LogLineDetailsComponent = ({ log, logOptionsStorageKey, logs }: Log
const fieldsOpen = logOptionsStorageKey
? store.getBool(`${logOptionsStorageKey}.log-details.fieldsOpen`, true)
: true;
const displayedFieldsOpen = logOptionsStorageKey
? store.getBool(`${logOptionsStorageKey}.log-details.displayedFieldsOpen`, false)
: false;
const handleToggle = useCallback(
(option: string, isOpen: boolean) => {
console.log(option, isOpen);
store.set(`${logOptionsStorageKey}.log-details.${option}`, isOpen);
},
[logOptionsStorageKey]
@@ -100,6 +105,16 @@ export const LogLineDetailsComponent = ({ log, logOptionsStorageKey, logs }: Log
>
<div className={styles.logLineWrapper}>{log.raw}</div>
</ControlledCollapse>
{displayedFields.length > 0 && setDisplayedFields && (
<ControlledCollapse
label={t('logs.log-line-details.displayed-fields-section', 'Organize displayed fields')}
collapsible
isOpen={displayedFieldsOpen}
onToggle={(isOpen: boolean) => handleToggle('displayedFieldsOpen', isOpen)}
>
<LogLineDetailsDisplayedFields />
</ControlledCollapse>
)}
{fieldsWithLinks.links.length > 0 && (
<ControlledCollapse
className={styles.collapsable}
@@ -0,0 +1,91 @@
import { css } from '@emotion/css';
import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd';
import { useCallback } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { Card, IconButton, useStyles2 } from '@grafana/ui';
import { LOG_LINE_BODY_FIELD_NAME } from '../LogDetailsBody';
import { useLogListContext } from './LogListContext';
export const LogLineDetailsDisplayedFields = () => {
const { displayedFields, setDisplayedFields } = useLogListContext();
const onDragEnd = useCallback(
(result: DropResult) => {
if (result.destination == null) {
return;
}
const newDisplayedFields = [...displayedFields];
const element = displayedFields[result.source.index];
newDisplayedFields.splice(result.source.index, 1);
newDisplayedFields.splice(result.destination.index, 0, element);
setDisplayedFields?.(newDisplayedFields);
},
[displayedFields, setDisplayedFields]
);
return (
<div>
<DragDropContext onDragEnd={onDragEnd}>
<Droppable ignoreContainerClipping={true} 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>
{provided.placeholder}
</>
);
}}
</Droppable>
</DragDropContext>
</div>
);
};
interface DraggableDisplayedFieldProps {
field: string;
index: number;
}
const DraggableDisplayedField = ({ field, index }: DraggableDisplayedFieldProps) => {
const { onClickHideField } = useLogListContext();
const styles = useStyles2(getStyles);
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>
)}
</Draggable>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
fieldCard: css({
cursor: 'move',
padding: theme.spacing(1),
marginBottom: theme.spacing(1),
wordBreak: 'break-word',
}),
});
@@ -2,7 +2,7 @@ import { css } from '@emotion/css';
import { debounce } from 'lodash';
import { Grammar } from 'prismjs';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, MouseEvent } from 'react';
import { VariableSizeList } from 'react-window';
import { Align, VariableSizeList } from 'react-window';
import {
AbsoluteTimeRange,
@@ -73,6 +73,7 @@ export interface Props {
permalinkedLogId?: string;
pinLineButtonTooltipTitle?: PopoverContent;
pinnedLogs?: string[];
setDisplayedFields?: (displayedFields: string[]) => void;
showControls: boolean;
showTime: boolean;
sortOrder: LogsSortOrder;
@@ -92,6 +93,7 @@ type LogListComponentProps = Omit<
| 'dedupStrategy'
| 'displayedFields'
| 'enableLogDetails'
| 'logOptionsStorageKey'
| 'permalinkedLogId'
| 'showTime'
| 'sortOrder'
@@ -135,6 +137,7 @@ export const LogList = ({
permalinkedLogId,
pinLineButtonTooltipTitle,
pinnedLogs,
setDisplayedFields,
showControls,
showTime,
sortOrder,
@@ -176,6 +179,7 @@ export const LogList = ({
permalinkedLogId={permalinkedLogId}
pinLineButtonTooltipTitle={pinLineButtonTooltipTitle}
pinnedLogs={pinnedLogs}
setDisplayedFields={setDisplayedFields}
showControls={showControls}
showTime={showTime}
sortOrder={sortOrder}
@@ -191,7 +195,6 @@ export const LogList = ({
initialScrollPosition={initialScrollPosition}
loading={loading}
loadMore={loadMore}
logOptionsStorageKey={logOptionsStorageKey}
logs={logs}
showControls={showControls}
timeRange={timeRange}
@@ -210,7 +213,6 @@ const LogListComponent = ({
initialScrollPosition = 'top',
loading,
loadMore,
logOptionsStorageKey,
logs,
showControls,
timeRange,
@@ -271,6 +273,12 @@ const LogListComponent = ({
}, 25);
}, []);
const debouncedScrollToItem = useMemo(() => {
return debounce((index: number, align?: Align) => {
listRef.current?.scrollToItem(index, align);
}, 250);
}, []);
useEffect(() => {
const subscription = eventBus.subscribe(ScrollToLogsEvent, (e: ScrollToLogsEvent) =>
handleScrollToEvent(e, logs.length, listRef.current)
@@ -380,6 +388,16 @@ const LogListComponent = ({
[filterLogs, levelFilteredLogs, matchingUids]
);
const focusLogLine = useCallback(
(log: LogListModel) => {
const index = filteredLogs.indexOf(log);
if (index >= 0) {
debouncedScrollToItem(index, 'start');
}
},
[debouncedScrollToItem, filteredLogs]
);
return (
<div className={styles.logListContainer}>
<div className={styles.logListWrapper} ref={wrapperRef}>
@@ -461,7 +479,7 @@ const LogListComponent = ({
{showDetails.length > 0 && (
<LogLineDetails
containerElement={containerElement}
logOptionsStorageKey={logOptionsStorageKey}
focusLogLine={focusLogLine}
logs={filteredLogs}
onResize={handleLogDetailsResize}
/>
@@ -162,6 +162,7 @@ export interface Props {
pinLineButtonTooltipTitle?: PopoverContent;
pinnedLogs?: string[];
prettifyJSON?: boolean;
setDisplayedFields?: (displayedFields: string[]) => void;
showControls: boolean;
showUniqueLabels?: boolean;
showTime: boolean;
@@ -204,6 +205,7 @@ export const LogListContextProvider = ({
pinLineButtonTooltipTitle,
pinnedLogs,
prettifyJSON,
setDisplayedFields,
showControls,
showTime,
showUniqueLabels,
@@ -492,6 +494,7 @@ export const LogListContextProvider = ({
getRowContextQuery,
logSupportsContext,
logLineMenuCustomItems,
logOptionsStorageKey,
onClickFilterLabel,
onClickFilterOutLabel,
onClickFilterString,
@@ -509,6 +512,7 @@ export const LogListContextProvider = ({
prettifyJSON: logListState.prettifyJSON,
setDedupStrategy,
setDetailsWidth,
setDisplayedFields,
setFilterLevels,
setFontSize,
setForceEscape,
+3 -9
View File
@@ -408,17 +408,11 @@ function getDataSourceLabelType(labelType: string, datasourceType: string, plura
case 'loki':
switch (labelType) {
case 'I':
return plural
? t('logs.fields.type.loki.indexed-label-plural', 'Indexed labels')
: t('logs.fields.type.loki.indexed-label', 'Indexed label');
return t('logs.fields.type.loki.indexed-label', 'Indexed label', { count: plural ? 2 : 1 });
case 'S':
return plural
? t('logs.fields.type.loki.structured-metadata-plural', 'Structured metadata')
: t('logs.fields.type.loki.structured-metadata', 'Structured metadata');
return t('logs.fields.type.loki.structured-metadata', 'Structured metadata', { count: plural ? 2 : 1 });
case 'P':
return plural
? t('logs.fields.type.loki.parsed-label-plural', 'Parsed fields')
: t('logs.fields.type.loki.parsedl-label', 'Parsed field');
return t('logs.fields.type.loki.parsedl-label', 'Parsed field', { count: plural ? 2 : 1 });
default:
return null;
}
@@ -565,6 +565,7 @@ export const LogsPanel = ({
onOpenContext={onOpenContext}
onPermalinkClick={showPermaLink() ? onPermalinkClick : undefined}
permalinkedLogId={getLogsPanelState()?.logs?.id ?? undefined}
setDisplayedFields={setDisplayedFields}
showControls={Boolean(showControls)}
showTime={showTime}
sortOrder={sortOrder}
+9 -6
View File
@@ -8644,12 +8644,12 @@
"fields": {
"type": {
"loki": {
"indexed-label": "Indexed label",
"indexed-label-plural": "Indexed labels",
"parsed-label-plural": "Parsed fields",
"parsedl-label": "Parsed field",
"structured-metadata": "Structured metadata",
"structured-metadata-plural": "Structured metadata"
"indexed-label_one": "Indexed label",
"indexed-label_other": "Indexed labels",
"parsedl-label_one": "Parsed field",
"parsedl-label_other": "Parsed fields",
"structured-metadata_one": "Structured metadata",
"structured-metadata_other": "Structured metadata"
}
}
},
@@ -8706,6 +8706,7 @@
"close": "Close log details",
"copy-shortlink": "Copy shortlink",
"copy-to-clipboard": "Copy to clipboard",
"displayed-fields-section": "Organize displayed fields",
"fields": {
"adhoc-statistics": "Ad-hoc statistics",
"copy-value-to-clipboard": "Copy value to clipboard",
@@ -8719,9 +8720,11 @@
"fields-section": "Fields",
"hide-log-line": "Hide log line",
"links-section": "Links",
"log-line-field": "Log line",
"log-line-section": "Log line",
"no-details": "No fields to display.",
"pin-line": "Pin log",
"remove-displayed-field": "Remove field",
"search": {
"no-results": "No results to display."
},