diff --git a/x-pack/legacy/plugins/ml/common/types/jobs.ts b/x-pack/legacy/plugins/ml/common/types/jobs.ts index 7b18ebccd5244..07c2be3e7f0b4 100644 --- a/x-pack/legacy/plugins/ml/common/types/jobs.ts +++ b/x-pack/legacy/plugins/ml/common/types/jobs.ts @@ -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'; } diff --git a/x-pack/legacy/plugins/ml/public/app.js b/x-pack/legacy/plugins/ml/public/app.js index ca0427544e83a..7cea0edaa7df7 100644 --- a/x-pack/legacy/plugins/ml/public/app.js +++ b/x-pack/legacy/plugins/ml/public/app.js @@ -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' }); diff --git a/x-pack/legacy/plugins/ml/public/components/ml_in_memory_table/index.ts b/x-pack/legacy/plugins/ml/public/components/ml_in_memory_table/index.ts new file mode 100644 index 0000000000000..91bf31ea1e7ab --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/components/ml_in_memory_table/index.ts @@ -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'; diff --git a/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_table.tsx b/x-pack/legacy/plugins/ml/public/components/ml_in_memory_table/ml_in_memory_table.tsx similarity index 95% rename from x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_table.tsx rename to x-pack/legacy/plugins/ml/public/components/ml_in_memory_table/ml_in_memory_table.tsx index 006aef70e5528..d5316b22a6a6f 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_table.tsx +++ b/x-pack/legacy/plugins/ml/public/components/ml_in_memory_table/ml_in_memory_table.tsx @@ -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, diff --git a/x-pack/legacy/plugins/ml/common/types/eui/in_memory_table.ts b/x-pack/legacy/plugins/ml/public/components/ml_in_memory_table/types.ts similarity index 97% rename from x-pack/legacy/plugins/ml/common/types/eui/in_memory_table.ts rename to x-pack/legacy/plugins/ml/public/components/ml_in_memory_table/types.ts index d5a0a50fe6a54..22c35fc453b88 100644 --- a/x-pack/legacy/plugins/ml/common/types/eui/in_memory_table.ts +++ b/x-pack/legacy/plugins/ml/public/components/ml_in_memory_table/types.ts @@ -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 extends Component { new (): Component; } -export const MlInMemoryTable = (EuiInMemoryTable as any) as ComponentWithConstructor< +export const MlInMemoryTableBasic = (EuiInMemoryTable as any) as ComponentWithConstructor< EuiInMemoryTableProps >; diff --git a/x-pack/legacy/plugins/ml/public/components/navigation_menu/main_tabs.tsx b/x-pack/legacy/plugins/ml/public/components/navigation_menu/main_tabs.tsx index 1b2b3e61a27c4..0ba38abb454e2 100644 --- a/x-pack/legacy/plugins/ml/public/components/navigation_menu/main_tabs.tsx +++ b/x-pack/legacy/plugins/ml/public/components/navigation_menu/main_tabs.tsx @@ -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 = { - // 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' }, diff --git a/x-pack/legacy/plugins/ml/public/components/navigation_menu/navigation_menu.tsx b/x-pack/legacy/plugins/ml/public/components/navigation_menu/navigation_menu.tsx index d61972bc0d850..5961bd5e71873 100644 --- a/x-pack/legacy/plugins/ml/public/components/navigation_menu/navigation_menu.tsx +++ b/x-pack/legacy/plugins/ml/public/components/navigation_menu/navigation_menu.tsx @@ -18,7 +18,7 @@ export type TabId = string; type TabSupport = Record; const tabSupport: TabSupport = { - // overview: null, + overview: null, jobs: 'anomaly_detection', settings: 'anomaly_detection', data_frames: null, diff --git a/x-pack/legacy/plugins/ml/public/components/navigation_menu/navigation_menu_react_wrapper_directive.js b/x-pack/legacy/plugins/ml/public/components/navigation_menu/navigation_menu_react_wrapper_directive.js index dcdaec159cc0a..543f04b6a4125 100644 --- a/x-pack/legacy/plugins/ml/public/components/navigation_menu/navigation_menu_react_wrapper_directive.js +++ b/x-pack/legacy/plugins/ml/public/components/navigation_menu/navigation_menu_react_wrapper_directive.js @@ -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 () { diff --git a/x-pack/legacy/plugins/ml/public/components/navigation_menu/tabs.tsx b/x-pack/legacy/plugins/ml/public/components/navigation_menu/tabs.tsx index 7a428877f8fd8..89ada7453c7c5 100644 --- a/x-pack/legacy/plugins/ml/public/components/navigation_menu/tabs.tsx +++ b/x-pack/legacy/plugins/ml/public/components/navigation_menu/tabs.tsx @@ -19,7 +19,7 @@ interface Props { export function getTabs(tabId: TabId, disableLinks: boolean): Tab[] { const TAB_MAP: Partial> = { - // 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', diff --git a/x-pack/legacy/plugins/ml/public/components/stats_bar/index.ts b/x-pack/legacy/plugins/ml/public/components/stats_bar/index.ts index 4c781afe0a64c..003378cfc14a5 100644 --- a/x-pack/legacy/plugins/ml/public/components/stats_bar/index.ts +++ b/x-pack/legacy/plugins/ml/public/components/stats_bar/index.ts @@ -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'; diff --git a/x-pack/legacy/plugins/ml/public/components/stats_bar/stats_bar.tsx b/x-pack/legacy/plugins/ml/public/components/stats_bar/stats_bar.tsx index 0995b1e70df5d..0ebc4647e030f 100644 --- a/x-pack/legacy/plugins/ml/public/components/stats_bar/stats_bar.tsx +++ b/x-pack/legacy/plugins/ml/public/components/stats_bar/stats_bar.tsx @@ -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 { diff --git a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/source_index_preview/source_index_preview.tsx b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/source_index_preview/source_index_preview.tsx index c26ce59343f45..7c7b211fe43db 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/source_index_preview/source_index_preview.tsx +++ b/x-pack/legacy/plugins/ml/public/data_frame/pages/data_frame_new_pivot/components/source_index_preview/source_index_preview.tsx @@ -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 = React.memo(({ cellClick, que )} {clearTable === false && columns.length > 0 && sorting !== false && ( - = React.memo(({ aggs, groupBy, )} {dataFramePreviewData.length > 0 && clearTable === false && columns.length > 0 && ( - { }; }; -export class TransformTable extends MlInMemoryTable { +export class TransformTable extends MlInMemoryTableBasic { static getDerivedStateFromProps(nextProps: any, prevState: any) { const derivedState = { ...prevState.prevProps, diff --git a/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_exploration/components/exploration/exploration.tsx b/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_exploration/components/exploration/exploration.tsx index fa9e68d0b25d3..95ec334667547 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_exploration/components/exploration/exploration.tsx +++ b/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_exploration/components/exploration/exploration.tsx @@ -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 = React.memo(({ jobId }) => { )} {clearTable === false && columns.length > 0 && sortField !== '' && ( - { + return ( + (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', + })} + + ); + }, +}; + export const getActions = () => { const canStartStopDataFrameAnalytics: boolean = checkPermission('canStartStopDataFrameAnalytics'); return [ - { - isPrimary: true, - render: (item: DataFrameAnalyticsListRow) => { - return ( - (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', - })} - - ); - }, - }, + AnalyticsViewAction, { render: (item: DataFrameAnalyticsListRow) => { if (!isDataFrameAnalyticsRunning(item.stats)) { diff --git a/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx b/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx index de87ca71e5181..45f53691eab8f 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx +++ b/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx @@ -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 = ({ return ( - 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 ( + + {isBatchTransform && ( + + + + {progress}% + + + + {`${progress}%`} + + + )} + {!isBatchTransform && ( + + + {item.stats.state === DATA_FRAME_TASK_STATE.STARTED && ( + + )} + {item.stats.state === DATA_FRAME_TASK_STATE.STOPPED && ( + + )} + + +   + + + )} + + ); + }, + width: '100px', +}; + export const getColumns = ( expandedRowItemIds: DataFrameAnalyticsId[], setExpandedRowItemIds: React.Dispatch>, @@ -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 ( - - {isBatchTransform && ( - - - - {progress}% - - - - {`${progress}%`} - - - )} - {!isBatchTransform && ( - - - {item.stats.state === DATA_FRAME_TASK_STATE.STARTED && ( - - )} - {item.stats.state === DATA_FRAME_TASK_STATE.STOPPED && ( - - )} - - -   - - - )} - - ); - }, - width: '100px', - }, + progressColumn, ]; if (isManagementTable === true) { diff --git a/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/components/analytics_list/common.ts b/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/components/analytics_list/common.ts index 3ad493c9e2b2d..c8dd101af8ab1 100644 --- a/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/components/analytics_list/common.ts +++ b/x-pack/legacy/plugins/ml/public/data_frame_analytics/pages/analytics_management/components/analytics_list/common.ts @@ -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', diff --git a/x-pack/legacy/plugins/ml/public/index.scss b/x-pack/legacy/plugins/ml/public/index.scss index 2a751aea1d831..4cab633d5fa56 100644 --- a/x-pack/legacy/plugins/ml/public/index.scss +++ b/x-pack/legacy/plugins/ml/public/index.scss @@ -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'; diff --git a/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/job_actions/results.js b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/job_actions/results.js index 172c198509d28..5ec407f7f054e 100644 --- a/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/job_actions/results.js +++ b/x-pack/legacy/plugins/ml/public/jobs/jobs_list/components/job_actions/results.js @@ -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}`; } diff --git a/x-pack/legacy/plugins/ml/public/overview/_index.scss b/x-pack/legacy/plugins/ml/public/overview/_index.scss new file mode 100644 index 0000000000000..192091fb04e3c --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/overview/_index.scss @@ -0,0 +1 @@ +@import './components/index'; diff --git a/x-pack/legacy/plugins/ml/public/overview/breadcrumbs.ts b/x-pack/legacy/plugins/ml/public/overview/breadcrumbs.ts new file mode 100644 index 0000000000000..893ae5de450ad --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/overview/breadcrumbs.ts @@ -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: '', + }, + ]; +} diff --git a/x-pack/legacy/plugins/ml/public/overview/components/_index.scss b/x-pack/legacy/plugins/ml/public/overview/components/_index.scss new file mode 100644 index 0000000000000..e7c39544da45b --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/overview/components/_index.scss @@ -0,0 +1,12 @@ +.mlOverviewPanel { + padding-top: 0; +} + +.mlOverviewPanel__buttons { + float: right; +} + +.mlOverviewPanel__statsBar { + margin-top: 0; + margin-right: 0 +} diff --git a/x-pack/legacy/plugins/ml/public/overview/components/analytics_panel/analytics_panel.tsx b/x-pack/legacy/plugins/ml/public/overview/components/analytics_panel/analytics_panel.tsx new file mode 100644 index 0000000000000..d45a137d9e9a3 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/overview/components/analytics_panel/analytics_panel.tsx @@ -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([]); + const [errorMessage, setErrorMessage] = useState(undefined); + const [isInitialized, setIsInitialized] = useState(false); + + const getAnalytics = getAnalyticsFactory(setAnalytics, setErrorMessage, setIsInitialized, false); + + useEffect(() => { + getAnalytics(true); + }, []); + + const onRefresh = () => { + getAnalytics(true); + }; + + const errorDisplay = ( + + +
+          {errorMessage && errorMessage.message !== undefined
+            ? errorMessage.message
+            : JSON.stringify(errorMessage)}
+        
+
+
+ ); + + return ( + + {typeof errorMessage !== 'undefined' && errorDisplay} + {isInitialized === false && }      + {isInitialized === true && analytics.length === 0 && ( + + {i18n.translate('xpack.ml.overview.analyticsList.createFirstJobMessage', { + defaultMessage: 'Create your first analytics job.', + })} + + } + body={ + +

+ {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.`, + })} +

+
+ } + actions={ + + {i18n.translate('xpack.ml.overview.analyticsList.createJobButtonText', { + defaultMessage: 'Create job.', + })} + + } + /> + )} + {isInitialized === true && analytics.length > 0 && ( + + + +
+ + {i18n.translate('xpack.ml.overview.analyticsList.refreshJobsButtonText', { + defaultMessage: 'Refresh', + })} + + + {i18n.translate('xpack.ml.overview.analyticsList.manageJobsButtonText', { + defaultMessage: 'Manage jobs', + })} + +
+
+ )} +
+ ); +}; diff --git a/x-pack/legacy/plugins/ml/public/overview/components/analytics_panel/analytics_stats_bar.tsx b/x-pack/legacy/plugins/ml/public/overview/components/analytics_panel/analytics_stats_bar.tsx new file mode 100644 index 0000000000000..d3360b60f7e53 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/overview/components/analytics_panel/analytics_stats_bar.tsx @@ -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 = ({ analyticsList }) => { + const analyticsStats: AnalyticStatsBarStats = getAnalyticsStats(analyticsList); + + return ; +}; diff --git a/x-pack/legacy/plugins/ml/public/overview/components/analytics_panel/index.ts b/x-pack/legacy/plugins/ml/public/overview/components/analytics_panel/index.ts new file mode 100644 index 0000000000000..24bfc63b3da3a --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/overview/components/analytics_panel/index.ts @@ -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'; diff --git a/x-pack/legacy/plugins/ml/public/overview/components/analytics_panel/table.tsx b/x-pack/legacy/plugins/ml/public/overview/components/analytics_panel/table.tsx new file mode 100644 index 0000000000000..1ac767ab97700 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/overview/components/analytics_panel/table.tsx @@ -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 = ({ items }) => { + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(10); + + const [sortField, setSortField] = useState(DataFrameAnalyticsListColumn.id); + const [sortDirection, setSortDirection] = useState(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 {getAnalysisType(item.config.analysis)}; + }, + 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 ( + + + + +

+ {i18n.translate('xpack.ml.overview.analyticsList.PanelTitle', { + defaultMessage: 'Analytics', + })} +

+
+
+ + + +
+ + +
+ ); +}; diff --git a/x-pack/legacy/plugins/ml/public/overview/components/anomaly_detection_panel/actions.tsx b/x-pack/legacy/plugins/ml/public/overview/components/anomaly_detection_panel/actions.tsx new file mode 100644 index 0000000000000..e865fd44c2a19 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/overview/components/anomaly_detection_panel/actions.tsx @@ -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 = ({ 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 ( + + + {i18n.translate('xpack.ml.overview.anomalyDetection.exploreActionName', { + defaultMessage: 'Explore', + })} + + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/overview/components/anomaly_detection_panel/anomaly_detection_panel.tsx b/x-pack/legacy/plugins/ml/public/overview/components/anomaly_detection_panel/anomaly_detection_panel.tsx new file mode 100644 index 0000000000000..bb1a2a6c68e92 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/overview/components/anomaly_detection_panel/anomaly_detection_panel.tsx @@ -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; + +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({}); + const [groupsCount, setGroupsCount] = useState(0); + const [jobsList, setJobsList] = useState([]); + const [statsBarData, setStatsBarData] = useState(undefined); + const [errorMessage, setErrorMessage] = useState(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 = ( + + +
{errorMessage}
+
+
+ ); + + return ( + + {typeof errorMessage !== 'undefined' && errorDisplay} + {isLoading && }    + {isLoading === false && typeof errorMessage === 'undefined' && groupsCount === 0 && ( + + {i18n.translate('xpack.ml.overview.anomalyDetection.createFirstJobMessage', { + defaultMessage: 'Create your first anomaly detection job.', + })} + + } + body={ + +

+ {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.`, + })} +

+
+ } + actions={ + + {i18n.translate('xpack.ml.overview.anomalyDetection.createJobButtonText', { + defaultMessage: 'Create job.', + })} + + } + /> + )} + {isLoading === false && typeof errorMessage === 'undefined' && groupsCount > 0 && ( + + + +
+ + {i18n.translate('xpack.ml.overview.anomalyDetection.refreshJobsButtonText', { + defaultMessage: 'Refresh', + })} + + + {i18n.translate('xpack.ml.overview.anomalyDetection.manageJobsButtonText', { + defaultMessage: 'Manage jobs', + })} + +
+
+ )} +
+ ); +}; diff --git a/x-pack/legacy/plugins/ml/public/overview/components/anomaly_detection_panel/index.ts b/x-pack/legacy/plugins/ml/public/overview/components/anomaly_detection_panel/index.ts new file mode 100644 index 0000000000000..1ccbd58b67295 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/overview/components/anomaly_detection_panel/index.ts @@ -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'; diff --git a/x-pack/legacy/plugins/ml/public/overview/components/anomaly_detection_panel/table.tsx b/x-pack/legacy/plugins/ml/public/overview/components/anomaly_detection_panel/table.tsx new file mode 100644 index 0000000000000..a9c5ae0e5f31b --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/overview/components/anomaly_detection_panel/table.tsx @@ -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 = ({ items, jobsList, statsBarData }) => { + const groupsList = Object.values(items); + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(10); + + const [sortField, setSortField] = useState(AnomalyDetectionListColumns.id); + const [sortDirection, setSortDirection] = useState(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']) => , + 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 ; + } else { + const color: string = getSeverityColor(score); + return ( + // @ts-ignore + + {score >= 1 ? Math.floor(score) : '< 1'} + + ); + } + }, + 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) => , + 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 ( + + + + +

+ {i18n.translate('xpack.ml.overview.anomalyDetection.panelTitle', { + defaultMessage: 'Anomaly Detection', + })} +

+
+
+ + + +
+ + +
+ ); +}; diff --git a/x-pack/legacy/plugins/ml/public/overview/components/anomaly_detection_panel/utils.ts b/x-pack/legacy/plugins/ml/public/overview/components/anomaly_detection_panel/utils.ts new file mode 100644 index 0000000000000..f33b60853ea66 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/overview/components/anomaly_detection_panel/utils.ts @@ -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; +} diff --git a/x-pack/legacy/plugins/ml/public/overview/components/content.tsx b/x-pack/legacy/plugins/ml/public/overview/components/content.tsx new file mode 100644 index 0000000000000..98295fe0a1a49 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/overview/components/content.tsx @@ -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 = () => ( + + + + + + + + + + +); diff --git a/x-pack/legacy/plugins/ml/public/overview/components/sidebar.tsx b/x-pack/legacy/plugins/ml/public/overview/components/sidebar.tsx new file mode 100644 index 0000000000000..26b67c7774978 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/overview/components/sidebar.tsx @@ -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 = () => ( + + +

+ +

+

+ + + + ), + createJob: ( + + + + ), + }} + /> +

+

+ +

+

+ + + + ), + }} + /> +

+
+
+); diff --git a/x-pack/legacy/plugins/ml/public/overview/directive.tsx b/x-pack/legacy/plugins/ml/public/overview/directive.tsx new file mode 100644 index 0000000000000..bd3b653ccbb64 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/overview/directive.tsx @@ -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( + + + , + element[0] + ); + + element.on('$destroy', () => { + ReactDOM.unmountComponentAtNode(element[0]); + scope.$destroy(); + }); + }, + }; +}); diff --git a/x-pack/legacy/plugins/ml/public/overview/index.ts b/x-pack/legacy/plugins/ml/public/overview/index.ts new file mode 100644 index 0000000000000..ac00eab1f2cdb --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/overview/index.ts @@ -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'; diff --git a/x-pack/legacy/plugins/ml/public/overview/overview_page.tsx b/x-pack/legacy/plugins/ml/public/overview/overview_page.tsx new file mode 100644 index 0000000000000..bb9e45ae84a41 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/overview/overview_page.tsx @@ -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 ( + + + + + + + + + + + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/overview/route.ts b/x-pack/legacy/plugins/ml/public/overview/route.ts new file mode 100644 index 0000000000000..4f5ce7e453f9a --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/overview/route.ts @@ -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 = ``; + +uiRoutes.when('/overview/?', { + template, + k7Breadcrumbs: getOverviewBreadcrumbs, + resolve: { + CheckLicense: checkFullLicense, + privileges: checkGetJobsPrivilege, + }, +}); diff --git a/x-pack/legacy/plugins/ml/public/services/job_service.d.ts b/x-pack/legacy/plugins/ml/public/services/job_service.d.ts index f94c0e866451d..3b60a7af505d7 100644 --- a/x-pack/legacy/plugins/ml/public/services/job_service.d.ts +++ b/x-pack/legacy/plugins/ml/public/services/job_service.d.ts @@ -11,6 +11,7 @@ export interface ExistingJobsAndGroups { declare interface JobService { currentJob: any; + createResultsUrlForJobs: () => string; tempJobCloningObjects: { job: any; skipTimeRangeStep: boolean; diff --git a/x-pack/legacy/plugins/ml/public/services/ml_api_service/index.d.ts b/x-pack/legacy/plugins/ml/public/services/ml_api_service/index.d.ts index c593da3f2c4ee..475e723f9fbc4 100644 --- a/x-pack/legacy/plugins/ml/public/services/ml_api_service/index.d.ts +++ b/x-pack/legacy/plugins/ml/public/services/ml_api_service/index.d.ts @@ -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; getVisualizerOverallStats(obj: object): Promise; + results: { + getMaxAnomalyScore: (jobIds: string[], earliestMs: number, latestMs: number) => Promise; // THIS ONE IS RIGHT + }; + jobs: { - jobsSummary(jobIds: string[]): Promise; + jobsSummary(jobIds: string[]): Promise; jobs(jobIds: string[]): Promise; groups(): Promise; updateGroups(updatedJobs: string[]): Promise; diff --git a/x-pack/legacy/plugins/ml/public/services/ml_api_service/results.js b/x-pack/legacy/plugins/ml/public/services/ml_api_service/results.js index aab8e8bd70abc..7a776d61dca21 100644 --- a/x-pack/legacy/plugins/ml/public/services/ml_api_service/results.js +++ b/x-pack/legacy/plugins/ml/public/services/ml_api_service/results.js @@ -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`, diff --git a/x-pack/legacy/plugins/ml/public/services/results_service.d.ts b/x-pack/legacy/plugins/ml/public/services/results_service.d.ts index 4d9b346000ba4..2bbe37c3fc05d 100644 --- a/x-pack/legacy/plugins/ml/public/services/results_service.d.ts +++ b/x-pack/legacy/plugins/ml/public/services/results_service.d.ts @@ -20,7 +20,13 @@ declare interface MlResultsService { getScheduledEventsByBucket: () => Promise; getTopInfluencers: () => Promise; getTopInfluencerValues: () => Promise; - getOverallBucketScores: () => Promise; + getOverallBucketScores: ( + jobIds: any, + topN: any, + earliestMs: any, + latestMs: any, + interval?: any + ) => Promise; getInfluencerValueMaxScoreByTime: () => Promise; getRecordInfluencers: () => Promise; getRecordsForInfluencer: () => Promise; diff --git a/x-pack/legacy/plugins/ml/server/models/results_service/results_service.js b/x-pack/legacy/plugins/ml/server/models/results_service/results_service.js index 7c00c3540eca4..3fd20308b2f9b 100644 --- a/x-pack/legacy/plugins/ml/server/models/results_service/results_service.js +++ b/x-pack/legacy/plugins/ml/server/models/results_service/results_service.js @@ -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 }; } diff --git a/x-pack/legacy/plugins/ml/server/routes/results_service.js b/x-pack/legacy/plugins/ml/server/routes/results_service.js index 9f1869ed55007..82688eb7faf20 100644 --- a/x-pack/legacy/plugins/ml/server/routes/results_service.js +++ b/x-pack/legacy/plugins/ml/server/routes/results_service.js @@ -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', diff --git a/x-pack/test/functional/apps/machine_learning/feature_controls/ml_spaces.ts b/x-pack/test/functional/apps/machine_learning/feature_controls/ml_spaces.ts index ea3c10f16ca60..b81656cd6c4b7 100644 --- a/x-pack/test/functional/apps/machine_learning/feature_controls/ml_spaces.ts +++ b/x-pack/test/functional/apps/machine_learning/feature_controls/ml_spaces.ts @@ -51,7 +51,7 @@ export default function({ getPageObjects, getService }: FtrProviderContext) { basePath: '/s/custom_space', }); - await testSubjects.existOrFail('ml-jobs-list'); + await testSubjects.existOrFail('mlPageOverview'); }); }); diff --git a/x-pack/test/functional/apps/machine_learning/pages.ts b/x-pack/test/functional/apps/machine_learning/pages.ts index f336ae0a570dc..0c997c7389317 100644 --- a/x-pack/test/functional/apps/machine_learning/pages.ts +++ b/x-pack/test/functional/apps/machine_learning/pages.ts @@ -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(); }); diff --git a/x-pack/test/functional/services/machine_learning/navigation.ts b/x-pack/test/functional/services/machine_learning/navigation.ts index 3ddf7196c52bb..b73cd9825b1bd 100644 --- a/x-pack/test/functional/services/machine_learning/navigation.ts +++ b/x-pack/test/functional/services/machine_learning/navigation.ts @@ -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', [