mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[Logs UI] Actions menu in log entry categorization page (#69567)
This commit is contained in:
parent
0bae5d62c9
commit
21fc56ed10
8 changed files with 215 additions and 69 deletions
|
@ -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>;
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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({
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue