[Logs UI] Actions menu in log entry categorization page (#69567)

This commit is contained in:
Alejandro Fernández 2020-07-07 16:52:17 +02:00 committed by GitHub
parent 0bae5d62c9
commit 21fc56ed10
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 215 additions and 69 deletions

View file

@ -12,6 +12,7 @@ import {
timeRangeRT,
routeTimingMetadataRT,
} from '../../shared';
import { logEntryContextRT } from '../../log_entries';
export const LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORY_EXAMPLES_PATH =
'/api/infra/log_analysis/results/log_entry_category_examples';
@ -42,9 +43,12 @@ export type GetLogEntryCategoryExamplesRequestPayload = rt.TypeOf<
*/
const logEntryCategoryExampleRT = rt.type({
id: rt.string,
dataset: rt.string,
message: rt.string,
timestamp: rt.number,
tiebreaker: rt.number,
context: logEntryContextRT,
});
export type LogEntryCategoryExample = rt.TypeOf<typeof logEntryCategoryExampleRT>;

View file

@ -74,15 +74,17 @@ export const logMessageColumnRT = rt.type({
export const logColumnRT = rt.union([logTimestampColumnRT, logFieldColumnRT, logMessageColumnRT]);
export const logEntryContextRT = rt.union([
rt.type({}),
rt.type({ 'container.id': rt.string }),
rt.type({ 'host.name': rt.string, 'log.file.path': rt.string }),
]);
export const logEntryRT = rt.type({
id: rt.string,
cursor: logEntriesCursorRT,
columns: rt.array(logColumnRT),
context: rt.union([
rt.type({}),
rt.type({ 'container.id': rt.string }),
rt.type({ 'host.name': rt.string, 'log.file.path': rt.string }),
]),
context: logEntryContextRT,
});
export type LogMessageConstantPart = rt.TypeOf<typeof logMessageConstantPartRT>;
@ -92,6 +94,7 @@ export type LogTimestampColumn = rt.TypeOf<typeof logTimestampColumnRT>;
export type LogFieldColumn = rt.TypeOf<typeof logFieldColumnRT>;
export type LogMessageColumn = rt.TypeOf<typeof logMessageColumnRT>;
export type LogColumn = rt.TypeOf<typeof logColumnRT>;
export type LogEntryContext = rt.TypeOf<typeof logEntryContextRT>;
export type LogEntry = rt.TypeOf<typeof logEntryRT>;
export const logEntriesResponseRT = rt.type({

View file

@ -21,6 +21,8 @@ import {
StringTimeRange,
useLogEntryCategoriesResultsUrlState,
} from './use_log_entry_categories_results_url_state';
import { PageViewLogInContext } from '../stream/page_view_log_in_context';
import { ViewLogInContext } from '../../../containers/logs/view_log_in_context';
const JOB_STATUS_POLLING_INTERVAL = 30000;
@ -178,54 +180,61 @@ export const LogEntryCategoriesResultsContent: React.FunctionComponent<LogEntryC
);
return (
<ResultsContentPage>
<EuiFlexGroup direction="column">
<EuiFlexItem grow={false}>
<EuiPanel paddingSize="m">
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem />
<EuiFlexItem grow={false}>
<EuiSuperDatePicker
start={selectedTimeRange.startTime}
end={selectedTimeRange.endTime}
onTimeChange={handleSelectedTimeRangeChange}
isPaused={autoRefresh.isPaused}
refreshInterval={autoRefresh.interval}
onRefreshChange={handleAutoRefreshChange}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<CategoryJobNoticesSection
hasOutdatedJobConfigurations={hasOutdatedJobConfigurations}
hasOutdatedJobDefinitions={hasOutdatedJobDefinitions}
hasStoppedJobs={hasStoppedJobs}
isFirstUse={isFirstUse}
onRecreateMlJobForReconfiguration={viewSetupFlyoutForReconfiguration}
onRecreateMlJobForUpdate={viewSetupFlyoutForUpdate}
qualityWarnings={categoryQualityWarnings}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiPanel paddingSize="m">
<TopCategoriesSection
availableDatasets={logEntryCategoryDatasets}
isLoadingDatasets={isLoadingLogEntryCategoryDatasets}
isLoadingTopCategories={isLoadingTopLogEntryCategories}
jobId={jobIds['log-entry-categories-count']}
onChangeDatasetSelection={setCategoryQueryDatasets}
onRequestRecreateMlJob={viewSetupFlyoutForReconfiguration}
selectedDatasets={categoryQueryDatasets}
sourceId={sourceId}
timeRange={categoryQueryTimeRange.timeRange}
topCategories={topLogEntryCategories}
<ViewLogInContext.Provider
sourceId={sourceId}
startTimestamp={categoryQueryTimeRange.timeRange.startTime}
endTimestamp={categoryQueryTimeRange.timeRange.endTime}
>
<ResultsContentPage>
<EuiFlexGroup direction="column">
<EuiFlexItem grow={false}>
<EuiPanel paddingSize="m">
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem />
<EuiFlexItem grow={false}>
<EuiSuperDatePicker
start={selectedTimeRange.startTime}
end={selectedTimeRange.endTime}
onTimeChange={handleSelectedTimeRangeChange}
isPaused={autoRefresh.isPaused}
refreshInterval={autoRefresh.interval}
onRefreshChange={handleAutoRefreshChange}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<CategoryJobNoticesSection
hasOutdatedJobConfigurations={hasOutdatedJobConfigurations}
hasOutdatedJobDefinitions={hasOutdatedJobDefinitions}
hasStoppedJobs={hasStoppedJobs}
isFirstUse={isFirstUse}
onRecreateMlJobForReconfiguration={viewSetupFlyoutForReconfiguration}
onRecreateMlJobForUpdate={viewSetupFlyoutForUpdate}
qualityWarnings={categoryQualityWarnings}
/>
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>
</ResultsContentPage>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiPanel paddingSize="m">
<TopCategoriesSection
availableDatasets={logEntryCategoryDatasets}
isLoadingDatasets={isLoadingLogEntryCategoryDatasets}
isLoadingTopCategories={isLoadingTopLogEntryCategories}
jobId={jobIds['log-entry-categories-count']}
onChangeDatasetSelection={setCategoryQueryDatasets}
onRequestRecreateMlJob={viewSetupFlyoutForReconfiguration}
selectedDatasets={categoryQueryDatasets}
sourceId={sourceId}
timeRange={categoryQueryTimeRange.timeRange}
topCategories={topLogEntryCategories}
/>
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>
</ResultsContentPage>
<PageViewLogInContext />
</ViewLogInContext.Provider>
);
};

View file

@ -45,9 +45,13 @@ export const CategoryDetailsRow: React.FunctionComponent<{
{logEntryCategoryExamples.map((example, exampleIndex) => (
<CategoryExampleMessage
key={exampleIndex}
id={example.id}
dataset={example.dataset}
message={example.message}
timeRange={timeRange}
timestamp={example.timestamp}
tiebreaker={example.tiebreaker}
context={example.context}
/>
))}
</LogEntryExampleMessages>

View file

@ -4,9 +4,18 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useMemo } from 'react';
import React, { useMemo, useState, useCallback, useContext } from 'react';
import { i18n } from '@kbn/i18n';
import { encode } from 'rison-node';
import moment from 'moment';
import { getFriendlyNameForPartitionId } from '../../../../../../common/log_analysis';
import { LogEntry, LogEntryContext } from '../../../../../../common/http_api';
import { TimeRange } from '../../../../../../common/http_api/shared';
import {
getFriendlyNameForPartitionId,
partitionField,
} from '../../../../../../common/log_analysis';
import { ViewLogInContext } from '../../../../../containers/logs/view_log_in_context';
import {
LogEntryColumn,
LogEntryFieldColumn,
@ -15,15 +24,22 @@ import {
LogEntryTimestampColumn,
} from '../../../../../components/logging/log_text_stream';
import { LogColumnConfiguration } from '../../../../../utils/source_configuration';
import { LogEntryContextMenu } from '../../../../../components/logging/log_text_stream/log_entry_context_menu';
import { useLinkProps } from '../../../../../hooks/use_link_props';
export const exampleMessageScale = 'medium' as const;
export const exampleTimestampFormat = 'dateTime' as const;
export const CategoryExampleMessage: React.FunctionComponent<{
id: string;
dataset: string;
message: string;
timeRange: TimeRange;
timestamp: number;
}> = ({ dataset, message, timestamp }) => {
tiebreaker: number;
context: LogEntryContext;
}> = ({ id, dataset, message, timestamp, timeRange, tiebreaker, context }) => {
const [, { setContextEntry }] = useContext(ViewLogInContext.Context);
// the dataset must be encoded for the field column and the empty value must
// be turned into a user-friendly value
const encodedDatasetFieldValue = useMemo(
@ -31,8 +47,40 @@ export const CategoryExampleMessage: React.FunctionComponent<{
[dataset]
);
const [isHovered, setIsHovered] = useState<boolean>(false);
const setHovered = useCallback(() => setIsHovered(true), []);
const setNotHovered = useCallback(() => setIsHovered(false), []);
const [isMenuOpen, setIsMenuOpen] = useState<boolean>(false);
const openMenu = useCallback(() => setIsMenuOpen(true), []);
const closeMenu = useCallback(() => setIsMenuOpen(false), []);
const viewInStreamLinkProps = useLinkProps({
app: 'logs',
pathname: 'stream',
search: {
logPosition: encode({
end: moment(timeRange.endTime).format('YYYY-MM-DDTHH:mm:ss.SSSZ'),
position: { tiebreaker, time: timestamp },
start: moment(timeRange.startTime).format('YYYY-MM-DDTHH:mm:ss.SSSZ'),
streamLive: false,
}),
flyoutOptions: encode({
surroundingLogsId: id,
}),
logFilter: encode({
expression: `${partitionField}: ${dataset}`,
kind: 'kuery',
}),
},
});
return (
<LogEntryRowWrapper scale={exampleMessageScale}>
<LogEntryRowWrapper
scale={exampleMessageScale}
onMouseEnter={setHovered}
onMouseLeave={setNotHovered}
>
<LogEntryColumn {...columnWidths[timestampColumnId]}>
<LogEntryTimestampColumn format={exampleTimestampFormat} time={timestamp} />
</LogEntryColumn>
@ -60,6 +108,39 @@ export const CategoryExampleMessage: React.FunctionComponent<{
wrapMode="none"
/>
</LogEntryColumn>
<LogEntryColumn {...columnWidths[iconColumnId]}>
{isHovered || isMenuOpen ? (
<LogEntryContextMenu
isOpen={isMenuOpen}
onOpen={openMenu}
onClose={closeMenu}
items={[
{
label: i18n.translate('xpack.infra.logs.categoryExample.viewInStreamText', {
defaultMessage: 'View in stream',
}),
onClick: viewInStreamLinkProps.onClick!,
href: viewInStreamLinkProps.href,
},
{
label: i18n.translate('xpack.infra.logs.categoryExample.viewInContextText', {
defaultMessage: 'View in context',
}),
onClick: () => {
const logEntry: LogEntry = {
id,
context,
cursor: { time: timestamp, tiebreaker },
columns: [],
};
setContextEntry(logEntry);
},
},
]}
/>
) : null}
</LogEntryColumn>
</LogEntryRowWrapper>
);
};
@ -68,6 +149,7 @@ const noHighlights: never[] = [];
const timestampColumnId = 'category-example-timestamp-column' as const;
const messageColumnId = 'category-examples-message-column' as const;
const datasetColumnId = 'category-examples-dataset-column' as const;
const iconColumnId = 'category-examples-icon-column' as const;
const columnWidths = {
[timestampColumnId]: {
@ -85,7 +167,12 @@ const columnWidths = {
growWeight: 0,
shrinkWeight: 0,
// w_dataset + w_max_anomaly + w_expand - w_padding = 200 px + 160 px + 40 px + 40 px - 8 px
baseWidth: '432px',
baseWidth: '400px',
},
[iconColumnId]: {
growWeight: 0,
shrinkWeight: 0,
baseWidth: '32px',
},
};

View file

@ -5,6 +5,7 @@
*/
import type { ILegacyScopedClusterClient } from 'src/core/server';
import { LogEntryContext } from '../../../common/http_api';
import {
compareDatasetsByMaximumAnomalyScore,
getJobId,
@ -43,6 +44,7 @@ import {
createTopLogEntryCategoriesQuery,
topLogEntryCategoriesResponseRT,
} from './queries/top_log_entry_categories';
import { InfraSource } from '../sources';
const COMPOSITE_AGGREGATION_BATCH_SIZE = 1000;
@ -197,7 +199,8 @@ export async function getLogEntryCategoryExamples(
startTime: number,
endTime: number,
categoryId: number,
exampleCount: number
exampleCount: number,
sourceConfiguration: InfraSource
) {
const finalizeLogEntryCategoryExamplesSpan = startTracingSpan('get category example log entries');
@ -215,6 +218,7 @@ export async function getLogEntryCategoryExamples(
const customSettings = decodeOrThrow(jobCustomSettingsRT)(mlJob.custom_settings);
const indices = customSettings?.logs_source_config?.indexPattern;
const timestampField = customSettings?.logs_source_config?.timestampField;
const tiebreakerField = sourceConfiguration.configuration.fields.tiebreaker;
if (indices == null || timestampField == null) {
throw new InsufficientLogAnalysisMlJobConfigurationError(
@ -239,6 +243,7 @@ export async function getLogEntryCategoryExamples(
context,
indices,
timestampField,
tiebreakerField,
startTime,
endTime,
category._source.terms,
@ -475,6 +480,7 @@ async function fetchLogEntryCategoryExamples(
requestContext: { core: { elasticsearch: { legacy: { client: ILegacyScopedClusterClient } } } },
indices: string,
timestampField: string,
tiebreakerField: string,
startTime: number,
endTime: number,
categoryQuery: string,
@ -490,6 +496,7 @@ async function fetchLogEntryCategoryExamples(
createLogEntryCategoryExamplesQuery(
indices,
timestampField,
tiebreakerField,
startTime,
endTime,
categoryQuery,
@ -502,9 +509,12 @@ async function fetchLogEntryCategoryExamples(
return {
examples: hits.map((hit) => ({
id: hit._id,
dataset: hit._source.event?.dataset ?? '',
message: hit._source.message ?? '',
timestamp: hit.sort[0],
tiebreaker: hit.sort[1],
context: getContextFromSource(hit._source),
})),
timing: {
spans: [esSearchSpan],
@ -514,6 +524,22 @@ async function fetchLogEntryCategoryExamples(
const parseCategoryId = (rawCategoryId: string) => parseInt(rawCategoryId, 10);
const getContextFromSource = (source: any): LogEntryContext => {
const containerId = source.container?.id;
const hostName = source.host?.name;
const logFilePath = source.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 {};
};
interface HistogramParameters {
id: string;
startTime: number;

View file

@ -12,6 +12,7 @@ import { defaultRequestParameters } from './common';
export const createLogEntryCategoryExamplesQuery = (
indices: string,
timestampField: string,
tiebreakerField: string,
startTime: number,
endTime: number,
categoryQuery: string,
@ -41,27 +42,33 @@ export const createLogEntryCategoryExamplesQuery = (
],
},
},
sort: [
{
[timestampField]: {
order: 'asc',
},
},
],
sort: [{ [timestampField]: 'asc' }, { [tiebreakerField]: 'asc' }],
},
_source: ['event.dataset', 'message'],
_source: ['event.dataset', 'message', 'container.id', 'host.name', 'log.file.path'],
index: indices,
size: exampleCount,
});
export const logEntryCategoryExampleHitRT = rt.type({
_id: rt.string,
_source: rt.partial({
event: rt.partial({
dataset: rt.string,
}),
message: rt.string,
container: rt.partial({
id: rt.string,
}),
host: rt.partial({
name: rt.string,
}),
log: rt.partial({
file: rt.partial({
path: rt.string,
}),
}),
}),
sort: rt.tuple([rt.number]),
sort: rt.tuple([rt.number, rt.number]),
});
export type LogEntryCategoryExampleHit = rt.TypeOf<typeof logEntryCategoryExampleHitRT>;

View file

@ -18,7 +18,7 @@ import {
} from '../../../lib/log_analysis';
import { assertHasInfraMlPlugins } from '../../../utils/request_context';
export const initGetLogEntryCategoryExamplesRoute = ({ framework }: InfraBackendLibs) => {
export const initGetLogEntryCategoryExamplesRoute = ({ framework, sources }: InfraBackendLibs) => {
framework.registerRoute(
{
method: 'post',
@ -37,6 +37,11 @@ export const initGetLogEntryCategoryExamplesRoute = ({ framework }: InfraBackend
},
} = request.body;
const sourceConfiguration = await sources.getSourceConfiguration(
requestContext.core.savedObjects.client,
sourceId
);
try {
assertHasInfraMlPlugins(requestContext);
@ -46,7 +51,8 @@ export const initGetLogEntryCategoryExamplesRoute = ({ framework }: InfraBackend
startTime,
endTime,
categoryId,
exampleCount
exampleCount,
sourceConfiguration
);
return response.ok({