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:
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user