[ML] Overview tab for ML (#45864) (#46372)

* show overview tab with main tabs

* wip:add overview dir with initial components

* convert and move AnalyticsTable and types to MlInMemoryTable components dir

* create analytics table for overview

* add stats bar to analytics panel

* wip: adds anomaly detection table

* adds actions column to anomaly detection table

* add stats bar to anomaly detection panel

* add max anomaly score to table

* update score display.

* add refresh button to panels

* create scss files for styles

* update functional nav tests

* fix functional test failure

* fix anomalyDetection for when there are no jobs

* add translations and update jobList types

* add maxAnomalyScore endpoint

* fix types and update tab testSub

* fix transforms use of inMemoryTable
This commit is contained in:
Melissa Alvarez 2019-09-23 13:40:00 -06:00 committed by GitHub
parent 13e30097cb
commit 90bf444703
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
52 changed files with 1502 additions and 123 deletions

View file

@ -39,6 +39,30 @@ export interface MlJob {
state: string;
}
export interface MlSummaryJob {
id: string;
description: string;
groups: string[];
processed_record_count: number;
memory_status?: string;
jobState: string;
hasDatafeed: boolean;
datafeedId?: string;
datafeedIndices: any[];
datafeedState?: string;
latestTimestampMs: number;
earliestTimestampMs?: number;
latestResultsTimestampMs: number;
isSingleMetricViewerJob: boolean;
nodeName?: string;
deleting?: boolean;
fullJob?: any;
auditMessage?: any;
latestTimestampSortValue?: number;
}
export type MlSummaryJobs = MlSummaryJob[];
export function isMlJob(arg: any): arg is MlJob {
return typeof arg.job_id === 'string';
}

View file

@ -19,6 +19,7 @@ import 'plugins/ml/components/transition/transition';
import 'plugins/ml/components/modal/modal';
import 'plugins/ml/access_denied';
import 'plugins/ml/jobs';
import 'plugins/ml/overview';
import 'plugins/ml/services/calendar_service';
import 'plugins/ml/components/messagebar';
import 'plugins/ml/data_frame';
@ -43,5 +44,5 @@ if (typeof uiRoutes.enable === 'function') {
uiRoutes
.otherwise({
redirectTo: '/jobs'
redirectTo: '/overview'
});

View file

@ -0,0 +1,8 @@
/*
* 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 { ProgressBar, MlInMemoryTable } from './ml_in_memory_table';
export * from './types';

View file

@ -71,9 +71,9 @@ const getInitialSorting = (columns: any, sorting: any) => {
};
};
import { MlInMemoryTable } from '../../../../../../common/types/eui/in_memory_table';
import { MlInMemoryTableBasic } from './types';
export class AnalyticsTable extends MlInMemoryTable {
export class MlInMemoryTable extends MlInMemoryTableBasic {
static getDerivedStateFromProps(nextProps: any, prevState: any) {
const derivedState = {
...prevState.prevProps,

View file

@ -30,6 +30,7 @@ export interface FieldDataColumnType {
truncateText?: boolean;
render?: RenderFunc;
footer?: string | ReactElement | FooterFunc;
textOnly?: boolean;
}
export interface ComputedColumnType {
@ -191,6 +192,6 @@ interface ComponentWithConstructor<T> extends Component {
new (): Component<T>;
}
export const MlInMemoryTable = (EuiInMemoryTable as any) as ComponentWithConstructor<
export const MlInMemoryTableBasic = (EuiInMemoryTable as any) as ComponentWithConstructor<
EuiInMemoryTableProps
>;

View file

@ -23,13 +23,13 @@ interface Props {
function getTabs(disableLinks: boolean): Tab[] {
return [
// {
// id: 'overview',
// name: i18n.translate('xpack.ml.navMenu.overviewTabLinkText', {
// defaultMessage: 'Overview',
// }),
// disabled: disableLinks,
// },
{
id: 'overview',
name: i18n.translate('xpack.ml.navMenu.overviewTabLinkText', {
defaultMessage: 'Overview',
}),
disabled: disableLinks,
},
{
id: 'anomaly_detection',
name: i18n.translate('xpack.ml.navMenu.anomalyDetectionTabLinkText', {
@ -66,7 +66,7 @@ interface TabData {
}
const TAB_DATA: Record<TabId, TabData> = {
// overview: { testSubject: 'mlTabOverview', pathId: 'overview' },
overview: { testSubject: 'mlMainTab overview', pathId: 'overview' },
anomaly_detection: { testSubject: 'mlMainTab anomalyDetection', pathId: 'jobs' },
data_frames: { testSubject: 'mlMainTab dataFrames' },
data_frame_analytics: { testSubject: 'mlMainTab dataFrameAnalytics' },

View file

@ -18,7 +18,7 @@ export type TabId = string;
type TabSupport = Record<TabId, string | null>;
const tabSupport: TabSupport = {
// overview: null,
overview: null,
jobs: 'anomaly_detection',
settings: 'anomaly_detection',
data_frames: null,

View file

@ -11,8 +11,6 @@ import ReactDOM from 'react-dom';
import { uiModules } from 'ui/modules';
const module = uiModules.get('apps/ml');
import 'ui/directives/kbn_href';
import { NavigationMenu } from './navigation_menu';
module.directive('mlNavMenu', function () {

View file

@ -19,7 +19,7 @@ interface Props {
export function getTabs(tabId: TabId, disableLinks: boolean): Tab[] {
const TAB_MAP: Partial<Record<TabId, Tab[]>> = {
// overview: [],
overview: [],
datavisualizer: [],
data_frames: [],
data_frame_analytics: [],
@ -59,7 +59,7 @@ export function getTabs(tabId: TabId, disableLinks: boolean): Tab[] {
}
enum TAB_TEST_SUBJECT {
// overview = 'mlOverview',
overview = 'mlOverview',
jobs = 'mlSubTab jobManagement',
explorer = 'mlSubTab anomalyExplorer',
timeseriesexplorer = 'mlSubTab singleMetricViewer',

View file

@ -4,4 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { StatsBar, TransformStatsBarStats } from './stats_bar';
export {
StatsBar,
TransformStatsBarStats,
AnalyticStatsBarStats,
JobStatsBarStats,
} from './stats_bar';

View file

@ -7,24 +7,29 @@
import React, { FC } from 'react';
import { Stat, StatsBarStat } from './stat';
interface JobStatsBarStats {
activeNodes: StatsBarStat;
interface Stats {
total: StatsBarStat;
open: StatsBarStat;
failed: StatsBarStat;
}
export interface JobStatsBarStats extends Stats {
activeNodes: StatsBarStat;
open: StatsBarStat;
closed: StatsBarStat;
activeDatafeeds: StatsBarStat;
}
export interface TransformStatsBarStats {
total: StatsBarStat;
export interface TransformStatsBarStats extends Stats {
batch: StatsBarStat;
continuous: StatsBarStat;
failed: StatsBarStat;
started: StatsBarStat;
}
type StatsBarStats = TransformStatsBarStats | JobStatsBarStats;
export interface AnalyticStatsBarStats extends Stats {
started: StatsBarStat;
stopped: StatsBarStat;
}
type StatsBarStats = TransformStatsBarStats | JobStatsBarStats | AnalyticStatsBarStats;
type StatsKey = keyof StatsBarStats;
interface StatsBarProps {

View file

@ -30,10 +30,10 @@ import {
import {
ColumnType,
MlInMemoryTable,
MlInMemoryTableBasic,
SortingPropType,
SORT_DIRECTION,
} from '../../../../../../common/types/eui/in_memory_table';
} from '../../../../../components/ml_in_memory_table';
import { KBN_FIELD_TYPES } from '../../../../../../common/constants/field_types';
import { Dictionary } from '../../../../../../common/types/common';
@ -405,7 +405,7 @@ export const SourceIndexPreview: React.SFC<Props> = React.memo(({ cellClick, que
<EuiProgress size="xs" color="accent" max={1} value={0} />
)}
{clearTable === false && columns.length > 0 && sorting !== false && (
<MlInMemoryTable
<MlInMemoryTableBasic
allowNeutralSort={false}
compressed
items={tableItems}

View file

@ -23,9 +23,9 @@ import {
import {
ColumnType,
MlInMemoryTable,
MlInMemoryTableBasic,
SORT_DIRECTION,
} from '../../../../../../common/types/eui/in_memory_table';
} from '../../../../../components/ml_in_memory_table';
import { dictionaryToArray } from '../../../../../../common/types/common';
import { ES_FIELD_TYPES } from '../../../../../../common/constants/field_types';
import { formatHumanReadableDateTimeSeconds } from '../../../../../util/date_utils';
@ -284,7 +284,7 @@ export const PivotPreview: SFC<PivotPreviewProps> = React.memo(({ aggs, groupBy,
<EuiProgress size="xs" color="accent" max={1} value={0} />
)}
{dataFramePreviewData.length > 0 && clearTable === false && columns.length > 0 && (
<MlInMemoryTable
<MlInMemoryTableBasic
allowNeutralSort={false}
compressed
items={dataFramePreviewData}

View file

@ -22,7 +22,7 @@ import {
ComputedColumnType,
ExpanderColumnType,
FieldDataColumnType,
} from '../../../../../../common/types/eui/in_memory_table';
} from '../../../../../components/ml_in_memory_table';
import {
getTransformProgress,

View file

@ -11,7 +11,7 @@ import {
SortDirection,
SORT_DIRECTION,
FieldDataColumnType,
} from '../../../../../../common/types/eui/in_memory_table';
} from '../../../../../components/ml_in_memory_table';
import { ml } from '../../../../../services/ml_api_service';

View file

@ -22,7 +22,7 @@ import {
OnTableChangeArg,
SortDirection,
SORT_DIRECTION,
} from '../../../../../../common/types/eui/in_memory_table';
} from '../../../../../components/ml_in_memory_table';
import {
DataFrameTransformId,

View file

@ -11,7 +11,7 @@ import React, { Fragment } from 'react';
import { EuiProgress } from '@elastic/eui';
import { MlInMemoryTable } from '../../../../../../common/types/eui/in_memory_table';
import { MlInMemoryTableBasic } from '../../../../../components/ml_in_memory_table';
// The built in loading progress bar of EuiInMemoryTable causes a full DOM replacement
// of the table and doesn't play well with auto-refreshing. That's why we're displaying
@ -73,7 +73,7 @@ const getInitialSorting = (columns: any, sorting: any) => {
};
};
export class TransformTable extends MlInMemoryTable {
export class TransformTable extends MlInMemoryTableBasic {
static getDerivedStateFromProps(nextProps: any, prevState: any) {
const derivedState = {
...prevState.prevProps,

View file

@ -32,11 +32,11 @@ import euiThemeDark from '@elastic/eui/dist/eui_theme_dark.json';
import {
ColumnType,
MlInMemoryTable,
MlInMemoryTableBasic,
OnTableChangeArg,
SortingPropType,
SORT_DIRECTION,
} from '../../../../../../common/types/eui/in_memory_table';
} from '../../../../../components/ml_in_memory_table';
import { useUiChromeContext } from '../../../../../contexts/ui/use_ui_chrome_context';
@ -466,7 +466,7 @@ export const Exploration: FC<Props> = React.memo(({ jobId }) => {
<EuiProgress size="xs" color="accent" max={1} value={0} />
)}
{clearTable === false && columns.length > 0 && sortField !== '' && (
<MlInMemoryTable
<MlInMemoryTableBasic
allowNeutralSort={false}
className="mlDataFrameAnalyticsExploration"
columns={columns}

View file

@ -8,7 +8,7 @@ import React, { useEffect, useState } from 'react';
import { SearchResponse } from 'elasticsearch';
import { SortDirection, SORT_DIRECTION } from '../../../../../../common/types/eui/in_memory_table';
import { SortDirection, SORT_DIRECTION } from '../../../../../components/ml_in_memory_table';
import { ml } from '../../../../../services/ml_api_service';
import { getNestedProperty } from '../../../../../util/object_utils';

View file

@ -21,31 +21,33 @@ import { stopAnalytics } from '../../services/analytics_service';
import { StartAction } from './action_start';
import { DeleteAction } from './action_delete';
export const AnalyticsViewAction = {
isPrimary: true,
render: (item: DataFrameAnalyticsListRow) => {
return (
<EuiButtonEmpty
disabled={!isOutlierAnalysis(item.config.analysis)}
onClick={() => (window.location.href = getResultsUrl(item.id))}
size="xs"
color="text"
iconType="visTable"
aria-label={i18n.translate('xpack.ml.dataframe.analyticsList.viewAriaLabel', {
defaultMessage: 'View',
})}
>
{i18n.translate('xpack.ml.dataframe.analyticsList.viewActionName', {
defaultMessage: 'View',
})}
</EuiButtonEmpty>
);
},
};
export const getActions = () => {
const canStartStopDataFrameAnalytics: boolean = checkPermission('canStartStopDataFrameAnalytics');
return [
{
isPrimary: true,
render: (item: DataFrameAnalyticsListRow) => {
return (
<EuiButtonEmpty
disabled={!isOutlierAnalysis(item.config.analysis)}
onClick={() => (window.location.href = getResultsUrl(item.id))}
size="xs"
color="text"
iconType="visTable"
aria-label={i18n.translate('xpack.ml.dataframe.analyticsList.viewAriaLabel', {
defaultMessage: 'View',
})}
>
{i18n.translate('xpack.ml.dataframe.analyticsList.viewActionName', {
defaultMessage: 'View',
})}
</EuiButtonEmpty>
);
},
},
AnalyticsViewAction,
{
render: (item: DataFrameAnalyticsListRow) => {
if (!isDataFrameAnalyticsRunning(item.stats)) {

View file

@ -15,12 +15,6 @@ import {
EuiEmptyPrompt,
} from '@elastic/eui';
import {
OnTableChangeArg,
SortDirection,
SORT_DIRECTION,
} from '../../../../../../common/types/eui/in_memory_table';
import { DataFrameAnalyticsId, useRefreshAnalyticsList } from '../../../../common';
import { checkPermission } from '../../../../../privilege/check_privilege';
import { getTaskStateBadge } from './columns';
@ -38,7 +32,13 @@ import { ActionDispatchers } from '../../hooks/use_create_analytics_form/actions
import { getAnalyticsFactory } from '../../services/analytics_service';
import { getColumns } from './columns';
import { ExpandedRow } from './expanded_row';
import { ProgressBar, AnalyticsTable } from './analytics_table';
import {
ProgressBar,
MlInMemoryTable,
OnTableChangeArg,
SortDirection,
SORT_DIRECTION,
} from '../../../../../components/ml_in_memory_table';
function getItemIdToExpandedRowMap(
itemIds: DataFrameAnalyticsId[],
@ -310,7 +310,7 @@ export const DataFrameAnalyticsList: FC<Props> = ({
return (
<Fragment>
<ProgressBar isLoading={isLoading} />
<AnalyticsTable
<MlInMemoryTable
allowNeutralSort={false}
className="mlAnalyticsTable"
columns={columns}

View file

@ -58,6 +58,57 @@ export const getTaskStateBadge = (
);
};
export const progressColumn = {
name: i18n.translate('xpack.ml.dataframe.analyticsList.progress', {
defaultMessage: 'Progress',
}),
sortable: (item: DataFrameAnalyticsListRow) => getDataFrameAnalyticsProgress(item.stats),
truncateText: true,
render(item: DataFrameAnalyticsListRow) {
const progress = getDataFrameAnalyticsProgress(item.stats);
if (progress === undefined) {
return null;
}
// For now all analytics jobs are batch jobs.
const isBatchTransform = true;
return (
<EuiFlexGroup alignItems="center" gutterSize="xs">
{isBatchTransform && (
<Fragment>
<EuiFlexItem style={{ width: '40px' }} grow={false}>
<EuiProgress value={progress} max={100} color="primary" size="m">
{progress}%
</EuiProgress>
</EuiFlexItem>
<EuiFlexItem style={{ width: '35px' }} grow={false}>
<EuiText size="xs">{`${progress}%`}</EuiText>
</EuiFlexItem>
</Fragment>
)}
{!isBatchTransform && (
<Fragment>
<EuiFlexItem style={{ width: '40px' }} grow={false}>
{item.stats.state === DATA_FRAME_TASK_STATE.STARTED && (
<EuiProgress color="primary" size="m" />
)}
{item.stats.state === DATA_FRAME_TASK_STATE.STOPPED && (
<EuiProgress value={0} max={100} color="primary" size="m" />
)}
</EuiFlexItem>
<EuiFlexItem style={{ width: '35px' }} grow={false}>
&nbsp;
</EuiFlexItem>
</Fragment>
)}
</EuiFlexGroup>
);
},
width: '100px',
};
export const getColumns = (
expandedRowItemIds: DataFrameAnalyticsId[],
setExpandedRowItemIds: React.Dispatch<React.SetStateAction<DataFrameAnalyticsId[]>>,
@ -166,56 +217,7 @@ export const getColumns = (
width: '100px',
},
*/
{
name: i18n.translate('xpack.ml.dataframe.analyticsList.progress', {
defaultMessage: 'Progress',
}),
sortable: (item: DataFrameAnalyticsListRow) => getDataFrameAnalyticsProgress(item.stats),
truncateText: true,
render(item: DataFrameAnalyticsListRow) {
const progress = getDataFrameAnalyticsProgress(item.stats);
if (progress === undefined) {
return null;
}
// For now all analytics jobs are batch jobs.
const isBatchTransform = true;
return (
<EuiFlexGroup alignItems="center" gutterSize="xs">
{isBatchTransform && (
<Fragment>
<EuiFlexItem style={{ width: '40px' }} grow={false}>
<EuiProgress value={progress} max={100} color="primary" size="m">
{progress}%
</EuiProgress>
</EuiFlexItem>
<EuiFlexItem style={{ width: '35px' }} grow={false}>
<EuiText size="xs">{`${progress}%`}</EuiText>
</EuiFlexItem>
</Fragment>
)}
{!isBatchTransform && (
<Fragment>
<EuiFlexItem style={{ width: '40px' }} grow={false}>
{item.stats.state === DATA_FRAME_TASK_STATE.STARTED && (
<EuiProgress color="primary" size="m" />
)}
{item.stats.state === DATA_FRAME_TASK_STATE.STOPPED && (
<EuiProgress value={0} max={100} color="primary" size="m" />
)}
</EuiFlexItem>
<EuiFlexItem style={{ width: '35px' }} grow={false}>
&nbsp;
</EuiFlexItem>
</Fragment>
)}
</EuiFlexGroup>
);
},
width: '100px',
},
progressColumn,
];
if (isManagementTable === true) {

View file

@ -94,6 +94,7 @@ export interface DataFrameAnalyticsListRow {
export enum DataFrameAnalyticsListColumn {
configDestIndex = 'config.dest.index',
configSourceIndex = 'config.source.index',
configCreateTime = 'config.create_time',
// Description attribute is not supported yet by API
// description = 'config.description',
id = 'id',

View file

@ -26,6 +26,7 @@
@import 'datavisualizer/index';
@import 'explorer/index'; // SASSTODO: This file needs to be rewritten
@import 'jobs/index'; // SASSTODO: This collection of sass files has multiple problems
@import 'overview/index';
@import 'settings/index';
@import 'timeseriesexplorer/index';

View file

@ -18,7 +18,7 @@ import chrome from 'ui/chrome';
import { mlJobService } from '../../../../services/job_service';
import { injectI18n } from '@kbn/i18n/react';
function getLink(location, jobs) {
export function getLink(location, jobs) {
const resultsPageUrl = mlJobService.createResultsUrlForJobs(jobs, location);
return `${chrome.getBasePath()}/app/${resultsPageUrl}`;
}

View file

@ -0,0 +1 @@
@import './components/index';

View file

@ -0,0 +1,23 @@
/*
* 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 { i18n } from '@kbn/i18n';
// @ts-ignore
import { ML_BREADCRUMB } from '../breadcrumbs';
export function getOverviewBreadcrumbs() {
// Whilst top level nav menu with tabs remains,
// use root ML breadcrumb.
return [
ML_BREADCRUMB,
{
text: i18n.translate('xpack.ml.overviewBreadcrumbs.overviewLabel', {
defaultMessage: 'Overview',
}),
href: '',
},
];
}

View file

@ -0,0 +1,12 @@
.mlOverviewPanel {
padding-top: 0;
}
.mlOverviewPanel__buttons {
float: right;
}
.mlOverviewPanel__statsBar {
margin-top: 0;
margin-right: 0
}

View file

@ -0,0 +1,107 @@
/*
* 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, { FC, Fragment, useState, useEffect } from 'react';
import {
EuiButton,
EuiButtonEmpty,
EuiCallOut,
EuiEmptyPrompt,
EuiLoadingSpinner,
EuiPanel,
EuiSpacer,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { AnalyticsTable } from './table';
import { getAnalyticsFactory } from '../../../data_frame_analytics/pages/analytics_management/services/analytics_service';
import { DataFrameAnalyticsListRow } from '../../../data_frame_analytics/pages/analytics_management/components/analytics_list/common';
export const AnalyticsPanel: FC = () => {
const [analytics, setAnalytics] = useState<DataFrameAnalyticsListRow[]>([]);
const [errorMessage, setErrorMessage] = useState<any>(undefined);
const [isInitialized, setIsInitialized] = useState(false);
const getAnalytics = getAnalyticsFactory(setAnalytics, setErrorMessage, setIsInitialized, false);
useEffect(() => {
getAnalytics(true);
}, []);
const onRefresh = () => {
getAnalytics(true);
};
const errorDisplay = (
<Fragment>
<EuiCallOut
title={i18n.translate('xpack.ml.overview.analyticsList.errorPromptTitle', {
defaultMessage: 'An error occurred getting the data frame analytics list.',
})}
color="danger"
iconType="alert"
>
<pre>
{errorMessage && errorMessage.message !== undefined
? errorMessage.message
: JSON.stringify(errorMessage)}
</pre>
</EuiCallOut>
</Fragment>
);
return (
<EuiPanel className="mlOverviewPanel">
{typeof errorMessage !== 'undefined' && errorDisplay}
{isInitialized === false && <EuiLoadingSpinner />}     
{isInitialized === true && analytics.length === 0 && (
<EuiEmptyPrompt
iconType="createAdvancedJob"
title={
<h2>
{i18n.translate('xpack.ml.overview.analyticsList.createFirstJobMessage', {
defaultMessage: 'Create your first analytics job.',
})}
</h2>
}
body={
<Fragment>
<p>
{i18n.translate('xpack.ml.overview.analyticsList.emptyPromptText', {
defaultMessage: `Data frame analytics enable you to perform different analyses of your data and annotate it with the results. The analytics job stores the annotated data, as well as a copy of the source data, in a new index.`,
})}
</p>
</Fragment>
}
actions={
<EuiButton href="#/data_frame_analytics?" color="primary" fill>
{i18n.translate('xpack.ml.overview.analyticsList.createJobButtonText', {
defaultMessage: 'Create job.',
})}
</EuiButton>
}
/>
)}
{isInitialized === true && analytics.length > 0 && (
<Fragment>
<AnalyticsTable items={analytics} />
<EuiSpacer size="m" />
<div className="mlOverviewPanel__buttons">
<EuiButtonEmpty size="s" onClick={onRefresh}>
{i18n.translate('xpack.ml.overview.analyticsList.refreshJobsButtonText', {
defaultMessage: 'Refresh',
})}
</EuiButtonEmpty>
<EuiButton size="s" fill href="#/data_frame_analytics?">
{i18n.translate('xpack.ml.overview.analyticsList.manageJobsButtonText', {
defaultMessage: 'Manage jobs',
})}
</EuiButton>
</div>
</Fragment>
)}
</EuiPanel>
);
};

View file

@ -0,0 +1,91 @@
/*
* 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, { FC } from 'react';
import { i18n } from '@kbn/i18n';
import { StatsBar, AnalyticStatsBarStats } from '../../../components/stats_bar';
import {
DataFrameAnalyticsListRow,
DATA_FRAME_TASK_STATE,
} from '../../../data_frame_analytics/pages/analytics_management/components/analytics_list/common';
function getAnalyticsStats(analyticsList: any[]) {
const analyticsStats = {
total: {
label: i18n.translate('xpack.ml.overview.statsBar.totalAnalyticsLabel', {
defaultMessage: 'Total analytics jobs',
}),
value: 0,
show: true,
},
started: {
label: i18n.translate('xpack.ml.overview.statsBar.startedAnalyticsLabel', {
defaultMessage: 'Started',
}),
value: 0,
show: true,
},
stopped: {
label: i18n.translate('xpack.ml.overview.statsBar.stoppedAnalyticsLabel', {
defaultMessage: 'Stopped',
}),
value: 0,
show: true,
},
failed: {
label: i18n.translate('xpack.ml.overview.statsBar.failedAnalyticsLabel', {
defaultMessage: 'Failed',
}),
value: 0,
show: false,
},
};
if (analyticsList === undefined) {
return analyticsStats;
}
let failedJobs = 0;
let startedJobs = 0;
let stoppedJobs = 0;
analyticsList.forEach(job => {
if (job.stats.state === DATA_FRAME_TASK_STATE.FAILED) {
failedJobs++;
} else if (
job.stats.state === DATA_FRAME_TASK_STATE.STARTED ||
job.stats.state === DATA_FRAME_TASK_STATE.ANALYZING ||
job.stats.state === DATA_FRAME_TASK_STATE.REINDEXING
) {
startedJobs++;
} else if (job.stats.state === DATA_FRAME_TASK_STATE.STOPPED) {
stoppedJobs++;
}
});
analyticsStats.total.value = analyticsList.length;
analyticsStats.started.value = startedJobs;
analyticsStats.stopped.value = stoppedJobs;
if (failedJobs !== 0) {
analyticsStats.failed.value = failedJobs;
analyticsStats.failed.show = true;
} else {
analyticsStats.failed.show = false;
}
return analyticsStats;
}
interface Props {
analyticsList: DataFrameAnalyticsListRow[];
}
export const AnalyticsStatsBar: FC<Props> = ({ analyticsList }) => {
const analyticsStats: AnalyticStatsBarStats = getAnalyticsStats(analyticsList);
return <StatsBar stats={analyticsStats} dataTestSub={'mlOverviewAnalyticsStatsBar'} />;
};

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 { AnalyticsPanel } from './analytics_panel';

View file

@ -0,0 +1,149 @@
/*
* 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, { FC, Fragment, useState } from 'react';
import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import {
MlInMemoryTable,
SortDirection,
SORT_DIRECTION,
OnTableChangeArg,
ColumnType,
} from '../../../components/ml_in_memory_table';
import { getAnalysisType } from '../../../data_frame_analytics/common/analytics';
import {
DataFrameAnalyticsListColumn,
DataFrameAnalyticsListRow,
} from '../../../data_frame_analytics/pages/analytics_management/components/analytics_list/common';
import {
getTaskStateBadge,
progressColumn,
} from '../../../data_frame_analytics/pages/analytics_management/components/analytics_list/columns';
import { AnalyticsViewAction } from '../../../data_frame_analytics/pages/analytics_management/components/analytics_list/actions';
import { formatHumanReadableDateTimeSeconds } from '../../../util/date_utils';
import { AnalyticsStatsBar } from './analytics_stats_bar';
interface Props {
items: any[];
}
export const AnalyticsTable: FC<Props> = ({ items }) => {
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const [sortField, setSortField] = useState<string>(DataFrameAnalyticsListColumn.id);
const [sortDirection, setSortDirection] = useState<SortDirection>(SORT_DIRECTION.ASC);
// id, type, status, progress, created time, view icon
const columns: ColumnType[] = [
{
field: DataFrameAnalyticsListColumn.id,
name: i18n.translate('xpack.ml.overview.analyticsList.id', { defaultMessage: 'ID' }),
sortable: true,
truncateText: true,
width: '20%',
},
{
name: i18n.translate('xpack.ml.overview.analyticsList.type', { defaultMessage: 'Type' }),
sortable: (item: DataFrameAnalyticsListRow) => getAnalysisType(item.config.analysis),
truncateText: true,
render(item: DataFrameAnalyticsListRow) {
return <EuiBadge color="hollow">{getAnalysisType(item.config.analysis)}</EuiBadge>;
},
width: '150px',
},
{
name: i18n.translate('xpack.ml.overview.analyticsList.status', { defaultMessage: 'Status' }),
sortable: (item: DataFrameAnalyticsListRow) => item.stats.state,
truncateText: true,
render(item: DataFrameAnalyticsListRow) {
return getTaskStateBadge(item.stats.state, item.stats.reason);
},
width: '100px',
},
progressColumn,
{
field: DataFrameAnalyticsListColumn.configCreateTime,
name: i18n.translate('xpack.ml.overview.analyticsList.reatedTimeColumnName', {
defaultMessage: 'Creation time',
}),
dataType: 'date',
render: (time: number) => formatHumanReadableDateTimeSeconds(time),
textOnly: true,
sortable: true,
width: '20%',
},
{
name: i18n.translate('xpack.ml.overview.analyticsList.tableActionLabel', {
defaultMessage: 'Actions',
}),
actions: [AnalyticsViewAction],
width: '100px',
},
];
const onTableChange = ({
page = { index: 0, size: 10 },
sort = { field: DataFrameAnalyticsListColumn.id, direction: SORT_DIRECTION.ASC },
}: OnTableChangeArg) => {
const { index, size } = page;
setPageIndex(index);
setPageSize(size);
const { field, direction } = sort;
setSortField(field);
setSortDirection(direction);
};
const pagination = {
initialPageIndex: pageIndex,
initialPageSize: pageSize,
totalItemCount: items.length,
pageSizeOptions: [10, 20, 50],
hidePerPageOptions: false,
};
const sorting = {
sort: {
field: sortField,
direction: sortDirection,
},
};
return (
<Fragment>
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiText size="m">
<h3>
{i18n.translate('xpack.ml.overview.analyticsList.PanelTitle', {
defaultMessage: 'Analytics',
})}
</h3>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false} className="mlOverviewPanel__statsBar">
<AnalyticsStatsBar analyticsList={items} />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
<MlInMemoryTable
allowNeutralSort={false}
className="mlAnalyticsTable"
columns={columns}
hasActions={false}
isExpandable={false}
isSelectable={false}
items={items}
itemId={DataFrameAnalyticsListColumn.id}
onTableChange={onTableChange}
pagination={pagination}
sorting={sorting}
data-test-subj="mlOverviewTableAnalytics"
/>
</Fragment>
);
};

View file

@ -0,0 +1,44 @@
/*
* 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, { FC } from 'react';
import { EuiToolTip, EuiButtonEmpty } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
// @ts-ignore no module file
import { getLink } from '../../../jobs/jobs_list/components/job_actions/results';
import { MlSummaryJobs } from '../../../../common/types/jobs';
interface Props {
jobsList: MlSummaryJobs;
}
export const ExplorerLink: FC<Props> = ({ jobsList }) => {
const openJobsInAnomalyExplorerText = i18n.translate(
'xpack.ml.overview.anomalyDetection.resultActions.openJobsInAnomalyExplorerText',
{
defaultMessage: 'Open {jobsCount, plural, one {{jobId}} other {# jobs}} in Anomaly Explorer',
values: { jobsCount: jobsList.length, jobId: jobsList[0] && jobsList[0].id },
}
);
return (
<EuiToolTip position="bottom" content={openJobsInAnomalyExplorerText}>
<EuiButtonEmpty
color="text"
size="xs"
href={getLink('explorer', jobsList)}
iconType="tableOfContents"
aria-label={openJobsInAnomalyExplorerText}
className="results-button"
data-test-subj={`openOverviewJobsInAnomalyExplorer`}
>
{i18n.translate('xpack.ml.overview.anomalyDetection.exploreActionName', {
defaultMessage: 'Explore',
})}
</EuiButtonEmpty>
</EuiToolTip>
);
};

View file

@ -0,0 +1,198 @@
/*
* 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, { FC, Fragment, useState, useEffect } from 'react';
import {
EuiButton,
EuiButtonEmpty,
EuiCallOut,
EuiEmptyPrompt,
EuiLoadingSpinner,
EuiPanel,
EuiSpacer,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import moment from 'moment';
import { toastNotifications } from 'ui/notify';
import { AnomalyDetectionTable } from './table';
import { ml } from '../../../services/ml_api_service';
import { getGroupsFromJobs, getStatsBarData, getJobsWithTimerange } from './utils';
import { Dictionary } from '../../../../common/types/common';
import { MlSummaryJobs, MlSummaryJob } from '../../../../common/types/jobs';
export type GroupsDictionary = Dictionary<Group>;
export interface Group {
id: string;
jobIds: string[];
docs_processed: number;
earliest_timestamp: number;
latest_timestamp: number;
max_anomaly_score: number | null;
}
type MaxScoresByGroup = Dictionary<{
maxScore: number;
index?: number;
}>;
const createJobLink = '#/jobs/new_job/step/index_or_search';
function getDefaultAnomalyScores(groups: Group[]): MaxScoresByGroup {
const anomalyScores: MaxScoresByGroup = {};
groups.forEach(group => {
anomalyScores[group.id] = { maxScore: 0 };
});
return anomalyScores;
}
export const AnomalyDetectionPanel: FC = () => {
const [isLoading, setIsLoading] = useState(false);
const [groups, setGroups] = useState<GroupsDictionary>({});
const [groupsCount, setGroupsCount] = useState<number>(0);
const [jobsList, setJobsList] = useState<MlSummaryJobs>([]);
const [statsBarData, setStatsBarData] = useState<any>(undefined);
const [errorMessage, setErrorMessage] = useState<any>(undefined);
const loadJobs = async () => {
setIsLoading(true);
try {
const jobsResult: MlSummaryJobs = await ml.jobs.jobsSummary([]);
const jobsSummaryList = jobsResult.map((job: MlSummaryJob) => {
job.latestTimestampSortValue = job.latestTimestampMs || 0;
return job;
});
const { groups: jobsGroups, count } = getGroupsFromJobs(jobsSummaryList);
const jobsWithTimerange = getJobsWithTimerange(jobsSummaryList);
const stats = getStatsBarData(jobsSummaryList);
setIsLoading(false);
setErrorMessage(undefined);
setStatsBarData(stats);
setGroupsCount(count);
setGroups(jobsGroups);
setJobsList(jobsWithTimerange);
loadMaxAnomalyScores(jobsGroups);
} catch (e) {
setErrorMessage(e.message !== undefined ? e.message : JSON.stringify(e));
setIsLoading(false);
}
};
const loadMaxAnomalyScores = async (groupsObject: GroupsDictionary) => {
const groupsList: Group[] = Object.values(groupsObject);
const scores = getDefaultAnomalyScores(groupsList);
try {
const promises = groupsList
.filter(group => group.jobIds.length > 0)
.map((group, i) => {
scores[group.id].index = i;
const latestTimestamp = group.latest_timestamp;
const startMoment = moment(latestTimestamp);
const twentyFourHoursAgo = startMoment.subtract(24, 'hours').valueOf();
return ml.results.getMaxAnomalyScore(group.jobIds, twentyFourHoursAgo, latestTimestamp);
});
const results = await Promise.all(promises);
const tempGroups = { ...groupsObject };
// Check results for each group's promise index and update state
Object.keys(scores).forEach(groupId => {
const resultsIndex = scores[groupId] && scores[groupId].index;
scores[groupId] = resultsIndex !== undefined && results[resultsIndex];
tempGroups[groupId].max_anomaly_score = resultsIndex !== undefined && results[resultsIndex];
});
setGroups(tempGroups);
} catch (e) {
toastNotifications.addDanger(
i18n.translate(
'xpack.ml.overview.anomalyDetection.errorWithFetchingAnomalyScoreNotificationErrorMessage',
{
defaultMessage: 'An error occurred fetching anomaly scores: {error}',
values: { error: e.message !== undefined ? e.message : JSON.stringify(e) },
}
)
);
}
};
useEffect(() => {
loadJobs();
}, []);
const onRefresh = () => {
loadJobs();
};
const errorDisplay = (
<Fragment>
<EuiCallOut
title={i18n.translate('xpack.ml.overview.anomalyDetection.errorPromptTitle', {
defaultMessage: 'An error occurred getting the anomaly detection jobs list.',
})}
color="danger"
iconType="alert"
>
<pre>{errorMessage}</pre>
</EuiCallOut>
</Fragment>
);
return (
<EuiPanel className="mlOverviewPanel">
{typeof errorMessage !== 'undefined' && errorDisplay}
{isLoading && <EuiLoadingSpinner />}   
{isLoading === false && typeof errorMessage === 'undefined' && groupsCount === 0 && (
<EuiEmptyPrompt
iconType="createSingleMetricJob"
title={
<h2>
{i18n.translate('xpack.ml.overview.anomalyDetection.createFirstJobMessage', {
defaultMessage: 'Create your first anomaly detection job.',
})}
</h2>
}
body={
<Fragment>
<p>
{i18n.translate('xpack.ml.overview.anomalyDetection.emptyPromptText', {
defaultMessage: `Machine learning makes it easy to detect anomalies in time series data stored in Elasticsearch. Track one metric from a single machine or hundreds of metrics across thousands of machines. Start automatically spotting the anomalies hiding in your data and resolve issues faster.`,
})}
</p>
</Fragment>
}
actions={
<EuiButton color="primary" href={createJobLink} fill>
{i18n.translate('xpack.ml.overview.anomalyDetection.createJobButtonText', {
defaultMessage: 'Create job.',
})}
</EuiButton>
}
/>
)}
{isLoading === false && typeof errorMessage === 'undefined' && groupsCount > 0 && (
<Fragment>
<AnomalyDetectionTable items={groups} jobsList={jobsList} statsBarData={statsBarData} />
<EuiSpacer size="m" />
<div className="mlOverviewPanel__buttons">
<EuiButtonEmpty size="s" onClick={onRefresh}>
{i18n.translate('xpack.ml.overview.anomalyDetection.refreshJobsButtonText', {
defaultMessage: 'Refresh',
})}
</EuiButtonEmpty>
<EuiButton size="s" fill href="#/jobs?">
{i18n.translate('xpack.ml.overview.anomalyDetection.manageJobsButtonText', {
defaultMessage: 'Manage jobs',
})}
</EuiButton>
</div>
</Fragment>
)}
</EuiPanel>
);
};

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 { AnomalyDetectionPanel } from './anomaly_detection_panel';

View file

@ -0,0 +1,194 @@
/*
* 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, { FC, Fragment, useState } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiHealth,
EuiLoadingSpinner,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import {
MlInMemoryTable,
SortDirection,
SORT_DIRECTION,
OnTableChangeArg,
ColumnType,
} from '../../../components/ml_in_memory_table';
import { formatHumanReadableDateTimeSeconds } from '../../../util/date_utils';
import { ExplorerLink } from './actions';
import { getJobsFromGroup } from './utils';
import { GroupsDictionary, Group } from './anomaly_detection_panel';
import { MlSummaryJobs } from '../../../../common/types/jobs';
import { StatsBar, JobStatsBarStats } from '../../../components/stats_bar';
// @ts-ignore
import { JobSelectorBadge } from '../../../components/job_selector/job_selector_badge';
// @ts-ignore
import { toLocaleString } from '../../../util/string_utils';
import { getSeverityColor } from '../../../../common/util/anomaly_utils';
// Used to pass on attribute names to table columns
export enum AnomalyDetectionListColumns {
id = 'id',
maxAnomalyScore = 'max_anomaly_score',
jobIds = 'jobIds',
latestTimestamp = 'latest_timestamp',
docsProcessed = 'docs_processed',
}
interface Props {
items: GroupsDictionary;
statsBarData: JobStatsBarStats;
jobsList: MlSummaryJobs;
}
export const AnomalyDetectionTable: FC<Props> = ({ items, jobsList, statsBarData }) => {
const groupsList = Object.values(items);
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const [sortField, setSortField] = useState<string>(AnomalyDetectionListColumns.id);
const [sortDirection, setSortDirection] = useState<SortDirection>(SORT_DIRECTION.ASC);
// columns: group, max anomaly, jobs in group, latest timestamp, docs processed, action to explorer
const columns: ColumnType[] = [
{
field: AnomalyDetectionListColumns.id,
name: i18n.translate('xpack.ml.overview.anomalyDetection.tableId', {
defaultMessage: 'Group ID',
}),
render: (id: Group['id']) => <JobSelectorBadge id={id} isGroup={id !== 'ungrouped'} />,
sortable: true,
truncateText: true,
width: '20%',
},
{
field: AnomalyDetectionListColumns.maxAnomalyScore,
name: i18n.translate('xpack.ml.overview.anomalyDetection.tableMaxScore', {
defaultMessage: 'Max anomaly score',
}),
sortable: true,
render: (score: Group['max_anomaly_score']) => {
if (score === null) {
return <EuiLoadingSpinner />;
} else {
const color: string = getSeverityColor(score);
return (
// @ts-ignore
<EuiHealth color={color} compressed="true">
{score >= 1 ? Math.floor(score) : '< 1'}
</EuiHealth>
);
}
},
truncateText: true,
width: '150px',
},
{
field: AnomalyDetectionListColumns.jobIds,
name: i18n.translate('xpack.ml.overview.anomalyDetection.tableNumJobs', {
defaultMessage: 'Jobs in group',
}),
render: (jobIds: Group['jobIds']) => jobIds.length,
sortable: true,
truncateText: true,
width: '100px',
},
{
field: AnomalyDetectionListColumns.latestTimestamp,
name: i18n.translate('xpack.ml.overview.anomalyDetection.tableLatestTimestamp', {
defaultMessage: 'Latest timestamp',
}),
dataType: 'date',
render: (time: number) => formatHumanReadableDateTimeSeconds(time),
textOnly: true,
sortable: true,
width: '20%',
},
{
field: AnomalyDetectionListColumns.docsProcessed,
name: i18n.translate('xpack.ml.overview.anomalyDetection.tableDocsProcessed', {
defaultMessage: 'Docs processed',
}),
render: (docs: number) => toLocaleString(docs),
textOnly: true,
sortable: true,
width: '20%',
},
{
name: i18n.translate('xpack.ml.overview.anomalyDetection.tableActionLabel', {
defaultMessage: 'Actions',
}),
render: (group: Group) => <ExplorerLink jobsList={getJobsFromGroup(group, jobsList)} />,
width: '100px',
},
];
const onTableChange = ({
page = { index: 0, size: 10 },
sort = { field: AnomalyDetectionListColumns.id, direction: SORT_DIRECTION.ASC },
}: OnTableChangeArg) => {
const { index, size } = page;
setPageIndex(index);
setPageSize(size);
const { field, direction } = sort;
setSortField(field);
setSortDirection(direction);
};
const pagination = {
initialPageIndex: pageIndex,
initialPageSize: pageSize,
totalItemCount: groupsList.length,
pageSizeOptions: [10, 20, 50],
hidePerPageOptions: false,
};
const sorting = {
sort: {
field: sortField,
direction: sortDirection,
},
};
return (
<Fragment>
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiText size="m">
<h3>
{i18n.translate('xpack.ml.overview.anomalyDetection.panelTitle', {
defaultMessage: 'Anomaly Detection',
})}
</h3>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false} className="mlOverviewPanel__statsBar">
<StatsBar stats={statsBarData} dataTestSub={'mlOverviewJobStatsBar'} />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
<MlInMemoryTable
allowNeutralSort={false}
className="mlAnomalyDetectionTable"
columns={columns}
hasActions={false}
isExpandable={false}
isSelectable={false}
items={groupsList}
itemId={AnomalyDetectionListColumns.id}
onTableChange={onTableChange}
pagination={pagination}
sorting={sorting}
data-test-subj="mlOverviewTableAnomalyDetection"
/>
</Fragment>
);
};

View file

@ -0,0 +1,175 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { JOB_STATE, DATAFEED_STATE } from '../../../../common/constants/states';
import { Group, GroupsDictionary } from './anomaly_detection_panel';
import { MlSummaryJobs, MlSummaryJob } from '../../../../common/types/jobs';
export function getGroupsFromJobs(
jobs: MlSummaryJobs
): { groups: GroupsDictionary; count: number } {
const groups: any = {
ungrouped: {
id: 'ungrouped',
jobIds: [],
docs_processed: 0,
latest_timestamp: 0,
max_anomaly_score: null,
},
};
jobs.forEach((job: MlSummaryJob) => {
// Organize job by group
if (job.groups.length > 0) {
job.groups.forEach((g: any) => {
if (groups[g] === undefined) {
groups[g] = {
id: g,
jobIds: [job.id],
docs_processed: job.processed_record_count,
latest_timestamp: job.latestTimestampMs,
max_anomaly_score: null,
};
} else {
groups[g].jobIds.push(job.id);
groups[g].docs_processed += job.processed_record_count;
// if incoming job latest timestamp is greater than the last saved one, replace it
if (groups[g].latest_timestamp === undefined) {
groups[g].latest_timestamp = job.latestTimestampMs;
} else if (job.latestTimestampMs > groups[g].latest_timestamp) {
groups[g].latest_timestamp = job.latestTimestampMs;
}
}
});
} else {
groups.ungrouped.jobIds.push(job.id);
groups.ungrouped.docs_processed += job.processed_record_count;
// if incoming job latest timestamp is greater than the last saved one, replace it
if (job.latestTimestampMs > groups.ungrouped.latest_timestamp) {
groups.ungrouped.latest_timestamp = job.latestTimestampMs;
}
}
});
if (groups.ungrouped.jobIds.length === 0) {
delete groups.ungrouped;
}
const count = Object.values(groups).length;
return { groups, count };
}
export function getStatsBarData(jobsList: any) {
const jobStats = {
activeNodes: {
label: i18n.translate('xpack.ml.overviewJobsList.statsBar.activeMLNodesLabel', {
defaultMessage: 'Active ML Nodes',
}),
value: 0,
show: true,
},
total: {
label: i18n.translate('xpack.ml.overviewJobsList.statsBar.totalJobsLabel', {
defaultMessage: 'Total jobs',
}),
value: 0,
show: true,
},
open: {
label: i18n.translate('xpack.ml.overviewJobsList.statsBar.openJobsLabel', {
defaultMessage: 'Open jobs',
}),
value: 0,
show: true,
},
closed: {
label: i18n.translate('xpack.ml.overviewJobsList.statsBar.closedJobsLabel', {
defaultMessage: 'Closed jobs',
}),
value: 0,
show: true,
},
failed: {
label: i18n.translate('xpack.ml.overviewJobsList.statsBar.failedJobsLabel', {
defaultMessage: 'Failed jobs',
}),
value: 0,
show: false,
},
activeDatafeeds: {
label: i18n.translate('xpack.ml.jobsList.statsBar.activeDatafeedsLabel', {
defaultMessage: 'Active datafeeds',
}),
value: 0,
show: true,
},
};
if (jobsList === undefined) {
return jobStats;
}
// object to keep track of nodes being used by jobs
const mlNodes: any = {};
let failedJobs = 0;
jobsList.forEach((job: MlSummaryJob) => {
if (job.jobState === JOB_STATE.OPENED) {
jobStats.open.value++;
} else if (job.jobState === JOB_STATE.CLOSED) {
jobStats.closed.value++;
} else if (job.jobState === JOB_STATE.FAILED) {
failedJobs++;
}
if (job.hasDatafeed && job.datafeedState === DATAFEED_STATE.STARTED) {
jobStats.activeDatafeeds.value++;
}
if (job.nodeName !== undefined) {
mlNodes[job.nodeName] = {};
}
});
jobStats.total.value = jobsList.length;
// Only show failed jobs if it is non-zero
if (failedJobs) {
jobStats.failed.value = failedJobs;
jobStats.failed.show = true;
} else {
jobStats.failed.show = false;
}
jobStats.activeNodes.value = Object.keys(mlNodes).length;
return jobStats;
}
export function getJobsFromGroup(group: Group, jobs: any) {
return group.jobIds.map(jobId => jobs[jobId]).filter(id => id !== undefined);
}
export function getJobsWithTimerange(jobsList: any) {
const jobs: any = {};
jobsList.forEach((job: any) => {
if (jobs[job.id] === undefined) {
// create the job in the object with the times you need
if (job.earliestTimestampMs !== undefined) {
const { earliestTimestampMs, latestResultsTimestampMs } = job;
jobs[job.id] = {
id: job.id,
earliestTimestampMs,
latestResultsTimestampMs,
};
}
}
});
return jobs;
}

View file

@ -0,0 +1,24 @@
/*
* 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, { FC } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { AnomalyDetectionPanel } from './anomaly_detection_panel';
import { AnalyticsPanel } from './analytics_panel/';
// Fetch jobs and determine what to show
export const OverviewContent: FC = () => (
<EuiFlexItem grow={3}>
<EuiFlexGroup direction="column">
<EuiFlexItem grow={false}>
<AnomalyDetectionPanel />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<AnalyticsPanel />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
);

View file

@ -0,0 +1,72 @@
/*
* 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, { FC } from 'react';
import { EuiFlexItem, EuiLink, EuiText } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { metadata } from 'ui/metadata';
const createJobLink = '#/jobs/new_job/step/index_or_search';
// metadata.branch corresponds to the version used in documentation links.
const docsLink = `https://www.elastic.co/guide/en/kibana/${metadata.branch}/xpack-ml.html`;
const feedbackLink = 'https://www.elastic.co/community/';
export const OverviewSideBar: FC = () => (
<EuiFlexItem grow={1}>
<EuiText className="mlOverview__sidebar">
<h2>
<FormattedMessage
id="xpack.ml.overview.gettingStartedSectionTitle"
defaultMessage="Getting Started"
/>
</h2>
<p>
<FormattedMessage
id="xpack.ml.overview.gettingStartedSectionText"
defaultMessage="Welcome to Machine Learning. Get started by reviewing our {docs} or {createJob}.
For information about upcoming features and tutorials be sure to check out our solutions page."
values={{
docs: (
<EuiLink href={docsLink} target="blank">
<FormattedMessage
id="xpack.ml.overview.gettingStartedSectionDocs"
defaultMessage="documentation"
/>
</EuiLink>
),
createJob: (
<EuiLink href={createJobLink} target="blank">
<FormattedMessage
id="xpack.ml.overview.gettingStartedSectionCreateJob"
defaultMessage="creating a new job"
/>
</EuiLink>
),
}}
/>
</p>
<h2>
<FormattedMessage id="xpack.ml.overview.feedbackSectionTitle" defaultMessage="Feedback" />
</h2>
<p>
<FormattedMessage
id="xpack.ml.overview.feedbackSectionText"
defaultMessage="If you have input or suggestions regarding your experience with Machine Learning please feel free to submit {feedbackLink}."
values={{
feedbackLink: (
<EuiLink href={feedbackLink} target="blank">
<FormattedMessage
id="xpack.ml.overview.feedbackSectionLink"
defaultMessage="feedback online"
/>
</EuiLink>
),
}}
/>
</p>
</EuiText>
</EuiFlexItem>
);

View file

@ -0,0 +1,38 @@
/*
* 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 ReactDOM from 'react-dom';
import React from 'react';
import { I18nContext } from 'ui/i18n';
import { timefilter } from 'ui/timefilter';
// @ts-ignore
import { uiModules } from 'ui/modules';
const module = uiModules.get('apps/ml', ['react']);
import { OverviewPage } from './overview_page';
module.directive('mlOverview', function() {
return {
scope: {},
restrict: 'E',
link: async (scope: ng.IScope, element: ng.IAugmentedJQuery) => {
timefilter.disableTimeRangeSelector();
timefilter.disableAutoRefreshSelector();
ReactDOM.render(
<I18nContext>
<OverviewPage />
</I18nContext>,
element[0]
);
element.on('$destroy', () => {
ReactDOM.unmountComponentAtNode(element[0]);
scope.$destroy();
});
},
};
});

View file

@ -0,0 +1,8 @@
/*
* 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 './route';
import './directive';

View file

@ -0,0 +1,27 @@
/*
* 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, { Fragment, FC } from 'react';
import { EuiFlexGroup, EuiPage, EuiPageBody } from '@elastic/eui';
import { NavigationMenu } from '../components/navigation_menu/navigation_menu';
import { OverviewSideBar } from './components/sidebar';
import { OverviewContent } from './components/content';
export const OverviewPage: FC = () => {
return (
<Fragment>
<NavigationMenu tabId="overview" />
<EuiPage data-test-subj="mlPageOverview">
<EuiPageBody>
<EuiFlexGroup>
<OverviewSideBar />
<OverviewContent />
</EuiFlexGroup>
</EuiPageBody>
</EuiPage>
</Fragment>
);
};

View file

@ -0,0 +1,23 @@
/*
* 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 uiRoutes from 'ui/routes';
// @ts-ignore no declaration module
import { checkFullLicense } from '../license/check_license';
import { checkGetJobsPrivilege } from '../privilege/check_privilege';
import { getOverviewBreadcrumbs } from './breadcrumbs';
import './directive';
const template = `<ml-overview />`;
uiRoutes.when('/overview/?', {
template,
k7Breadcrumbs: getOverviewBreadcrumbs,
resolve: {
CheckLicense: checkFullLicense,
privileges: checkGetJobsPrivilege,
},
});

View file

@ -11,6 +11,7 @@ export interface ExistingJobsAndGroups {
declare interface JobService {
currentJob: any;
createResultsUrlForJobs: () => string;
tempJobCloningObjects: {
job: any;
skipTimeRangeStep: boolean;

View file

@ -12,6 +12,7 @@ import {
DataFrameTransformEndpointRequest,
DataFrameTransformEndpointResult,
} from '../../data_frame/pages/transform_management/components/transform_list/common';
import { MlSummaryJobs } from '../../../common/types/jobs';
// TODO This is not a complete representation of all methods of `ml.*`.
// It just satisfies needs for other parts of the code area which use
@ -87,8 +88,12 @@ declare interface Ml {
getVisualizerFieldStats(obj: object): Promise<any>;
getVisualizerOverallStats(obj: object): Promise<any>;
results: {
getMaxAnomalyScore: (jobIds: string[], earliestMs: number, latestMs: number) => Promise<any>; // THIS ONE IS RIGHT
};
jobs: {
jobsSummary(jobIds: string[]): Promise<object>;
jobsSummary(jobIds: string[]): Promise<MlSummaryJobs>;
jobs(jobIds: string[]): Promise<object>;
groups(): Promise<object>;
updateGroups(updatedJobs: string[]): Promise<object>;

View file

@ -45,6 +45,23 @@ export const results = {
});
},
getMaxAnomalyScore(
jobIds,
earliestMs,
latestMs
) {
return http({
url: `${basePath}/results/max_anomaly_score`,
method: 'POST',
data: {
jobIds,
earliestMs,
latestMs
}
});
},
getCategoryDefinition(jobId, categoryId) {
return http({
url: `${basePath}/results/category_definition`,

View file

@ -20,7 +20,13 @@ declare interface MlResultsService {
getScheduledEventsByBucket: () => Promise<any>;
getTopInfluencers: () => Promise<any>;
getTopInfluencerValues: () => Promise<any>;
getOverallBucketScores: () => Promise<any>;
getOverallBucketScores: (
jobIds: any,
topN: any,
earliestMs: any,
latestMs: any,
interval?: any
) => Promise<any>;
getInfluencerValueMaxScoreByTime: () => Promise<any>;
getRecordInfluencers: () => Promise<any>;
getRecordsForInfluencer: () => Promise<any>;

View file

@ -200,6 +200,73 @@ export function resultsServiceProvider(callWithRequest) {
}
// Returns the maximum anomaly_score for result_type:bucket over jobIds for the interval passed in
async function getMaxAnomalyScore(jobIds = [], earliestMs, latestMs) {
// Build the criteria to use in the bool filter part of the request.
// Adds criteria for the time range plus any specified job IDs.
const boolCriteria = [
{
range: {
timestamp: {
gte: earliestMs,
lte: latestMs,
format: 'epoch_millis'
}
}
}
];
if (jobIds.length > 0) {
let jobIdFilterStr = '';
jobIds.forEach((jobId, i) => {
if (i > 0) {
jobIdFilterStr += ' OR ';
}
jobIdFilterStr += 'job_id:';
jobIdFilterStr += jobId;
});
boolCriteria.push({
query_string: {
analyze_wildcard: false,
query: jobIdFilterStr
}
});
}
const query = {
size: 0,
index: ML_RESULTS_INDEX_PATTERN,
body: {
query: {
bool: {
filter: [{
query_string: {
query: 'result_type:bucket',
analyze_wildcard: false
}
}, {
bool: {
must: boolCriteria
}
}]
}
},
aggs: {
max_score: {
max: {
field: 'anomaly_score'
}
}
}
}
};
const resp = await callWithRequest('search', query);
const maxScore = _.get(resp, ['aggregations', 'max_score', 'value'], null);
return maxScore;
}
// Obtains the latest bucket result timestamp by job ID.
// Returns data over all jobs unless an optional list of job IDs of interest is supplied.
// Returned response consists of latest bucket timestamps (ms since Jan 1 1970) against job ID
@ -342,6 +409,7 @@ export function resultsServiceProvider(callWithRequest) {
getCategoryDefinition,
getCategoryExamples,
getLatestBucketTimestampByJob,
getMaxAnomalyScore
};
}

View file

@ -58,6 +58,19 @@ function getCategoryExamples(callWithRequest, payload) {
maxExamples);
}
function getMaxAnomalyScore(callWithRequest, payload) {
const rs = resultsServiceProvider(callWithRequest);
const {
jobIds,
earliestMs,
latestMs } = payload;
return rs.getMaxAnomalyScore(
jobIds,
earliestMs,
latestMs);
}
export function resultsServiceRoutes({ commonRouteConfig, elasticsearchPlugin, route }) {
route({
@ -86,6 +99,19 @@ export function resultsServiceRoutes({ commonRouteConfig, elasticsearchPlugin, r
}
});
route({
method: 'POST',
path: '/api/ml/results/max_anomaly_score',
handler(request) {
const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request);
return getMaxAnomalyScore(callWithRequest, request.payload)
.catch(resp => wrapError(resp));
},
config: {
...commonRouteConfig
}
});
route({
method: 'POST',
path: '/api/ml/results/category_examples',

View file

@ -51,7 +51,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) {
basePath: '/s/custom_space',
});
await testSubjects.existOrFail('ml-jobs-list');
await testSubjects.existOrFail('mlPageOverview');
});
});

View file

@ -24,6 +24,10 @@ export default function({ getService }: FtrProviderContext) {
await ml.navigation.navigateToMl();
});
it('loads the overview page', async () => {
await ml.navigation.navigateToOverview();
});
it('loads the anomaly detection area', async () => {
await ml.navigation.navigateToAnomalyDetection();
});

View file

@ -50,6 +50,10 @@ export function MachineLearningNavigationProvider({
]);
},
async navigateToOverview() {
await this.navigateToArea('mlMainTab overview', 'mlPageOverview');
},
async navigateToAnomalyDetection() {
await this.navigateToArea('mlMainTab anomalyDetection', 'mlPageJobManagement');
await this.assertTabsExist('mlSubTab', [