[ML] Log pattern analysis UI (#139005)

* [ML] Log caegorization UI POC

* improvements

* code structure changes

* further refactoring

* fixes after merge with main

* fixes after merging with main

* adding table pagination

* catching category request errors

* small refactor

* fixes after merge with main

* further fixes are merge with main

* showing errors in toast

* updating breadcrumbs

* translations and removing unused files

* changing case of side nav items

* translations

* updating actions

* fixing unused variable

* translations

* adding comment

* small changes based on review

* improving search request type

* capitalizing discover

* changes based on review

* fixing chart tooltip

* fixing examples from fields containing dots

* fixing breadcrumbs

* [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix'

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
James Gowdy 2022-09-16 10:31:41 +01:00 committed by GitHub
parent 78f00e12af
commit 0bfc3fd41a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 1362 additions and 27 deletions

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { categorizeSchema } from './schema';
export type { CategorizeSchema } from './schema';

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { schema, TypeOf } from '@kbn/config-schema';
export const categorizeSchema = schema.object({
index: schema.string(),
field: schema.string(),
timeField: schema.string(),
to: schema.number(),
from: schema.number(),
query: schema.any(),
intervalMs: schema.maybe(schema.number()),
});
export type CategorizeSchema = TypeOf<typeof categorizeSchema>;

View file

@ -185,3 +185,9 @@ export const extractErrorProperties = (error: ErrorType): AiOpsErrorObject => {
message: '',
};
};
export const extractErrorMessage = (error: ErrorType): string => {
// extract only the error message within the response error coming from Kibana, Elasticsearch, and our own ML messages
const errorObj = extractErrorProperties(error);
return errorObj.message;
};

View file

@ -27,7 +27,6 @@ import { DualBrush, DualBrushAnnotation } from '@kbn/aiops-components';
import { getSnappedWindowParameters, getWindowParameters } from '@kbn/aiops-utils';
import type { WindowParameters } from '@kbn/aiops-utils';
import { MULTILAYER_TIME_AXIS_STYLE } from '@kbn/charts-plugin/common';
import type { ChangePoint } from '@kbn/ml-agg-utils';
import { useAiopsAppContext } from '../../../hooks/use_aiops_app_context';
@ -48,14 +47,14 @@ export interface DocumentCountChartPoint {
}
interface DocumentCountChartProps {
brushSelectionUpdateHandler: (d: WindowParameters, force: boolean) => void;
brushSelectionUpdateHandler?: (d: WindowParameters, force: boolean) => void;
width?: number;
chartPoints: DocumentCountChartPoint[];
chartPointsSplit?: DocumentCountChartPoint[];
timeRangeEarliest: number;
timeRangeLatest: number;
interval: number;
changePoint?: ChangePoint;
chartPointsSplitLabel: string;
isBrushCleared: boolean;
}
@ -100,7 +99,7 @@ export const DocumentCountChart: FC<DocumentCountChartProps> = ({
timeRangeEarliest,
timeRangeLatest,
interval,
changePoint,
chartPointsSplitLabel,
isBrushCleared,
}) => {
const { data, uiSettings, fieldFormats, charts } = useAiopsAppContext();
@ -125,8 +124,6 @@ export const DocumentCountChart: FC<DocumentCountChartProps> = ({
}
);
const splitSeriesName = `${changePoint?.fieldName}:${changePoint?.fieldValue}`;
// TODO Let user choose between ZOOM and BRUSH mode.
const [viewMode] = useState<VIEW_MODE>(VIEW_MODE.BRUSH);
@ -198,6 +195,9 @@ export const DocumentCountChart: FC<DocumentCountChartProps> = ({
};
const onElementClick: ElementClickListener = ([elementData]) => {
if (brushSelectionUpdateHandler === undefined) {
return;
}
const startRange = (elementData as XYChartElementEvent)[0].x;
const range = {
@ -245,6 +245,9 @@ export const DocumentCountChart: FC<DocumentCountChartProps> = ({
}, [isBrushCleared, originalWindowParameters]);
function onWindowParametersChange(wp: WindowParameters, wpPx: WindowParameters) {
if (brushSelectionUpdateHandler === undefined) {
return;
}
setWindowParameters(wp);
setWindowParametersAsPixels(wpPx);
brushSelectionUpdateHandler(wp, false);
@ -360,7 +363,7 @@ export const DocumentCountChart: FC<DocumentCountChartProps> = ({
{chartPointsSplit && (
<HistogramBarSeries
id={`${SPEC_ID}_split`}
name={splitSeriesName}
name={chartPointsSplitLabel}
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
xAccessor="time"

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useEffect, useState, FC } from 'react';
import React, { useEffect, useState, FC, useMemo } from 'react';
import { min, max } from 'd3-array';
import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
@ -53,6 +53,10 @@ export const DocumentCountContent: FC<DocumentCountContentProps> = ({
const bucketTimestamps = Object.keys(documentCountStats?.buckets ?? {}).map((time) => +time);
const timeRangeEarliest = min(bucketTimestamps);
const timeRangeLatest = max(bucketTimestamps);
const chartPointsSplitLabel = useMemo(
() => `${changePoint?.fieldName}:${changePoint?.fieldValue}`,
[changePoint]
);
if (
documentCountStats === undefined ||
@ -118,7 +122,7 @@ export const DocumentCountContent: FC<DocumentCountContentProps> = ({
timeRangeEarliest={timeRangeEarliest}
timeRangeLatest={timeRangeLatest}
interval={documentCountStats.interval}
changePoint={changePoint}
chartPointsSplitLabel={chartPointsSplitLabel}
isBrushCleared={isBrushCleared}
/>
)}

View file

@ -0,0 +1,270 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import type { TimefilterContract } from '@kbn/data-plugin/public';
import {
EuiButton,
EuiSpacer,
EuiFlexGroup,
EuiFlexItem,
EuiInMemoryTable,
EuiBasicTableColumn,
EuiCode,
EuiText,
EuiTableSelectionType,
} from '@elastic/eui';
import { useDiscoverLinks } from '../use_discover_links';
import { MiniHistogram } from '../../mini_histogram';
import { useEuiTheme } from '../../../hooks/use_eui_theme';
import type { AiOpsIndexBasedAppState } from '../../explain_log_rate_spikes/explain_log_rate_spikes_app_state';
import type { EventRate, Category, SparkLinesPerCategory } from '../use_categorize_request';
import { useTableState } from './use_table_state';
const QUERY_MODE = {
INCLUDE: 'should',
EXCLUDE: 'must_not',
} as const;
export type QueryMode = typeof QUERY_MODE[keyof typeof QUERY_MODE];
interface Props {
categories: Category[];
sparkLines: SparkLinesPerCategory;
eventRate: EventRate;
dataViewId: string;
selectedField: string | undefined;
timefilter: TimefilterContract;
aiopsListState: Required<AiOpsIndexBasedAppState>;
pinnedCategory: Category | null;
setPinnedCategory: (category: Category | null) => void;
selectedCategory: Category | null;
setSelectedCategory: (category: Category | null) => void;
}
export const CategoryTable: FC<Props> = ({
categories,
sparkLines,
eventRate,
dataViewId,
selectedField,
timefilter,
aiopsListState,
pinnedCategory,
setPinnedCategory,
selectedCategory,
setSelectedCategory,
}) => {
const euiTheme = useEuiTheme();
const { openInDiscoverWithFilter } = useDiscoverLinks();
const [selectedCategories, setSelectedCategories] = useState<Category[]>([]);
const { onTableChange, pagination, sorting } = useTableState<Category>(categories ?? [], 'key');
const openInDiscover = (mode: QueryMode, category?: Category) => {
const timefilterActiveBounds = timefilter.getActiveBounds();
if (timefilterActiveBounds === undefined || selectedField === undefined) {
return;
}
openInDiscoverWithFilter(
dataViewId,
selectedField,
selectedCategories,
aiopsListState,
timefilterActiveBounds,
mode,
category
);
};
const columns: Array<EuiBasicTableColumn<Category>> = [
{
field: 'count',
name: i18n.translate('xpack.aiops.logCategorization.column.count', {
defaultMessage: 'Count',
}),
sortable: true,
width: '80px',
},
{
field: 'count',
name: i18n.translate('xpack.aiops.logCategorization.column.logRate', {
defaultMessage: 'Log rate',
}),
sortable: true,
width: '100px',
render: (_, { key }) => {
const sparkLine = sparkLines[key];
if (sparkLine === undefined) {
return null;
}
const histogram = eventRate.map((e) => ({
doc_count_overall: e.docCount,
doc_count_change_point: sparkLine[e.key],
key: e.key,
key_as_string: `${e.key}`,
}));
return (
<MiniHistogram
chartData={histogram}
isLoading={categories === null && histogram === undefined}
label={''}
/>
);
},
},
{
field: 'examples',
name: i18n.translate('xpack.aiops.logCategorization.column.examples', {
defaultMessage: 'Examples',
}),
sortable: true,
style: { display: 'block' },
render: (examples: string[]) => (
<div style={{ display: 'block' }}>
{examples.map((e) => (
<>
<EuiText size="s">
<EuiCode language="log" transparentBackground>
{e}
</EuiCode>
</EuiText>
<EuiSpacer size="s" />
</>
))}
</div>
),
},
{
name: 'Actions',
width: '60px',
actions: [
{
name: i18n.translate('xpack.aiops.logCategorization.showInDiscover', {
defaultMessage: 'Show these in Discover',
}),
description: i18n.translate('xpack.aiops.logCategorization.showInDiscover', {
defaultMessage: 'Show these in Discover',
}),
icon: 'discoverApp',
type: 'icon',
onClick: (category) => openInDiscover(QUERY_MODE.INCLUDE, category),
},
{
name: i18n.translate('xpack.aiops.logCategorization.filterOutInDiscover', {
defaultMessage: 'Filter out in Discover',
}),
description: i18n.translate('xpack.aiops.logCategorization.filterOutInDiscover', {
defaultMessage: 'Filter out in Discover',
}),
icon: 'filter',
type: 'icon',
onClick: (category) => openInDiscover(QUERY_MODE.EXCLUDE, category),
},
// Disabled for now
// {
// name: i18n.translate('xpack.aiops.logCategorization.openInDataViz', {
// defaultMessage: 'Open in data visualizer',
// }),
// icon: 'stats',
// type: 'icon',
// onClick: () => {},
// },
],
},
] as Array<EuiBasicTableColumn<Category>>;
const selectionValue: EuiTableSelectionType<Category> | undefined = {
selectable: () => true,
onSelectionChange: (selectedItems) => setSelectedCategories(selectedItems),
};
const getRowStyle = (category: Category) => {
if (
pinnedCategory &&
pinnedCategory.key === category.key &&
pinnedCategory.key === category.key
) {
return {
backgroundColor: 'rgb(227,240,249,0.37)',
};
}
if (
selectedCategory &&
selectedCategory.key === category.key &&
selectedCategory.key === category.key
) {
return {
backgroundColor: euiTheme.euiColorLightestShade,
};
}
return {
backgroundColor: 'white',
};
};
return (
<>
{selectedCategories.length > 0 ? (
<>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiButton size="s" onClick={() => openInDiscover(QUERY_MODE.INCLUDE)}>
<FormattedMessage
id="xpack.aiops.logCategorization.showInDiscover"
defaultMessage="Show these in Discover"
/>
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton size="s" onClick={() => openInDiscover(QUERY_MODE.EXCLUDE)}>
<FormattedMessage
id="xpack.aiops.logCategorization.filterOutInDiscover"
defaultMessage="Filter out in Discover"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</>
) : null}
<EuiInMemoryTable<Category>
compressed
items={categories}
columns={columns}
isSelectable={true}
selection={selectionValue}
itemId="key"
onTableChange={onTableChange}
pagination={pagination}
sorting={sorting}
rowProps={(category) => {
return {
onClick: () => {
if (category.key === pinnedCategory?.key) {
setPinnedCategory(null);
} else {
setPinnedCategory(category);
}
},
onMouseEnter: () => {
setSelectedCategory(category);
},
onMouseLeave: () => {
setSelectedCategory(null);
},
style: getRowStyle(category),
};
}}
/>
</>
);
};

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { CategoryTable } from './category_table';
export type { QueryMode } from './category_table';

View file

@ -0,0 +1,46 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useState } from 'react';
import { EuiInMemoryTable, Direction, Pagination } from '@elastic/eui';
export function useTableState<T>(items: T[], initialSortField: string) {
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const [sortField, setSortField] = useState<string>(initialSortField);
const [sortDirection, setSortDirection] = useState<Direction>('asc');
const onTableChange: EuiInMemoryTable<T>['onTableChange'] = ({
page = { index: 0, size: 10 },
sort = { field: sortField, direction: sortDirection },
}) => {
const { index, size } = page;
setPageIndex(index);
setPageSize(size);
const { field, direction } = sort;
setSortField(field as string);
setSortDirection(direction as Direction);
};
const pagination: Pagination = {
pageIndex,
pageSize,
totalItemCount: (items ?? []).length,
pageSizeOptions: [10, 20, 50],
showPerPageOptions: true,
};
const sorting = {
sort: {
field: sortField,
direction: sortDirection,
},
};
return { onTableChange, pagination, sorting, setPageIndex };
}

View file

@ -0,0 +1,82 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { DocumentCountChart as DocumentCountChartRoot } from '../document_count_content/document_count_chart';
import { TotalCountHeader } from '../document_count_content/total_count_header';
import type { Category, SparkLinesPerCategory } from './use_categorize_request';
import type { EventRate } from './use_categorize_request';
import { DocumentCountStats } from '../../get_document_stats';
interface Props {
totalCount: number;
pinnedCategory: Category | null;
selectedCategory: Category | null;
eventRate: EventRate;
sparkLines: SparkLinesPerCategory;
documentCountStats?: DocumentCountStats;
}
export const DocumentCountChart: FC<Props> = ({
eventRate,
sparkLines,
totalCount,
pinnedCategory,
selectedCategory,
documentCountStats,
}) => {
const chartPointsSplitLabel = i18n.translate(
'xpack.aiops.logCategorization.chartPointsSplitLabel',
{
defaultMessage: 'Selected category',
}
);
const chartPoints = useMemo(() => {
const category = selectedCategory ?? pinnedCategory ?? null;
return eventRate.map(({ key, docCount }) => {
let value = docCount;
if (category && sparkLines[category.key] && sparkLines[category.key][key]) {
value -= sparkLines[category.key][key];
}
return { time: key, value };
});
}, [eventRate, pinnedCategory, selectedCategory, sparkLines]);
const chartPointsSplit = useMemo(() => {
const category = selectedCategory ?? pinnedCategory ?? null;
return category !== null
? eventRate.map(({ key }) => {
const value =
sparkLines && sparkLines[category.key] && sparkLines[category.key][key]
? sparkLines[category.key][key]
: 0;
return { time: key, value };
})
: undefined;
}, [eventRate, pinnedCategory, selectedCategory, sparkLines]);
if (documentCountStats?.interval === undefined) {
return null;
}
return (
<>
<TotalCountHeader totalCount={totalCount} />
<DocumentCountChartRoot
chartPoints={chartPoints}
chartPointsSplit={chartPointsSplit}
timeRangeEarliest={eventRate[0].key}
timeRangeLatest={eventRate[eventRate.length - 1].key}
interval={documentCountStats.interval}
chartPointsSplitLabel={chartPointsSplitLabel}
isBrushCleared={false}
/>
</>
);
};

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export type { LogCategorizationAppStateProps } from './log_categorization_app_state';
import { LogCategorizationAppState } from './log_categorization_app_state';
// required for dynamic import using React.lazy()
// eslint-disable-next-line import/no-default-export
export default LogCategorizationAppState;

View file

@ -0,0 +1,31 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC } from 'react';
import type { SavedSearch } from '@kbn/discover-plugin/public';
import type { DataView } from '@kbn/data-views-plugin/public';
import { LogCategorizationPage } from './log_categorization_page';
import { SavedSearchSavedObject } from '../../application/utils/search_utils';
import type { AiopsAppDependencies } from '../../hooks/use_aiops_app_context';
import { AiopsAppContext } from '../../hooks/use_aiops_app_context';
export interface LogCategorizationAppStateProps {
dataView: DataView;
savedSearch: SavedSearch | SavedSearchSavedObject | null;
appDependencies: AiopsAppDependencies;
}
export const LogCategorizationAppState: FC<LogCategorizationAppStateProps> = ({
dataView,
savedSearch,
appDependencies,
}) => {
return (
<AiopsAppContext.Provider value={appDependencies}>
<LogCategorizationPage dataView={dataView} savedSearch={savedSearch} />
</AiopsAppContext.Provider>
);
};

View file

@ -0,0 +1,333 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC, useState, useEffect, useCallback, useMemo } from 'react';
import type { SavedSearch } from '@kbn/discover-plugin/public';
import type { DataView } from '@kbn/data-views-plugin/public';
import { Filter, Query } from '@kbn/es-query';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiButton,
EuiSpacer,
EuiFlexGroup,
EuiFlexItem,
EuiPageBody,
EuiPageContentHeader_Deprecated as EuiPageContentHeader,
EuiPageContentHeaderSection_Deprecated as EuiPageContentHeaderSection,
EuiTitle,
EuiComboBox,
EuiComboBoxOptionOption,
EuiFormRow,
EuiLoadingContent,
} from '@elastic/eui';
import { FullTimeRangeSelector } from '../full_time_range_selector';
import { DatePickerWrapper } from '../date_picker_wrapper';
import { useData } from '../../hooks/use_data';
import { SearchPanel } from '../search_panel';
import type {
SearchQueryLanguage,
SavedSearchSavedObject,
} from '../../application/utils/search_utils';
import { useUrlState } from '../../hooks/use_url_state';
import { useAiopsAppContext } from '../../hooks/use_aiops_app_context';
import { restorableDefaults } from '../explain_log_rate_spikes/explain_log_rate_spikes_app_state';
import { useCategorizeRequest } from './use_categorize_request';
import type { EventRate, Category, SparkLinesPerCategory } from './use_categorize_request';
import { CategoryTable } from './category_table';
import { DocumentCountChart } from './document_count_chart';
export interface LogCategorizationPageProps {
dataView: DataView;
savedSearch: SavedSearch | SavedSearchSavedObject | null;
}
const BAR_TARGET = 20;
export const LogCategorizationPage: FC<LogCategorizationPageProps> = ({
dataView,
savedSearch,
}) => {
const {
notifications: { toasts },
} = useAiopsAppContext();
const { runCategorizeRequest, cancelRequest } = useCategorizeRequest();
const [aiopsListState, setAiopsListState] = useState(restorableDefaults);
const [globalState, setGlobalState] = useUrlState('_g');
const [selectedField, setSelectedField] = useState<string | undefined>();
const [selectedCategory, setSelectedCategory] = useState<Category | null>(null);
const [categories, setCategories] = useState<Category[] | null>(null);
const [currentSavedSearch, setCurrentSavedSearch] = useState(savedSearch);
const [loading, setLoading] = useState(false);
const [totalCount, setTotalCount] = useState(0);
const [eventRate, setEventRate] = useState<EventRate>([]);
const [pinnedCategory, setPinnedCategory] = useState<Category | null>(null);
const [sparkLines, setSparkLines] = useState<SparkLinesPerCategory>({});
useEffect(
function cancelRequestOnLeave() {
return () => {
cancelRequest();
};
},
[cancelRequest]
);
const setSearchParams = useCallback(
(searchParams: {
searchQuery: Query['query'];
searchString: Query['query'];
queryLanguage: SearchQueryLanguage;
filters: Filter[];
}) => {
// When the user loads saved search and then clear or modify the query
// we should remove the saved search and replace it with the index pattern id
if (currentSavedSearch !== null) {
setCurrentSavedSearch(null);
}
setAiopsListState({
...aiopsListState,
searchQuery: searchParams.searchQuery,
searchString: searchParams.searchString,
searchQueryLanguage: searchParams.queryLanguage,
filters: searchParams.filters,
});
},
[currentSavedSearch, aiopsListState, setAiopsListState]
);
const {
documentStats,
timefilter,
earliest,
latest,
searchQueryLanguage,
searchString,
searchQuery,
intervalMs,
} = useData(
{ currentDataView: dataView, currentSavedSearch },
aiopsListState,
setGlobalState,
undefined,
BAR_TARGET
);
useEffect(() => {
if (globalState?.time !== undefined) {
timefilter.setTime({
from: globalState.time.from,
to: globalState.time.to,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(globalState?.time), timefilter]);
const fields = useMemo(
() =>
dataView.fields
.filter(
({ displayName, esTypes, count }) =>
esTypes && esTypes.includes('text') && !['_id', '_index'].includes(displayName)
)
.map(({ displayName }) => ({
label: displayName,
})),
[dataView]
);
useEffect(
function setSingleFieldAsSelected() {
if (fields.length === 1) {
setSelectedField(fields[0].label);
}
},
[fields]
);
useEffect(() => {
if (documentStats.documentCountStats?.buckets) {
setEventRate(
Object.entries(documentStats.documentCountStats.buckets).map(([key, docCount]) => ({
key: +key,
docCount,
}))
);
setTotalCount(documentStats.totalCount);
}
}, [documentStats, earliest, latest, searchQueryLanguage, searchString, searchQuery]);
const loadCategories = useCallback(async () => {
setLoading(true);
setCategories(null);
const { title: index, timeFieldName: timeField } = dataView;
if (selectedField === undefined || timeField === undefined) {
return;
}
cancelRequest();
try {
const resp = await runCategorizeRequest(
index,
selectedField,
timeField,
earliest,
latest,
searchQuery,
intervalMs
);
setCategories(resp.categories);
setSparkLines(resp.sparkLinesPerCategory);
} catch (error) {
toasts.addError(error, {
title: i18n.translate('xpack.aiops.logCategorization.errorLoadingCategories', {
defaultMessage: 'Error loading categories',
}),
});
}
setLoading(false);
}, [
selectedField,
dataView,
searchQuery,
earliest,
latest,
runCategorizeRequest,
cancelRequest,
intervalMs,
toasts,
]);
const onFieldChange = (value: EuiComboBoxOptionOption[] | undefined) => {
setSelectedField(value && value.length ? value[0].label : undefined);
};
return (
<EuiPageBody data-test-subj="aiopsExplainLogRateSpikesPage" paddingSize="none" panelled={false}>
<EuiFlexGroup gutterSize="none">
<EuiFlexItem>
<EuiPageContentHeader className="aiopsPageHeader">
<EuiPageContentHeaderSection>
<div className="dataViewTitleHeader">
<EuiTitle size="s">
<h2>{dataView.getName()}</h2>
</EuiTitle>
</div>
</EuiPageContentHeaderSection>
<EuiFlexGroup
alignItems="center"
justifyContent="flexEnd"
gutterSize="s"
data-test-subj="aiopsTimeRangeSelectorSection"
>
{dataView.timeFieldName !== undefined && (
<EuiFlexItem grow={false}>
<FullTimeRangeSelector
dataView={dataView}
query={undefined}
disabled={false}
timefilter={timefilter}
/>
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<DatePickerWrapper />
</EuiFlexItem>
</EuiFlexGroup>
</EuiPageContentHeader>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
<EuiFlexGroup gutterSize="none">
<EuiFlexItem>
<SearchPanel
dataView={dataView}
searchString={searchString ?? ''}
searchQuery={searchQuery}
searchQueryLanguage={searchQueryLanguage}
setSearchParams={setSearchParams}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFlexGroup gutterSize="none">
<EuiFlexItem grow={false} css={{ minWidth: '410px' }}>
<EuiFormRow
label={i18n.translate('xpack.aiops.logCategorization.categoryFieldSelect', {
defaultMessage: 'Category field',
})}
>
<EuiComboBox
isDisabled={loading === true}
options={fields}
onChange={onFieldChange}
selectedOptions={selectedField === undefined ? undefined : [{ label: selectedField }]}
singleSelection={{ asPlainText: true }}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow={false} css={{ marginTop: 'auto' }}>
{loading === false ? (
<EuiButton
disabled={selectedField === undefined}
onClick={() => {
loadCategories();
}}
>
<FormattedMessage
id="xpack.aiops.logCategorization.runButton"
defaultMessage="Run categorization"
/>
</EuiButton>
) : (
<EuiButton onClick={() => cancelRequest()}>Cancel</EuiButton>
)}
</EuiFlexItem>
<EuiFlexItem grow={false} css={{ marginTop: 'auto' }} />
<EuiFlexItem />
</EuiFlexGroup>
{eventRate.length ? (
<>
<EuiSpacer />
<DocumentCountChart
eventRate={eventRate}
pinnedCategory={pinnedCategory}
selectedCategory={selectedCategory}
sparkLines={sparkLines}
totalCount={totalCount}
documentCountStats={documentStats.documentCountStats}
/>
<EuiSpacer />
</>
) : null}
{loading === true ? <EuiLoadingContent lines={10} /> : null}
{categories !== null ? (
<CategoryTable
categories={categories}
aiopsListState={aiopsListState}
dataViewId={dataView.id!}
eventRate={eventRate}
sparkLines={sparkLines}
selectedField={selectedField}
pinnedCategory={pinnedCategory}
setPinnedCategory={setPinnedCategory}
selectedCategory={selectedCategory}
setSelectedCategory={setSelectedCategory}
timefilter={timefilter}
/>
) : null}
</EuiPageBody>
);
};

View file

@ -0,0 +1,201 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { cloneDeep, get } from 'lodash';
import { useRef, useCallback } from 'react';
import { isCompleteResponse, isErrorResponse } from '@kbn/data-plugin/public';
import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
import { useAiopsAppContext } from '../../hooks/use_aiops_app_context';
const CATEGORY_LIMIT = 1000;
const EXAMPLE_LIMIT = 1;
interface CatResponse {
rawResponse: {
aggregations: {
categories: {
buckets: Array<{
key: string;
doc_count: number;
hit: { hits: { hits: Array<{ _source: { message: string } }> } };
sparkline: { buckets: Array<{ key_as_string: string; key: number; doc_count: number }> };
}>;
};
};
};
}
export interface Category {
key: string;
count: number;
examples: string[];
sparkline?: Array<{ doc_count: number; key: number; key_as_string: string }>;
}
export type EventRate = Array<{
key: number;
docCount: number;
}>;
export type SparkLinesPerCategory = Record<string, Record<number, number>>;
export function useCategorizeRequest() {
const { data } = useAiopsAppContext();
const abortController = useRef(new AbortController());
const runCategorizeRequest = useCallback(
(
index: string,
field: string,
timeField: string,
from: number | undefined,
to: number | undefined,
query: QueryDslQueryContainer,
intervalMs?: number
): Promise<{ categories: Category[]; sparkLinesPerCategory: SparkLinesPerCategory }> => {
return new Promise((resolve, reject) => {
data.search
.search<ReturnType<typeof createCategoryRequest>, CatResponse>(
createCategoryRequest(index, field, timeField, from, to, query, intervalMs),
{ abortSignal: abortController.current.signal }
)
.subscribe({
next: (result) => {
if (isCompleteResponse(result)) {
resolve(processCategoryResults(result, field));
} else if (isErrorResponse(result)) {
reject(result);
} else {
// partial results
// Ignore partial results for now.
// An issue with the search function means partial results are not being returned correctly.
}
},
error: (error) => {
if (error.name === 'AbortError') {
return resolve({ categories: [], sparkLinesPerCategory: {} });
}
reject(error);
},
});
});
},
[data.search]
);
const cancelRequest = useCallback(() => {
abortController.current.abort();
abortController.current = new AbortController();
}, []);
return { runCategorizeRequest, cancelRequest };
}
function createCategoryRequest(
index: string,
field: string,
timeField: string,
from: number | undefined,
to: number | undefined,
queryIn: QueryDslQueryContainer,
intervalMs?: number
) {
const query = cloneDeep(queryIn);
if (query.bool === undefined) {
query.bool = {};
}
if (query.bool.must === undefined) {
query.bool.must = [];
if (query.match_all !== undefined) {
query.bool.must.push({ match_all: query.match_all });
delete query.match_all;
}
}
if (query.multi_match !== undefined) {
query.bool.should = {
multi_match: query.multi_match,
};
delete query.multi_match;
}
(query.bool.must as QueryDslQueryContainer[]).push({
range: {
[timeField]: {
gte: from,
lte: to,
format: 'epoch_millis',
},
},
});
return {
params: {
index,
size: 0,
body: {
query,
aggs: {
categories: {
categorize_text: {
field,
size: CATEGORY_LIMIT,
},
aggs: {
hit: {
top_hits: {
size: EXAMPLE_LIMIT,
sort: [timeField],
_source: field,
},
},
...(intervalMs
? {
sparkline: {
date_histogram: {
field: timeField,
fixed_interval: `${intervalMs}ms`,
},
},
}
: {}),
},
},
},
},
},
};
}
function processCategoryResults(result: CatResponse, field: string) {
const sparkLinesPerCategory: SparkLinesPerCategory = {};
if (result.rawResponse.aggregations === undefined) {
throw new Error('processCategoryResults failed, did not return aggregations.');
}
const categories: Category[] = result.rawResponse.aggregations.categories.buckets.map((b) => {
sparkLinesPerCategory[b.key] =
b.sparkline === undefined
? {}
: b.sparkline.buckets.reduce<Record<number, number>>((acc2, cur2) => {
acc2[cur2.key] = cur2.doc_count;
return acc2;
}, {});
return {
key: b.key,
count: b.doc_count,
examples: b.hit.hits.hits.map((h) => get(h._source, field)),
};
});
return {
categories,
sparkLinesPerCategory,
};
}

View file

@ -0,0 +1,76 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import rison from 'rison-node';
import moment from 'moment';
import type { TimeRangeBounds } from '@kbn/data-plugin/common';
import { useAiopsAppContext } from '../../hooks/use_aiops_app_context';
import type { Category } from './use_categorize_request';
import type { QueryMode } from './category_table';
import type { AiOpsIndexBasedAppState } from '../explain_log_rate_spikes/explain_log_rate_spikes_app_state';
export function useDiscoverLinks() {
const {
http: { basePath },
} = useAiopsAppContext();
const openInDiscoverWithFilter = (
index: string,
field: string,
selection: Category[],
aiopsListState: Required<AiOpsIndexBasedAppState>,
timefilterActiveBounds: TimeRangeBounds,
mode: QueryMode,
category?: Category
) => {
const selectedRows = category === undefined ? selection : [category];
const _g = rison.encode({
time: {
from: moment(timefilterActiveBounds.min?.valueOf()).toISOString(),
to: moment(timefilterActiveBounds.max?.valueOf()).toISOString(),
},
});
const _a = rison.encode({
filters: [
...aiopsListState.filters,
{
query: {
bool: {
[mode]: selectedRows.map(({ key: query }) => ({
match: {
[field]: {
auto_generate_synonyms_phrase_query: false,
fuzziness: 0,
operator: 'and',
query,
},
},
})),
},
},
},
],
index,
interval: 'auto',
query: {
language: aiopsListState.searchQueryLanguage,
query: aiopsListState.searchString,
},
});
let path = basePath.get();
path += '/app/discover#/';
path += '?_g=' + _g;
path += '&_a=' + encodeURIComponent(_a);
window.open(path, '_blank');
};
return { openInDiscoverWithFilter };
}

View file

@ -29,6 +29,8 @@ import { useTimefilter } from './use_time_filter';
import { useDocumentCountStats } from './use_document_count_stats';
import type { Dictionary } from './use_url_state';
const DEFAULT_BAR_TARGET = 75;
export const useData = (
{
currentDataView,
@ -36,7 +38,8 @@ export const useData = (
}: { currentDataView: DataView; currentSavedSearch: SavedSearch | SavedSearchSavedObject | null },
aiopsListState: AiOpsIndexBasedAppState,
onUpdate: (params: Dictionary<unknown>) => void,
selectedChangePoint?: ChangePoint
selectedChangePoint?: ChangePoint,
barTarget: number = DEFAULT_BAR_TARGET
) => {
const {
uiSettings,
@ -125,10 +128,9 @@ export const useData = (
function updateFieldStatsRequest() {
const timefilterActiveBounds = timefilter.getActiveBounds();
if (timefilterActiveBounds !== undefined) {
const BAR_TARGET = 75;
_timeBuckets.setInterval('auto');
_timeBuckets.setBounds(timefilterActiveBounds);
_timeBuckets.setBarTarget(BAR_TARGET);
_timeBuckets.setBarTarget(barTarget);
setFieldStatsRequest({
earliest: timefilterActiveBounds.min?.valueOf(),
latest: timefilterActiveBounds.max?.valueOf(),
@ -186,6 +188,7 @@ export const useData = (
earliest: fieldStatsRequest?.earliest,
/** End timestamp filter */
latest: fieldStatsRequest?.latest,
intervalMs: fieldStatsRequest?.intervalMs,
searchQueryLanguage,
searchString,
searchQuery,

View file

@ -13,4 +13,4 @@ export function plugin() {
return new AiopsPlugin();
}
export { ExplainLogRateSpikes } from './shared_lazy_components';
export { ExplainLogRateSpikes, LogCategorization } from './shared_lazy_components';

View file

@ -9,12 +9,13 @@ import React, { FC, Suspense } from 'react';
import { EuiErrorBoundary, EuiLoadingContent } from '@elastic/eui';
import type { ExplainLogRateSpikesAppStateProps } from './components/explain_log_rate_spikes';
import type { LogCategorizationAppStateProps } from './components/log_categorization';
const ExplainLogRateSpikesAppStateLazy = React.lazy(
() => import('./components/explain_log_rate_spikes')
);
const LazyWrapper: FC = ({ children }) => (
const ExplainLogRateSpikesLazyWrapper: FC = ({ children }) => (
<EuiErrorBoundary>
<Suspense fallback={<EuiLoadingContent lines={3} />}>{children}</Suspense>
</EuiErrorBoundary>
@ -25,7 +26,25 @@ const LazyWrapper: FC = ({ children }) => (
* @param {ExplainLogRateSpikesAppStateProps} props - properties specifying the data on which to run the analysis.
*/
export const ExplainLogRateSpikes: FC<ExplainLogRateSpikesAppStateProps> = (props) => (
<LazyWrapper>
<ExplainLogRateSpikesLazyWrapper>
<ExplainLogRateSpikesAppStateLazy {...props} />
</LazyWrapper>
</ExplainLogRateSpikesLazyWrapper>
);
const LogCategorizationAppStateLazy = React.lazy(() => import('./components/log_categorization'));
const LogCategorizationLazyWrapper: FC = ({ children }) => (
<EuiErrorBoundary>
<Suspense fallback={<EuiLoadingContent lines={3} />}>{children}</Suspense>
</EuiErrorBoundary>
);
/**
* Lazy-wrapped LogCategorizationAppStateProps React component
* @param {LogCategorizationAppStateProps} props - properties specifying the data on which to run the analysis.
*/
export const LogCategorization: FC<LogCategorizationAppStateProps> = (props) => (
<LogCategorizationLazyWrapper>
<LogCategorizationAppStateLazy {...props} />
</LogCategorizationLazyWrapper>
);

View file

@ -32,7 +32,10 @@ export class AiopsPlugin
this.logger = initializerContext.logger.get();
}
public setup(core: CoreSetup<AiopsPluginStartDeps>, plugins: AiopsPluginSetupDeps) {
public setup(
core: CoreSetup<AiopsPluginStartDeps, AiopsPluginSetupDeps>,
plugins: AiopsPluginSetupDeps
) {
this.logger.debug('aiops: Setup');
// Subscribe to license changes and store the current license in `currentLicense`.

View file

@ -56,6 +56,8 @@ export const ML_PAGES = {
AIOPS: 'aiops',
AIOPS_EXPLAIN_LOG_RATE_SPIKES: 'aiops/explain_log_rate_spikes',
AIOPS_EXPLAIN_LOG_RATE_SPIKES_INDEX_SELECT: 'aiops/explain_log_rate_spikes_index_select',
AIOPS_LOG_CATEGORIZATION: 'aiops/log_categorization',
AIOPS_LOG_CATEGORIZATION_INDEX_SELECT: 'aiops/log_categorization_index_select',
} as const;
export type MlPages = typeof ML_PAGES[keyof typeof ML_PAGES];

View file

@ -63,7 +63,9 @@ export type MlGenericUrlState = MLPageState<
| typeof ML_PAGES.DATA_VISUALIZER_INDEX_SELECT
| typeof ML_PAGES.AIOPS
| typeof ML_PAGES.AIOPS_EXPLAIN_LOG_RATE_SPIKES
| typeof ML_PAGES.AIOPS_EXPLAIN_LOG_RATE_SPIKES_INDEX_SELECT,
| typeof ML_PAGES.AIOPS_EXPLAIN_LOG_RATE_SPIKES_INDEX_SELECT
| typeof ML_PAGES.AIOPS_LOG_CATEGORIZATION
| typeof ML_PAGES.AIOPS_LOG_CATEGORIZATION_INDEX_SELECT,
MlGenericUrlPageState | undefined
>;

View file

@ -0,0 +1,66 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC } from 'react';
import { pick } from 'lodash';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { LogCategorization } from '@kbn/aiops-plugin/public';
import { useMlContext } from '../contexts/ml';
import { useMlKibana } from '../contexts/kibana';
import { HelpMenu } from '../components/help_menu';
import { TechnicalPreviewBadge } from '../components/technical_preview_badge';
import { MlPageHeader } from '../components/page_header';
export const LogCategorizationPage: FC = () => {
const { services } = useMlKibana();
const context = useMlContext();
const dataView = context.currentDataView;
const savedSearch = context.currentSavedSearch;
return (
<>
<MlPageHeader>
<EuiFlexGroup responsive={false} wrap={false} alignItems={'center'} gutterSize={'m'}>
<EuiFlexItem grow={false}>
<FormattedMessage
id="xpack.ml.logCategorization.pageHeader"
defaultMessage="Log Pattern Analysis"
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<TechnicalPreviewBadge />
</EuiFlexItem>
</EuiFlexGroup>
</MlPageHeader>
{dataView && (
<LogCategorization
dataView={dataView}
savedSearch={savedSearch}
appDependencies={pick(services, [
'application',
'data',
'charts',
'fieldFormats',
'http',
'notifications',
'share',
'storage',
'uiSettings',
'unifiedSearch',
])}
/>
)}
<HelpMenu docLink={services.docLinks.links.ml.guide} />
</>
);
};

View file

@ -234,7 +234,7 @@ export function useSideNavItems(activeRoute: MlRoute | undefined) {
mlTabs.push({
id: 'aiops_section',
name: i18n.translate('xpack.ml.navMenu.aiopsTabLinkText', {
defaultMessage: 'AIOps',
defaultMessage: 'AIOps Labs',
}),
disabled: disableLinks,
items: [
@ -242,11 +242,20 @@ export function useSideNavItems(activeRoute: MlRoute | undefined) {
id: 'explainlogratespikes',
pathId: ML_PAGES.AIOPS_EXPLAIN_LOG_RATE_SPIKES_INDEX_SELECT,
name: i18n.translate('xpack.ml.navMenu.explainLogRateSpikesLinkText', {
defaultMessage: 'Explain log rate spikes',
defaultMessage: 'Explain Log Rate Spikes',
}),
disabled: disableLinks,
testSubj: 'mlMainTab explainLogRateSpikes',
},
{
id: 'logCategorization',
pathId: ML_PAGES.AIOPS_LOG_CATEGORIZATION_INDEX_SELECT,
name: i18n.translate('xpack.ml.navMenu.logCategorizationLinkText', {
defaultMessage: 'Log Pattern Analysis',
}),
disabled: disableLinks,
testSubj: 'mlMainTab logCategorization',
},
],
});
}

View file

@ -55,13 +55,36 @@ export const DATA_VISUALIZER_BREADCRUMB: ChromeBreadcrumb = Object.freeze({
href: '/datavisualizer',
});
export const AIOPS_BREADCRUMB: ChromeBreadcrumb = Object.freeze({
// we need two AIOPS_BREADCRUMB breadcrumb items as they each need to link
// to either the explain log rate spikes page or the log categorization page
export const AIOPS_BREADCRUMB_EXPLAIN_LOG_RATE_SPIKES: ChromeBreadcrumb = Object.freeze({
text: i18n.translate('xpack.ml.aiopsBreadcrumbLabel', {
defaultMessage: 'AIOps',
defaultMessage: 'AIOps Labs',
}),
href: '/aiops/explain_log_rate_spikes_index_select',
});
export const AIOPS_BREADCRUMB_LOG_PATTERN_ANALYSIS: ChromeBreadcrumb = Object.freeze({
text: i18n.translate('xpack.ml.aiopsBreadcrumbLabel', {
defaultMessage: 'AIOps Labs',
}),
href: '/aiops/log_categorization_index_select',
});
export const EXPLAIN_LOG_RATE_SPIKES: ChromeBreadcrumb = Object.freeze({
text: i18n.translate('xpack.ml.aiops.explainLogRateSpikesBreadcrumbLabel', {
defaultMessage: 'Explain Log Rate Spikes',
}),
href: '/aiops/explain_log_rate_spikes_index_select',
});
export const LOG_PATTERN_ANALYSIS: ChromeBreadcrumb = Object.freeze({
text: i18n.translate('xpack.ml.aiops.logPatternAnalysisBreadcrumbLabel', {
defaultMessage: 'Log Pattern Analysis',
}),
href: '/aiops/log_categorization_index_select',
});
export const CREATE_JOB_BREADCRUMB: ChromeBreadcrumb = Object.freeze({
text: i18n.translate('xpack.ml.createJobsBreadcrumbLabel', {
defaultMessage: 'Create job',
@ -90,7 +113,10 @@ const breadcrumbs = {
DATA_FRAME_ANALYTICS_BREADCRUMB,
TRAINED_MODELS,
DATA_VISUALIZER_BREADCRUMB,
AIOPS_BREADCRUMB,
AIOPS_BREADCRUMB_EXPLAIN_LOG_RATE_SPIKES,
AIOPS_BREADCRUMB_LOG_PATTERN_ANALYSIS,
EXPLAIN_LOG_RATE_SPIKES,
LOG_PATTERN_ANALYSIS,
CREATE_JOB_BREADCRUMB,
CALENDAR_MANAGEMENT_BREADCRUMB,
FILTER_LISTS_BREADCRUMB,

View file

@ -35,7 +35,11 @@ export const explainLogRateSpikesRouteFactory = (
render: (props, deps) => <PageWrapper {...props} deps={deps} />,
breadcrumbs: [
getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath),
getBreadcrumbWithUrlForApp('AIOPS_BREADCRUMB', navigateToPath, basePath),
getBreadcrumbWithUrlForApp(
'AIOPS_BREADCRUMB_EXPLAIN_LOG_RATE_SPIKES',
navigateToPath,
basePath
),
{
text: i18n.translate('xpack.ml.aiopsBreadcrumbs.explainLogRateSpikesLabel', {
defaultMessage: 'Explain log rate spikes',

View file

@ -6,3 +6,4 @@
*/
export * from './explain_log_rate_spikes';
export * from './log_categorization';

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { FC } from 'react';
import { parse } from 'query-string';
import { i18n } from '@kbn/i18n';
import { AIOPS_ENABLED } from '@kbn/aiops-plugin/common';
import { NavigateToPath } from '../../../contexts/kibana';
import { MlRoute, PageLoader, PageProps } from '../../router';
import { useResolver } from '../../use_resolver';
import { LogCategorizationPage as Page } from '../../../aiops/log_categorization';
import { checkBasicLicense } from '../../../license';
import { checkGetJobsCapabilitiesResolver } from '../../../capabilities/check_capabilities';
import { cacheDataViewsContract } from '../../../util/index_utils';
import { getBreadcrumbWithUrlForApp } from '../../breadcrumbs';
export const logCategorizationRouteFactory = (
navigateToPath: NavigateToPath,
basePath: string
): MlRoute => ({
id: 'log_categorization',
path: '/aiops/log_categorization',
title: i18n.translate('xpack.ml.aiops.logCategorization.docTitle', {
defaultMessage: 'Log Pattern Analysis',
}),
render: (props, deps) => <PageWrapper {...props} deps={deps} />,
breadcrumbs: [
getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath),
getBreadcrumbWithUrlForApp('AIOPS_BREADCRUMB_LOG_PATTERN_ANALYSIS', navigateToPath, basePath),
{
text: i18n.translate('xpack.ml.aiops.logCategorization.docTitle', {
defaultMessage: 'Log Pattern Analysis',
}),
},
],
disabled: !AIOPS_ENABLED,
});
const PageWrapper: FC<PageProps> = ({ location, deps }) => {
const { redirectToMlAccessDeniedPage } = deps;
const { index, savedSearchId }: Record<string, any> = parse(location.search, { sort: false });
const { context } = useResolver(index, savedSearchId, deps.config, deps.dataViewsContract, {
checkBasicLicense,
cacheDataViewsContract: () => cacheDataViewsContract(deps.dataViewsContract),
checkGetJobsCapabilities: () => checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage),
});
return (
<PageLoader context={context}>
<Page />
</PageLoader>
);
};

View file

@ -45,17 +45,29 @@ const getDataVisBreadcrumbs = (navigateToPath: NavigateToPath, basePath: string)
getBreadcrumbWithUrlForApp('DATA_VISUALIZER_BREADCRUMB', navigateToPath, basePath),
{
text: i18n.translate('xpack.ml.jobsBreadcrumbs.selectDateViewLabel', {
defaultMessage: 'Data View',
defaultMessage: 'Select Data View',
}),
},
];
const getExplainLogRateSpikesBreadcrumbs = (navigateToPath: NavigateToPath, basePath: string) => [
getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath),
getBreadcrumbWithUrlForApp('AIOPS_BREADCRUMB', navigateToPath, basePath),
getBreadcrumbWithUrlForApp('AIOPS_BREADCRUMB_EXPLAIN_LOG_RATE_SPIKES', navigateToPath, basePath),
getBreadcrumbWithUrlForApp('EXPLAIN_LOG_RATE_SPIKES', navigateToPath, basePath),
{
text: i18n.translate('xpack.ml.aiopsBreadcrumbs.selectDateViewLabel', {
defaultMessage: 'Data View',
defaultMessage: 'Select Data View',
}),
},
];
const getLogCategorizationBreadcrumbs = (navigateToPath: NavigateToPath, basePath: string) => [
getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath),
getBreadcrumbWithUrlForApp('AIOPS_BREADCRUMB_LOG_PATTERN_ANALYSIS', navigateToPath, basePath),
getBreadcrumbWithUrlForApp('LOG_PATTERN_ANALYSIS', navigateToPath, basePath),
{
text: i18n.translate('xpack.ml.aiopsBreadcrumbs.selectDateViewLabel', {
defaultMessage: 'Select Data View',
}),
},
];
@ -116,6 +128,26 @@ export const explainLogRateSpikesIndexOrSearchRouteFactory = (
breadcrumbs: getExplainLogRateSpikesBreadcrumbs(navigateToPath, basePath),
});
export const logCategorizationIndexOrSearchRouteFactory = (
navigateToPath: NavigateToPath,
basePath: string
): MlRoute => ({
id: 'data_view_log_categorization',
path: '/aiops/log_categorization_index_select',
title: i18n.translate('xpack.ml.selectDataViewLabel', {
defaultMessage: 'Select Data View',
}),
render: (props, deps) => (
<PageWrapper
{...props}
nextStepPath="aiops/log_categorization"
deps={deps}
mode={MODE.DATAVISUALIZER}
/>
),
breadcrumbs: getLogCategorizationBreadcrumbs(navigateToPath, basePath),
});
const PageWrapper: FC<IndexOrSearchPageProps> = ({ nextStepPath, deps, mode }) => {
const {
services: {

View file

@ -88,6 +88,8 @@ export class MlLocatorDefinition implements LocatorDefinition<MlLocatorParams> {
case ML_PAGES.AIOPS:
case ML_PAGES.AIOPS_EXPLAIN_LOG_RATE_SPIKES:
case ML_PAGES.AIOPS_EXPLAIN_LOG_RATE_SPIKES_INDEX_SELECT:
case ML_PAGES.AIOPS_LOG_CATEGORIZATION:
case ML_PAGES.AIOPS_LOG_CATEGORIZATION_INDEX_SELECT:
case ML_PAGES.OVERVIEW:
case ML_PAGES.SETTINGS:
case ML_PAGES.FILTER_LISTS_MANAGE: