[7.x] [Logs UI] View log in context modal (#62198) (#64457)

This commit is contained in:
Alejandro Fernández 2020-04-25 12:22:20 +02:00 committed by GitHub
parent eba00e803d
commit 0fbe1d1ca8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 414 additions and 46 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 531 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

View file

@ -69,12 +69,19 @@ To highlight a word or phrase in the logs stream, click *Highlights* and enter y
[float]
[[logs-event-inspector]]
=== Inspect a log event
To inspect a log event, hover over it, then click the *View details* icon image:logs/images/logs-view-event.png[View event icon] beside the event.
This opens the *Log event document details* fly-out that shows the fields associated with the log event.
To inspect a log event, hover over it, then click the *View actions for line* icon image:logs/images/logs-action-menu.png[View actions for line icon]. On the menu that opens, select *View details*. This opens the *Log event document details* fly-out that shows the fields associated with the log event.
To quickly filter the logs stream by one of the field values, in the log event details, click the *View event with filter* icon image:logs/images/logs-view-event-with-filter.png[View event icon] beside the field.
This automatically adds a search filter to the logs stream to filter the entries by this field and value.
[float]
[[log-view-in-context]]
=== View log line in context
To view a certain line in its context (for example, with other log lines from the same file, or the same cloud container), hover over it, then click the *View actions for line* image:logs/images/logs-action-menu.png[View actions for line icon]. On the menu that opens, select *View in context*. This opens the *View log in context* modal, that shows the log line in its context.
[role="screenshot"]
image::logs/images/logs-view-in-context.png[View a log line in context]
[float]
[[view-log-anomalies]]
=== View log anomalies

View file

@ -78,11 +78,11 @@ export const logEntryRT = rt.type({
id: rt.string,
cursor: logEntriesCursorRT,
columns: rt.array(logColumnRT),
context: rt.partial({
'log.file.path': rt.string,
'host.name': rt.string,
'container.id': rt.string,
}),
context: rt.union([
rt.type({}),
rt.type({ 'container.id': rt.string }),
rt.type({ 'host.name': rt.string, 'log.file.path': rt.string }),
]),
});
export type LogMessageConstantPart = rt.TypeOf<typeof logMessageConstantPartRT>;

View file

@ -24,29 +24,49 @@ interface LogEntryActionsColumnProps {
isMenuOpen: boolean;
onOpenMenu: () => void;
onCloseMenu: () => void;
onViewDetails: () => void;
onViewDetails?: () => void;
onViewLogInContext?: () => void;
}
const MENU_LABEL = i18n.translate('xpack.infra.logEntryItemView.logEntryActionsMenuToolTip', {
defaultMessage: 'View Details',
defaultMessage: 'View actions for line',
});
const LOG_DETAILS_LABEL = i18n.translate('xpack.infra.logs.logEntryActionsDetailsButton', {
defaultMessage: 'View actions for line',
defaultMessage: 'View details',
});
const LOG_VIEW_IN_CONTEXT_LABEL = i18n.translate(
'xpack.infra.lobs.logEntryActionsViewInContextButton',
{
defaultMessage: 'View in context',
}
);
export const LogEntryActionsColumn: React.FC<LogEntryActionsColumnProps> = ({
isHovered,
isMenuOpen,
onOpenMenu,
onCloseMenu,
onViewDetails,
onViewLogInContext,
}) => {
const handleClickViewDetails = useCallback(() => {
onCloseMenu();
onViewDetails();
// Function might be `undefined` and the linter doesn't like that.
// eslint-disable-next-line no-unused-expressions
onViewDetails?.();
}, [onCloseMenu, onViewDetails]);
const handleClickViewInContext = useCallback(() => {
onCloseMenu();
// Function might be `undefined` and the linter doesn't like that.
// eslint-disable-next-line no-unused-expressions
onViewLogInContext?.();
}, [onCloseMenu, onViewLogInContext]);
const button = (
<ButtonWrapper>
<EuiButtonIcon
@ -72,6 +92,12 @@ export const LogEntryActionsColumn: React.FC<LogEntryActionsColumnProps> = ({
</SectionTitle>
<SectionLinks>
<SectionLink label={LOG_DETAILS_LABEL} onClick={handleClickViewDetails} />
{onViewLogInContext !== undefined ? (
<SectionLink
label={LOG_VIEW_IN_CONTEXT_LABEL}
onClick={handleClickViewInContext}
/>
) : null}
</SectionLinks>
</Section>
</ActionMenu>

View file

@ -5,6 +5,7 @@
*/
import React, { memo, useState, useCallback, useMemo } from 'react';
import { isEmpty } from 'lodash';
import { euiStyled } from '../../../../../observability/public';
import { isTimestampColumn } from '../../../utils/log_entry';
@ -32,6 +33,7 @@ interface LogEntryRowProps {
isHighlighted: boolean;
logEntry: LogEntry;
openFlyoutWithItem?: (id: string) => void;
openViewLogInContext?: (entry: LogEntry) => void;
scale: TextScale;
wrap: boolean;
}
@ -46,6 +48,7 @@ export const LogEntryRow = memo(
isHighlighted,
logEntry,
openFlyoutWithItem,
openViewLogInContext,
scale,
wrap,
}: LogEntryRowProps) => {
@ -63,6 +66,16 @@ export const LogEntryRow = memo(
logEntry.id,
]);
const handleOpenViewLogInContext = useCallback(() => openViewLogInContext?.(logEntry), [
openViewLogInContext,
logEntry,
]);
const hasContext = useMemo(() => !isEmpty(logEntry.context), [logEntry]);
const hasActionFlyoutWithItem = openFlyoutWithItem !== undefined;
const hasActionViewLogInContext = hasContext && openViewLogInContext !== undefined;
const hasActionsMenu = hasActionFlyoutWithItem || hasActionViewLogInContext;
const logEntryColumnsById = useMemo(
() =>
logEntry.columns.reduce<{
@ -165,18 +178,23 @@ export const LogEntryRow = memo(
);
}
})}
<LogEntryColumn
key="logColumn iconLogColumn iconLogColumn:details"
{...columnWidths[iconColumnId]}
>
<LogEntryActionsColumn
isHovered={isHovered}
isMenuOpen={isMenuOpen}
onOpenMenu={openMenu}
onCloseMenu={closeMenu}
onViewDetails={openFlyout}
/>
</LogEntryColumn>
{hasActionsMenu ? (
<LogEntryColumn
key="logColumn iconLogColumn iconLogColumn:details"
{...columnWidths[iconColumnId]}
>
<LogEntryActionsColumn
isHovered={isHovered}
isMenuOpen={isMenuOpen}
onOpenMenu={openMenu}
onCloseMenu={closeMenu}
onViewDetails={hasActionFlyoutWithItem ? openFlyout : undefined}
onViewLogInContext={
hasActionViewLogInContext ? handleOpenViewLogInContext : undefined
}
/>
</LogEntryColumn>
) : null}
</LogEntryRowWrapper>
);
}

View file

@ -26,6 +26,7 @@ import { MeasurableItemView } from './measurable_item_view';
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';
interface ScrollableLogTextStreamViewProps {
columnConfigurations: LogColumnConfiguration[];
@ -50,8 +51,9 @@ interface ScrollableLogTextStreamViewProps {
}) => any;
loadNewerItems: () => void;
reloadItems: () => void;
setFlyoutItem: (id: string) => void;
setFlyoutVisibility: (visible: boolean) => void;
setFlyoutItem?: (id: string) => void;
setFlyoutVisibility?: (visible: boolean) => void;
setContextEntry?: (entry: LogEntry) => void;
highlightedItem: string | null;
currentHighlightKey: UniqueTimeKey | null;
startDateExpression: string;
@ -140,9 +142,16 @@ export class ScrollableLogTextStreamView extends React.PureComponent<
lastLoadedTime,
updateDateRange,
startLiveStreaming,
setFlyoutItem,
setFlyoutVisibility,
setContextEntry,
} = this.props;
const { targetId, items, isScrollLocked } = this.state;
const hasItems = items.length > 0;
const hasFlyoutAction = !!(setFlyoutItem && setFlyoutVisibility);
const hasContextAction = !!setContextEntry;
return (
<ScrollableLogTextStreamViewWrapper>
{isReloading && (!isStreaming || !hasItems) ? (
@ -227,7 +236,14 @@ export class ScrollableLogTextStreamView extends React.PureComponent<
<LogEntryRow
columnConfigurations={columnConfigurations}
columnWidths={columnWidths}
openFlyoutWithItem={this.handleOpenFlyout}
openFlyoutWithItem={
hasFlyoutAction ? this.handleOpenFlyout : undefined
}
openViewLogInContext={
hasContextAction
? this.handleOpenViewLogInContext
: undefined
}
boundingBoxRef={itemMeasureRef}
logEntry={item.logEntry}
highlights={item.highlights}
@ -287,8 +303,19 @@ export class ScrollableLogTextStreamView extends React.PureComponent<
}
private handleOpenFlyout = (id: string) => {
this.props.setFlyoutItem(id);
this.props.setFlyoutVisibility(true);
const { setFlyoutItem, setFlyoutVisibility } = this.props;
if (setFlyoutItem && setFlyoutVisibility) {
setFlyoutItem(id);
setFlyoutVisibility(true);
}
};
private handleOpenViewLogInContext = (entry: LogEntry) => {
const { setContextEntry } = this.props;
if (setContextEntry) {
setContextEntry(entry);
}
};
private handleReload = () => {

View file

@ -33,7 +33,7 @@ export const hoveredContentStyle = css`
`;
export const highlightedContentStyle = css`
background-color: ${props => props.theme.eui.euiFocusBackgroundColor};
background-color: ${props => props.theme.eui.euiColorHighlight};
`;
export const longWrappedContentStyle = css`

View file

@ -0,0 +1,7 @@
/*
* 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.
*/
export * from './view_log_in_context';

View file

@ -0,0 +1,83 @@
/*
* 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 { useState, useEffect, useCallback } from 'react';
import createContainer from 'constate';
import { LogEntry } from '../../../../common/http_api';
import { fetchLogEntries } from '../log_entries/api/fetch_log_entries';
import { esKuery } from '../../../../../../../src/plugins/data/public';
function getQueryFromLogEntry(entry: LogEntry) {
const expression = Object.entries(entry.context).reduce((kuery, [key, value]) => {
const currentExpression = `${key} : "${value}"`;
if (kuery.length > 0) {
return `${kuery} AND ${currentExpression}`;
} else {
return currentExpression;
}
}, '');
return JSON.stringify(esKuery.toElasticsearchQuery(esKuery.fromKueryExpression(expression)));
}
interface ViewLogInContextProps {
sourceId: string;
startTimestamp: number;
endTimestamp: number;
}
export interface ViewLogInContextState {
entries: LogEntry[];
isLoading: boolean;
contextEntry?: LogEntry;
}
interface ViewLogInContextCallbacks {
setContextEntry: (entry?: LogEntry) => void;
}
export const useViewLogInContext = (
props: ViewLogInContextProps
): [ViewLogInContextState, ViewLogInContextCallbacks] => {
const [contextEntry, setContextEntry] = useState<LogEntry | undefined>();
const [entries, setEntries] = useState<LogEntry[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const { startTimestamp, endTimestamp, sourceId } = props;
const maybeFetchLogs = useCallback(async () => {
if (contextEntry) {
setIsLoading(true);
const { data } = await fetchLogEntries({
sourceId,
startTimestamp,
endTimestamp,
center: contextEntry.cursor,
query: getQueryFromLogEntry(contextEntry),
});
setEntries(data.entries);
setIsLoading(false);
} else {
setEntries([]);
setIsLoading(false);
}
}, [contextEntry, startTimestamp, endTimestamp, sourceId]);
useEffect(() => {
maybeFetchLogs();
}, [maybeFetchLogs]);
return [
{
contextEntry,
entries,
isLoading,
},
{
setContextEntry,
},
];
};
export const ViewLogInContext = createContainer(useViewLogInContext);

View file

@ -10,6 +10,7 @@ import { ColumnarPage } from '../../../components/page';
import { StreamPageContent } from './page_content';
import { StreamPageHeader } from './page_header';
import { LogsPageProviders } from './page_providers';
import { PageViewLogInContext } from './page_view_log_in_context';
import { useTrackPageview } from '../../../../../observability/public';
export const StreamPage = () => {
@ -21,6 +22,7 @@ export const StreamPage = () => {
<StreamPageHeader />
<StreamPageContent />
</ColumnarPage>
<PageViewLogInContext />
</LogsPageProviders>
);
};

View file

@ -27,6 +27,7 @@ import { Source } from '../../../containers/source';
import { LogsToolbar } from './page_toolbar';
import { LogHighlightsState } from '../../../containers/logs/log_highlights';
import { ViewLogInContext } from '../../../containers/logs/view_log_in_context';
export const LogsPageLogsContent: React.FunctionComponent = () => {
const { source, sourceId, version } = useContext(Source.Context);
@ -55,6 +56,9 @@ export const LogsPageLogsContent: React.FunctionComponent = () => {
endDateExpression,
updateDateRange,
} = useContext(LogPositionState.Context);
const [, { setContextEntry }] = useContext(ViewLogInContext.Context);
return (
<>
<WithLogTextviewUrlState />
@ -104,6 +108,7 @@ export const LogsPageLogsContent: React.FunctionComponent = () => {
wrap={textWrap}
setFlyoutItem={setFlyoutId}
setFlyoutVisibility={setFlyoutVisibility}
setContextEntry={setContextEntry}
highlightedItem={surroundingLogsId ? surroundingLogsId : null}
currentHighlightKey={currentHighlightKey}
startDateExpression={startDateExpression}

View file

@ -14,6 +14,7 @@ import { LogFilterState, WithLogFilterUrlState } from '../../../containers/logs/
import { LogEntriesState } from '../../../containers/logs/log_entries';
import { Source } from '../../../containers/source';
import { ViewLogInContext } from '../../../containers/logs/view_log_in_context';
const LogFilterStateProvider: React.FC = ({ children }) => {
const { createDerivedIndexPattern } = useContext(Source.Context);
@ -26,6 +27,25 @@ const LogFilterStateProvider: React.FC = ({ children }) => {
);
};
const ViewLogInContextProvider: React.FC = ({ children }) => {
const { startTimestamp, endTimestamp } = useContext(LogPositionState.Context);
const { sourceId } = useContext(Source.Context);
if (!startTimestamp || !endTimestamp) {
return null;
}
return (
<ViewLogInContext.Provider
startTimestamp={startTimestamp}
endTimestamp={endTimestamp}
sourceId={sourceId}
>
{children}
</ViewLogInContext.Provider>
);
};
const LogEntriesStateProvider: React.FC = ({ children }) => {
const { sourceId } = useContext(Source.Context);
const {
@ -91,11 +111,13 @@ export const LogsPageProviders: React.FunctionComponent = ({ children }) => {
<LogFlyout.Provider>
<LogPositionState.Provider>
<WithLogPositionUrlState />
<LogFilterStateProvider>
<LogEntriesStateProvider>
<LogHighlightsStateProvider>{children}</LogHighlightsStateProvider>
</LogEntriesStateProvider>
</LogFilterStateProvider>
<ViewLogInContextProvider>
<LogFilterStateProvider>
<LogEntriesStateProvider>
<LogHighlightsStateProvider>{children}</LogHighlightsStateProvider>
</LogEntriesStateProvider>
</LogFilterStateProvider>
</ViewLogInContextProvider>
</LogPositionState.Provider>
</LogFlyout.Provider>
</LogViewConfiguration.Provider>

View file

@ -0,0 +1,124 @@
/*
* 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 React, { useContext, useCallback, useMemo } from 'react';
import { noop } from 'lodash';
import {
EuiOverlayMask,
EuiModal,
EuiModalBody,
EuiText,
EuiTextColor,
EuiFlexGroup,
EuiFlexItem,
EuiToolTip,
} from '@elastic/eui';
import { ViewLogInContext } from '../../../containers/logs/view_log_in_context';
import { LogEntry } from '../../../../common/http_api';
import { Source } from '../../../containers/source';
import { LogViewConfiguration } from '../../../containers/logs/log_view_configuration';
import { ScrollableLogTextStreamView } from '../../../components/logging/log_text_stream';
import { useViewportDimensions } from '../../../utils/use_viewport_dimensions';
const MODAL_MARGIN = 25;
export const PageViewLogInContext: React.FC = () => {
const { source } = useContext(Source.Context);
const { textScale, textWrap } = useContext(LogViewConfiguration.Context);
const columnConfigurations = useMemo(() => (source && source.configuration.logColumns) || [], [
source,
]);
const [{ contextEntry, entries, isLoading }, { setContextEntry }] = useContext(
ViewLogInContext.Context
);
const closeModal = useCallback(() => setContextEntry(undefined), [setContextEntry]);
const { width: vw, height: vh } = useViewportDimensions();
const streamItems = useMemo(
() =>
entries.map(entry => ({
kind: 'logEntry' as const,
logEntry: entry,
highlights: [],
})),
[entries]
);
if (!contextEntry) {
return null;
}
return (
<EuiOverlayMask>
<EuiModal onClose={closeModal} maxWidth={false}>
<EuiModalBody style={{ width: vw - MODAL_MARGIN * 2, height: vh - MODAL_MARGIN * 2 }}>
<EuiFlexGroup
direction="column"
responsive={false}
wrap={false}
style={{ height: '100%' }}
>
<EuiFlexItem grow={1}>
<LogEntryContext context={contextEntry.context} />
<ScrollableLogTextStreamView
target={contextEntry.cursor}
columnConfigurations={columnConfigurations}
items={streamItems}
scale={textScale}
wrap={textWrap}
isReloading={isLoading}
isLoadingMore={false}
hasMoreBeforeStart={false}
hasMoreAfterEnd={false}
isStreaming={false}
lastLoadedTime={null}
jumpToTarget={noop}
reportVisibleInterval={noop}
loadNewerItems={noop}
reloadItems={noop}
highlightedItem={contextEntry.id}
currentHighlightKey={null}
startDateExpression={''}
endDateExpression={''}
updateDateRange={noop}
startLiveStreaming={noop}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiModalBody>
</EuiModal>
</EuiOverlayMask>
);
};
const LogEntryContext: React.FC<{ context: LogEntry['context'] }> = ({ context }) => {
if ('container.id' in context) {
return <p>Displayed logs are from container {context['container.id']}</p>;
}
if ('host.name' in context) {
const shortenedFilePath =
context['log.file.path'].length > 45
? context['log.file.path'].slice(0, 20) + '...' + context['log.file.path'].slice(-25)
: context['log.file.path'];
return (
<EuiText size="s">
<p>
<EuiTextColor color="subdued">
Displayed logs are from file{' '}
<EuiToolTip content={context['log.file.path']}>
<span>{shortenedFilePath}</span>
</EuiToolTip>{' '}
and host {context['host.name']}
</EuiTextColor>
</p>
</EuiText>
);
}
return null;
};

View file

@ -0,0 +1,39 @@
/*
* 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 { useState, useEffect } from 'react';
import { throttle } from 'lodash';
interface ViewportDimensions {
width: number;
height: number;
}
const getViewportWidth = () =>
window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth;
const getViewportHeight = () =>
window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight;
export function useViewportDimensions(): ViewportDimensions {
const [dimensions, setDimensions] = useState<ViewportDimensions>({
width: getViewportWidth(),
height: getViewportHeight(),
});
useEffect(() => {
const updateDimensions = throttle(() => {
setDimensions({
width: getViewportWidth(),
height: getViewportHeight(),
});
}, 250);
window.addEventListener('resize', updateDimensions);
return () => window.removeEventListener('resize', updateDimensions);
}, []);
return dimensions;
}

View file

@ -156,14 +156,7 @@ export class InfraLogEntriesDomain {
}
}
),
context: FIELDS_FROM_CONTEXT.reduce<LogEntry['context']>((ctx, field) => {
// Users might have different types here in their mappings.
const value = doc.fields[field];
if (typeof value === 'string') {
ctx[field] = value;
}
return ctx;
}, {}),
context: getContextFromDoc(doc),
};
});
@ -352,3 +345,20 @@ const createHighlightQueryDsl = (phrase: string, fields: string[]) => ({
type: 'phrase',
},
});
const getContextFromDoc = (doc: LogEntryDocument): LogEntry['context'] => {
// Get all context fields, then test for the presence and type of the ones that go together
const containerId = doc.fields['container.id'];
const hostName = doc.fields['host.name'];
const logFilePath = doc.fields['log.file.path'];
if (typeof containerId === 'string') {
return { 'container.id': containerId };
}
if (typeof hostName === 'string' && typeof logFilePath === 'string') {
return { 'host.name': hostName, 'log.file.path': logFilePath };
}
return {};
};

View file

@ -126,7 +126,7 @@ export default function({ getService }: FtrProviderContext) {
expect(messageColumn.message.length).to.be.greaterThan(0);
});
it('Returns the context fields', async () => {
it('Does not build context if entry does not have all fields', async () => {
const { body } = await supertest
.post(LOG_ENTRIES_PATH)
.set(COMMON_HEADERS)
@ -147,9 +147,7 @@ export default function({ getService }: FtrProviderContext) {
const entries = logEntriesResponse.data.entries;
const entry = entries[0];
expect(entry.context).to.have.property('host.name');
expect(entry.context['host.name']).to.be('demo-stack-nginx-01');
expect(entry.context).to.eql({});
});
it('Paginates correctly with `after`', async () => {