[Logs UI] Custom rendering for <LogStream /> columns (#85148)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Alejandro Fernández Gómez 2020-12-10 17:19:40 +01:00 committed by GitHub
parent 404d846f09
commit 4778365fc8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 317 additions and 60 deletions

View file

@ -98,6 +98,74 @@ There are three column types:
<td>A specific field specified in the `field` property.
</table>
### Custom column rendering
Besides customizing what columns are shown, you can also customize how a column is rendered. You can customize the width of the column, the text of the header, and how each value is rendered within the cell
#### `width` option
The `width` modifies the width of the column. It can be a number (in pixels) or a string with a valid CSS value.
```tsx
<LogStream
startTimestamp={...}
endTimetsamp={...}
columns={[
{ type: 'timestamp', width: 100 }, // Same as "100px"
{ type: 'field', field: 'event.dataset', width: "50%" }
]}
/>
```
#### `header` option
The `header` takes either a `boolean` value that specifies if the header should be rendered or not, or a `string` with the text to render.
```tsx
<LogStream
startTimestamp={...}
endTimetsamp={...}
columns={[
// Don't show anything in the header
{ type: 'timestamp', header: false },
// Show a custom string in the header
{ type: 'field', field: 'event.dataset', header: "Dataset of the event" }
]}
/>
```
The default is `true`, which render the default values for each column type:
| Column type | Default value |
| ----------- | ------------------------------------- |
| `timestamp` | Date of the top-most visible log line |
| `message` | `"Message"` literal |
| `field` | Field name |
#### `render` option
The `render` takes a function to customize the rendering of the column. The first argument is the value of the column. The function must return a valid `ReactNode`.
```tsx
<LogStream
startTimestamp={...}
endTimetsamp={...}
columns={[
{ type: 'timestamp', render: (timestamp) => <b>{new Date(timestamp).toString()}</b>; },
{ type: 'field', field: 'log.level', render: (value) => value === 'warn' ? '⚠️' : '' }
{ type: 'message', render: (message) => message.toUpperCase() }
]}
/>
```
The first argument's type depends on the column type.
| Column type | Type of the `value` |
| ----------- | ---------------------------------------------------------------------- |
| `timestamp` | `number`. The epoch_millis of the log line |
| `message` | `string`. The processed log message |
| `field` | `JsonValue`. The type of the field itself. Must be checked at runtime. |
### Considerations
As mentioned in the prerequisites, the component relies on `kibana-react` to access kibana's core services. If this is not the case the component will throw an exception when rendering. We advise to use an `<EuiErrorBoundary>` in your component hierarchy to catch this error if necessary.

View file

@ -11,17 +11,45 @@ import { euiStyled } from '../../../../observability/public';
import { LogEntryCursor } from '../../../common/log_entry';
import { useKibana } from '../../../../../../src/plugins/kibana_react/public';
import { LogSourceConfigurationProperties, useLogSource } from '../../containers/logs/log_source';
import { useLogSource } from '../../containers/logs/log_source';
import { useLogStream } from '../../containers/logs/log_stream';
import { ScrollableLogTextStreamView } from '../logging/log_text_stream';
import { LogColumnRenderConfiguration } from '../../utils/log_column_render_configuration';
import { JsonValue } from '../../../common/typed_json';
const PAGE_THRESHOLD = 2;
interface CommonColumnDefinition {
/** width of the column, in CSS units */
width?: number | string;
/** Content for the header. `true` renders the field name. `false` renders nothing. A string renders a custom value */
header?: boolean | string;
}
interface TimestampColumnDefinition extends CommonColumnDefinition {
type: 'timestamp';
/** Timestamp renderer. Takes a epoch_millis and returns a valid `ReactNode` */
render?: (timestamp: number) => React.ReactNode;
}
interface MessageColumnDefinition extends CommonColumnDefinition {
type: 'message';
/** Message renderer. Takes the processed message and returns a valid `ReactNode` */
render?: (message: string) => React.ReactNode;
}
interface FieldColumnDefinition extends CommonColumnDefinition {
type: 'field';
field: string;
/** Field renderer. Takes the value of the field and returns a valid `ReactNode` */
render?: (value: JsonValue) => React.ReactNode;
}
type LogColumnDefinition =
| { type: 'timestamp' }
| { type: 'message' }
| { type: 'field'; field: string };
| TimestampColumnDefinition
| MessageColumnDefinition
| FieldColumnDefinition;
export interface LogStreamProps {
sourceId?: string;
@ -178,15 +206,24 @@ const LogStreamContent = euiStyled.div<{ height: string }>`
function convertLogColumnDefinitionToLogSourceColumnDefinition(
columns: LogColumnDefinition[]
): LogSourceConfigurationProperties['logColumns'] {
): LogColumnRenderConfiguration[] {
return columns.map((column) => {
// We extract the { width, header, render } inside each block so the TS compiler uses the right type for `render`
switch (column.type) {
case 'timestamp':
return { timestampColumn: { id: '___#timestamp' } };
case 'message':
return { messageColumn: { id: '___#message' } };
case 'field':
return { fieldColumn: { id: `___#${column.field}`, field: column.field } };
case 'timestamp': {
const { width, header, render } = column;
return { timestampColumn: { id: '___#timestamp', width, header, render } };
}
case 'message': {
const { width, header, render } = column;
return { messageColumn: { id: '___#message', width, header, render } };
}
case 'field': {
const { width, header, render } = column;
return {
fieldColumn: { id: `___#${column.field}`, field: column.field, width, header, render },
};
}
}
});
}

View file

@ -4,16 +4,11 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import React, { useContext } from 'react';
import { transparentize } from 'polished';
import { euiStyled } from '../../../../../observability/public';
import {
LogColumnConfiguration,
isTimestampLogColumnConfiguration,
isFieldLogColumnConfiguration,
isMessageLogColumnConfiguration,
} from '../../../utils/source_configuration';
import {
LogEntryColumn,
LogEntryColumnContent,
@ -23,43 +18,82 @@ import {
import { ASSUMED_SCROLLBAR_WIDTH } from './vertical_scroll_panel';
import { LogPositionState } from '../../../containers/logs/log_position';
import { localizedDate } from '../../../../common/formatters/datetime';
import {
LogColumnRenderConfiguration,
isTimestampColumnRenderConfiguration,
isMessageColumnRenderConfiguration,
isFieldColumnRenderConfiguration,
} from '../../../utils/log_column_render_configuration';
export const LogColumnHeaders: React.FunctionComponent<{
columnConfigurations: LogColumnConfiguration[];
columnConfigurations: LogColumnRenderConfiguration[];
columnWidths: LogEntryColumnWidths;
}> = ({ columnConfigurations, columnWidths }) => {
const { firstVisiblePosition } = useContext(LogPositionState.Context);
return (
<LogColumnHeadersWrapper>
{columnConfigurations.map((columnConfiguration) => {
if (isTimestampLogColumnConfiguration(columnConfiguration)) {
if (isTimestampColumnRenderConfiguration(columnConfiguration)) {
let columnHeader;
if (columnConfiguration.timestampColumn.header === false) {
columnHeader = null;
} else if (typeof columnConfiguration.timestampColumn.header === 'string') {
columnHeader = columnConfiguration.timestampColumn.header;
} else {
columnHeader = firstVisiblePosition
? localizedDate(firstVisiblePosition.time)
: i18n.translate('xpack.infra.logs.stream.timestampColumnTitle', {
defaultMessage: 'Timestamp',
});
}
return (
<LogColumnHeader
key={columnConfiguration.timestampColumn.id}
columnWidth={columnWidths[columnConfiguration.timestampColumn.id]}
data-test-subj="logColumnHeader timestampLogColumnHeader"
>
{firstVisiblePosition ? localizedDate(firstVisiblePosition.time) : 'Timestamp'}
{columnHeader}
</LogColumnHeader>
);
} else if (isMessageLogColumnConfiguration(columnConfiguration)) {
} else if (isMessageColumnRenderConfiguration(columnConfiguration)) {
let columnHeader;
if (columnConfiguration.messageColumn.header === false) {
columnHeader = null;
} else if (typeof columnConfiguration.messageColumn.header === 'string') {
columnHeader = columnConfiguration.messageColumn.header;
} else {
columnHeader = i18n.translate('xpack.infra.logs.stream.messageColumnTitle', {
defaultMessage: 'Message',
});
}
return (
<LogColumnHeader
columnWidth={columnWidths[columnConfiguration.messageColumn.id]}
data-test-subj="logColumnHeader messageLogColumnHeader"
key={columnConfiguration.messageColumn.id}
>
Message
{columnHeader}
</LogColumnHeader>
);
} else if (isFieldLogColumnConfiguration(columnConfiguration)) {
} else if (isFieldColumnRenderConfiguration(columnConfiguration)) {
let columnHeader;
if (columnConfiguration.fieldColumn.header === false) {
columnHeader = null;
} else if (typeof columnConfiguration.fieldColumn.header === 'string') {
columnHeader = columnConfiguration.fieldColumn.header;
} else {
columnHeader = columnConfiguration.fieldColumn.field;
}
return (
<LogColumnHeader
columnWidth={columnWidths[columnConfiguration.fieldColumn.id]}
data-test-subj="logColumnHeader fieldLogColumnHeader"
key={columnConfiguration.fieldColumn.id}
>
{columnConfiguration.fieldColumn.field}
{columnHeader}
</LogColumnHeader>
);
}

View file

@ -14,7 +14,12 @@ export const FieldValue: React.FC<{
highlightTerms: string[];
isActiveHighlight: boolean;
value: JsonArray;
}> = React.memo(({ highlightTerms, isActiveHighlight, value }) => {
render?: (value: JsonValue) => React.ReactNode;
}> = React.memo(({ highlightTerms, isActiveHighlight, value, render }) => {
if (render) {
return <>{render(value.length === 1 ? value[0] : value)}</>;
}
if (value.length === 1) {
return (
<>

View file

@ -9,10 +9,11 @@ import { useMemo } from 'react';
import { euiStyled } from '../../../../../observability/public';
import { TextScale } from '../../../../common/log_text_scale';
import {
isMessageLogColumnConfiguration,
isTimestampLogColumnConfiguration,
LogColumnConfiguration,
} from '../../../utils/source_configuration';
LogColumnRenderConfiguration,
isTimestampColumnRenderConfiguration,
isMessageColumnRenderConfiguration,
columnWidthToCSS,
} from '../../../utils/log_column_render_configuration';
import { useFormattedTime, TimeFormat } from '../../formatted_time';
import { useMeasuredCharacterDimensions } from './text_styles';
@ -59,42 +60,58 @@ export interface LogEntryColumnWidths {
}
export const getColumnWidths = (
columns: LogColumnConfiguration[],
columns: LogColumnRenderConfiguration[],
characterWidth: number,
formattedDateWidth: number
): LogEntryColumnWidths =>
columns.reduce<LogEntryColumnWidths>(
(columnWidths, column) => {
if (isTimestampLogColumnConfiguration(column)) {
if (isTimestampColumnRenderConfiguration(column)) {
const customWidth = column.timestampColumn.width
? columnWidthToCSS(column.timestampColumn.width)
: undefined;
return {
...columnWidths,
[column.timestampColumn.id]: {
growWeight: 0,
shrinkWeight: 0,
baseWidth: `${
Math.ceil(characterWidth * formattedDateWidth * DATE_COLUMN_SLACK_FACTOR) +
2 * COLUMN_PADDING
}px`,
baseWidth:
customWidth ??
`${
Math.ceil(characterWidth * formattedDateWidth * DATE_COLUMN_SLACK_FACTOR) +
2 * COLUMN_PADDING
}px`,
},
};
} else if (isMessageLogColumnConfiguration(column)) {
} else if (isMessageColumnRenderConfiguration(column)) {
const customWidth = column.messageColumn.width
? columnWidthToCSS(column.messageColumn.width)
: undefined;
return {
...columnWidths,
[column.messageColumn.id]: {
growWeight: 5,
shrinkWeight: 0,
baseWidth: '0%',
baseWidth: customWidth ?? '0%',
},
};
} else {
const customWidth = column.fieldColumn.width
? columnWidthToCSS(column.fieldColumn.width)
: undefined;
return {
...columnWidths,
[column.fieldColumn.id]: {
growWeight: 1,
growWeight: customWidth ? 0 : 1,
shrinkWeight: 0,
baseWidth: `${
Math.ceil(characterWidth * FIELD_COLUMN_MIN_WIDTH_CHARACTERS) + 2 * COLUMN_PADDING
}px`,
baseWidth:
customWidth ??
`${
Math.ceil(characterWidth * FIELD_COLUMN_MIN_WIDTH_CHARACTERS) + 2 * COLUMN_PADDING
}px`,
},
};
}
@ -119,7 +136,7 @@ export const useColumnWidths = ({
scale,
timeFormat = 'time',
}: {
columnConfigurations: LogColumnConfiguration[];
columnConfigurations: LogColumnRenderConfiguration[];
scale: TextScale;
timeFormat?: TimeFormat;
}) => {

View file

@ -5,6 +5,7 @@
*/
import React from 'react';
import { JsonValue } from '../../../../common/typed_json';
import { euiStyled } from '../../../../../observability/public';
import { LogColumn } from '../../../../common/http_api';
import { isFieldColumn, isHighlightFieldColumn } from '../../../utils/log_entry';
@ -22,6 +23,7 @@ interface LogEntryFieldColumnProps {
highlights: LogColumn[];
isActiveHighlight: boolean;
wrapMode: WrapMode;
render?: (value: JsonValue) => React.ReactNode;
}
export const LogEntryFieldColumn: React.FunctionComponent<LogEntryFieldColumnProps> = ({
@ -29,6 +31,7 @@ export const LogEntryFieldColumn: React.FunctionComponent<LogEntryFieldColumnPro
highlights: [firstHighlight], // we only support one highlight for now
isActiveHighlight,
wrapMode,
render,
}) => {
if (isFieldColumn(columnValue)) {
return (
@ -37,6 +40,7 @@ export const LogEntryFieldColumn: React.FunctionComponent<LogEntryFieldColumnPro
highlightTerms={isHighlightFieldColumn(firstHighlight) ? firstHighlight.highlights : []}
isActiveHighlight={isActiveHighlight}
value={columnValue.value}
render={render}
/>
</FieldColumnContent>
);

View file

@ -28,10 +28,11 @@ interface LogEntryMessageColumnProps {
highlights: LogColumn[];
isActiveHighlight: boolean;
wrapMode: WrapMode;
render?: (message: string) => React.ReactNode;
}
export const LogEntryMessageColumn = memo<LogEntryMessageColumnProps>(
({ columnValue, highlights, isActiveHighlight, wrapMode }) => {
({ columnValue, highlights, isActiveHighlight, wrapMode, render }) => {
const message = useMemo(
() =>
isMessageColumn(columnValue)
@ -40,7 +41,16 @@ export const LogEntryMessageColumn = memo<LogEntryMessageColumnProps>(
[columnValue, highlights, isActiveHighlight]
);
return <MessageColumnContent wrapMode={wrapMode}>{message}</MessageColumnContent>;
const messageAsString = useMemo(
() => (isMessageColumn(columnValue) ? renderMessageSegments(columnValue.message) : ''),
[columnValue]
);
return (
<MessageColumnContent wrapMode={wrapMode}>
{render ? render(messageAsString) : message}
</MessageColumnContent>
);
}
);
@ -90,3 +100,16 @@ const formatMessageSegments = (
return 'failed to format message';
});
const renderMessageSegments = (messageSegments: LogMessagePart[]): string => {
return messageSegments
.map((messageSegment) => {
if (isConstantSegment(messageSegment)) {
return messageSegment.constant;
}
if (isFieldSegment(messageSegment)) {
return messageSegment.value.toString();
}
})
.join(' ');
};

View file

@ -10,12 +10,6 @@ import { isEmpty } from 'lodash';
import { euiStyled, useUiTracker } from '../../../../../observability/public';
import { isTimestampColumn } from '../../../utils/log_entry';
import {
LogColumnConfiguration,
isTimestampLogColumnConfiguration,
isMessageLogColumnConfiguration,
isFieldLogColumnConfiguration,
} from '../../../utils/source_configuration';
import { TextScale } from '../../../../common/log_text_scale';
import { LogEntryColumn, LogEntryColumnWidths, iconColumnId } from './log_entry_column';
import { LogEntryFieldColumn } from './log_entry_field_column';
@ -24,6 +18,12 @@ import { LogEntryTimestampColumn } from './log_entry_timestamp_column';
import { monospaceTextStyle, hoveredContentStyle, highlightedContentStyle } from './text_styles';
import { LogEntry, LogColumn } from '../../../../common/http_api';
import { LogEntryContextMenu } from './log_entry_context_menu';
import {
LogColumnRenderConfiguration,
isTimestampColumnRenderConfiguration,
isMessageColumnRenderConfiguration,
isFieldColumnRenderConfiguration,
} from '../../../utils/log_column_render_configuration';
const MENU_LABEL = i18n.translate('xpack.infra.logEntryItemView.logEntryActionsMenuToolTip', {
defaultMessage: 'View actions for line',
@ -42,7 +42,7 @@ const LOG_VIEW_IN_CONTEXT_LABEL = i18n.translate(
interface LogEntryRowProps {
boundingBoxRef?: React.Ref<Element>;
columnConfigurations: LogColumnConfiguration[];
columnConfigurations: LogColumnRenderConfiguration[];
columnWidths: LogEntryColumnWidths;
highlights: LogEntry[];
isActiveHighlight: boolean;
@ -162,7 +162,7 @@ export const LogEntryRow = memo(
scale={scale}
>
{columnConfigurations.map((columnConfiguration) => {
if (isTimestampLogColumnConfiguration(columnConfiguration)) {
if (isTimestampColumnRenderConfiguration(columnConfiguration)) {
const column = logEntryColumnsById[columnConfiguration.timestampColumn.id];
const columnWidth = columnWidths[columnConfiguration.timestampColumn.id];
@ -173,11 +173,14 @@ export const LogEntryRow = memo(
{...columnWidth}
>
{isTimestampColumn(column) ? (
<LogEntryTimestampColumn time={column.timestamp} />
<LogEntryTimestampColumn
time={column.timestamp}
render={columnConfiguration.timestampColumn.render}
/>
) : null}
</LogEntryColumn>
);
} else if (isMessageLogColumnConfiguration(columnConfiguration)) {
} else if (isMessageColumnRenderConfiguration(columnConfiguration)) {
const column = logEntryColumnsById[columnConfiguration.messageColumn.id];
const columnWidth = columnWidths[columnConfiguration.messageColumn.id];
@ -193,11 +196,12 @@ export const LogEntryRow = memo(
highlights={highlightsByColumnId[column.columnId] || []}
isActiveHighlight={isActiveHighlight}
wrapMode={wrap ? 'long' : 'pre-wrapped'}
render={columnConfiguration.messageColumn.render}
/>
) : null}
</LogEntryColumn>
);
} else if (isFieldLogColumnConfiguration(columnConfiguration)) {
} else if (isFieldColumnRenderConfiguration(columnConfiguration)) {
const column = logEntryColumnsById[columnConfiguration.fieldColumn.id];
const columnWidth = columnWidths[columnConfiguration.fieldColumn.id];
@ -213,6 +217,7 @@ export const LogEntryRow = memo(
highlights={highlightsByColumnId[column.columnId] || []}
isActiveHighlight={isActiveHighlight}
wrapMode={wrap ? 'long' : 'pre-wrapped'}
render={columnConfiguration.fieldColumn.render}
/>
) : null}
</LogEntryColumn>

View file

@ -13,13 +13,14 @@ import { LogEntryColumnContent } from './log_entry_column';
interface LogEntryTimestampColumnProps {
format?: TimeFormat;
time: number;
render?: (timestamp: number) => React.ReactNode;
}
export const LogEntryTimestampColumn = memo<LogEntryTimestampColumnProps>(
({ format = 'time', time }) => {
({ format = 'time', time, render }) => {
const formattedTime = useFormattedTime(time, { format });
return <TimestampColumnContent>{formattedTime}</TimestampColumnContent>;
return <TimestampColumnContent>{render ? render(time) : formattedTime}</TimestampColumnContent>;
}
);

View file

@ -13,7 +13,6 @@ import { euiStyled } from '../../../../../observability/public';
import { TextScale } from '../../../../common/log_text_scale';
import { TimeKey, UniqueTimeKey } from '../../../../common/time';
import { callWithoutRepeats } from '../../../utils/handlers';
import { LogColumnConfiguration } from '../../../utils/source_configuration';
import { AutoSizer } from '../../auto_sizer';
import { NoData } from '../../empty_states';
import { InfraLoadingPanel } from '../../loading';
@ -27,9 +26,10 @@ import { VerticalScrollPanel } from './vertical_scroll_panel';
import { useColumnWidths, LogEntryColumnWidths } from './log_entry_column';
import { LogDateRow } from './log_date_row';
import { LogEntry } from '../../../../common/http_api';
import { LogColumnRenderConfiguration } from '../../../utils/log_column_render_configuration';
interface ScrollableLogTextStreamViewProps {
columnConfigurations: LogColumnConfiguration[];
columnConfigurations: LogColumnRenderConfiguration[];
items: StreamItem[];
scale: TextScale;
wrap: boolean;
@ -379,7 +379,7 @@ const WithColumnWidths: React.FunctionComponent<{
columnWidths: LogEntryColumnWidths;
CharacterDimensionsProbe: React.ComponentType;
}) => React.ReactElement<any> | null;
columnConfigurations: LogColumnConfiguration[];
columnConfigurations: LogColumnRenderConfiguration[];
scale: TextScale;
}> = ({ children, columnConfigurations, scale }) => {
const childParams = useColumnWidths({ columnConfigurations, scale });

View file

@ -0,0 +1,63 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { ReactNode } from 'react';
import { JsonValue } from '../../common/typed_json';
/**
* Interface for common configuration properties, regardless of the column type.
*/
interface CommonRenderConfiguration {
id: string;
width?: number | string;
header?: boolean | string;
}
interface TimestampColumnRenderConfiguration {
timestampColumn: CommonRenderConfiguration & {
render?: (timestamp: number) => ReactNode;
};
}
interface MessageColumnRenderConfiguration {
messageColumn: CommonRenderConfiguration & {
render?: (message: string) => ReactNode;
};
}
interface FieldColumnRenderConfiguration {
fieldColumn: CommonRenderConfiguration & {
field: string;
render?: (value: JsonValue) => ReactNode;
};
}
export type LogColumnRenderConfiguration =
| TimestampColumnRenderConfiguration
| MessageColumnRenderConfiguration
| FieldColumnRenderConfiguration;
export function isTimestampColumnRenderConfiguration(
column: LogColumnRenderConfiguration
): column is TimestampColumnRenderConfiguration {
return 'timestampColumn' in column;
}
export function isMessageColumnRenderConfiguration(
column: LogColumnRenderConfiguration
): column is MessageColumnRenderConfiguration {
return 'messageColumn' in column;
}
export function isFieldColumnRenderConfiguration(
column: LogColumnRenderConfiguration
): column is FieldColumnRenderConfiguration {
return 'fieldColumn' in column;
}
export function columnWidthToCSS(width: number | string) {
return typeof width === 'number' ? `${width}px` : width;
}