[ML] Data frames: Analytics jobs list. (#42598)

Introduces the data frame analytics jobs list.
This commit is contained in:
Walter Rafelsberger 2019-08-08 15:35:29 +02:00 committed by GitHub
parent 492ade3d77
commit 81d7d6c2a3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
52 changed files with 2720 additions and 11 deletions

View file

@ -12,6 +12,10 @@ export interface AuditMessageBase {
text?: string;
}
export interface AnalyticsMessage extends AuditMessageBase {
analytics_id: string;
}
export interface TransformMessage extends AuditMessageBase {
transform_id: string;
}

View file

@ -28,12 +28,17 @@ export interface Privileges {
canDeleteFilter: boolean;
// File Data Visualizer
canFindFileStructure: boolean;
// Data Frames
// Data Frame Transforms
canGetDataFrame: boolean;
canDeleteDataFrame: boolean;
canPreviewDataFrame: boolean;
canCreateDataFrame: boolean;
canStartStopDataFrame: boolean;
// Data Frame Analytics
canGetDataFrameAnalytics: boolean;
canDeleteDataFrameAnalytics: boolean;
canCreateDataFrameAnalytics: boolean;
canStartStopDataFrameAnalytics: boolean;
}
export function getDefaultPrivileges(): Privileges {
@ -60,12 +65,17 @@ export function getDefaultPrivileges(): Privileges {
canDeleteFilter: false,
// File Data Visualizer
canFindFileStructure: false,
// Data Frames
// Data Frame Transforms
canGetDataFrame: false,
canDeleteDataFrame: false,
canPreviewDataFrame: false,
canCreateDataFrame: false,
canStartStopDataFrame: false,
// Data Frame Analytics
canGetDataFrameAnalytics: false,
canDeleteDataFrameAnalytics: false,
canCreateDataFrameAnalytics: false,
canStartStopDataFrameAnalytics: false,
};
}

View file

@ -22,6 +22,7 @@ import 'plugins/ml/jobs';
import 'plugins/ml/services/calendar_service';
import 'plugins/ml/components/messagebar';
import 'plugins/ml/data_frame';
import 'plugins/ml/data_frame_analytics';
import 'plugins/ml/data_visualizer';
import 'plugins/ml/datavisualizer';
import 'plugins/ml/explorer';

View file

@ -17,6 +17,7 @@ const tabSupport = [
'jobs',
'settings',
'data_frames',
'data_frame_analytics',
'datavisualizer',
'filedatavisualizer',
'timeseriesexplorer',

View file

@ -50,6 +50,13 @@ function getTabs(disableLinks: boolean): Tab[] {
}),
disabled: false,
},
{
id: 'data_frame_analytics',
name: i18n.translate('xpack.ml.navMenu.dataFrameAnalyticsTabLinkText', {
defaultMessage: 'Analytics',
}),
disabled: disableLinks,
},
{
id: 'datavisualizer',
name: i18n.translate('xpack.ml.navMenu.dataVisualizerTabLinkText', {
@ -72,6 +79,7 @@ enum TAB_TEST_SUBJECT {
explorer = 'mlTabAnomalyExplorer',
timeseriesexplorer = 'mlTabSingleMetricViewer',
data_frames = 'mlTabDataFrames', // eslint-disable-line
data_frame_analytics = 'mlTabDataFrameAnalytics', // eslint-disable-line
datavisualizer = 'mlTabDataVisualizer',
settings = 'mlTabSettings',
}

View file

@ -37,13 +37,17 @@ exports[`Data Frame: Job List <Page /> Minimal initialization 1`] = `
<EuiFlexGroup
alignItems="center"
>
<EuiFlexItem>
<EuiFlexItem
grow={false}
>
<RefreshTransformListButton
isLoading={false}
onClick={[Function]}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexItem
grow={false}
>
<CreateTransformButton />
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -63,10 +63,12 @@ export const Page: FC = () => {
</EuiPageContentHeaderSection>
<EuiPageContentHeaderSection>
<EuiFlexGroup alignItems="center">
<EuiFlexItem>
{/* grow={false} fixes IE11 issue with nested flex */}
<EuiFlexItem grow={false}>
<RefreshTransformListButton onClick={refresh} isLoading={isLoading} />
</EuiFlexItem>
<EuiFlexItem>
{/* grow={false} fixes IE11 issue with nested flex */}
<EuiFlexItem grow={false}>
<CreateTransformButton />
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -0,0 +1 @@
@import 'pages/analytics_management/components/analytics_list/index';

View file

@ -0,0 +1,22 @@
/*
* 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 getDataFrameAnalyticsBreadcrumbs() {
return [
ML_BREADCRUMB,
{
text: i18n.translate('xpack.ml.dataFrameAnalyticsBreadcrumbs.dataFrameLabel', {
defaultMessage: 'Analytics',
}),
href: '',
},
];
}

View file

@ -0,0 +1,102 @@
/*
* 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 { useEffect } from 'react';
import { BehaviorSubject } from 'rxjs';
import { filter, distinctUntilChanged } from 'rxjs/operators';
import { Subscription } from 'rxjs';
// @ts-ignore
import { isJobIdValid } from '../../../common/util/job_utils';
// TODO
export const moveToAnalyticsWizard = () => {};
export const isAnalyticsIdValid = isJobIdValid;
export type IndexName = string;
export type IndexPattern = string;
export type DataFrameAnalyticsId = string;
export interface CreateRequestBody {
// Description attribute is not supported yet
// description?: string;
dest: {
index: IndexName;
results_field: string;
};
}
export interface DataFrameAnalyticsOutlierConfig extends CreateRequestBody {
id: DataFrameAnalyticsId;
analysis: {
outlier_detection: {};
};
analyzed_fields: {
includes: string[];
excludes: string[];
};
model_memory_limit: string;
create_time: number;
version: string;
}
export enum REFRESH_ANALYTICS_LIST_STATE {
ERROR = 'error',
IDLE = 'idle',
LOADING = 'loading',
REFRESH = 'refresh',
}
export const refreshAnalyticsList$ = new BehaviorSubject<REFRESH_ANALYTICS_LIST_STATE>(
REFRESH_ANALYTICS_LIST_STATE.IDLE
);
export const useRefreshAnalyticsList = (
callback: {
isLoading?(d: boolean): void;
onRefresh?(): void;
} = {}
) => {
useEffect(() => {
const distinct$ = refreshAnalyticsList$.pipe(distinctUntilChanged());
const subscriptions: Subscription[] = [];
if (typeof callback.onRefresh === 'function') {
// initial call to refresh
callback.onRefresh();
subscriptions.push(
distinct$
.pipe(filter(state => state === REFRESH_ANALYTICS_LIST_STATE.REFRESH))
.subscribe(() => typeof callback.onRefresh === 'function' && callback.onRefresh())
);
}
if (typeof callback.isLoading === 'function') {
subscriptions.push(
distinct$.subscribe(
state =>
typeof callback.isLoading === 'function' &&
callback.isLoading(state === REFRESH_ANALYTICS_LIST_STATE.LOADING)
)
);
}
return () => {
subscriptions.map(sub => sub.unsubscribe());
};
}, []);
return {
refresh: () => {
// A refresh is followed immediately by setting the state to loading
// to trigger data fetching and loading indicators in one go.
refreshAnalyticsList$.next(REFRESH_ANALYTICS_LIST_STATE.REFRESH);
refreshAnalyticsList$.next(REFRESH_ANALYTICS_LIST_STATE.LOADING);
},
};
};

View file

@ -0,0 +1,18 @@
/*
* 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 {
isAnalyticsIdValid,
moveToAnalyticsWizard,
refreshAnalyticsList$,
useRefreshAnalyticsList,
CreateRequestBody,
DataFrameAnalyticsId,
DataFrameAnalyticsOutlierConfig,
IndexName,
IndexPattern,
REFRESH_ANALYTICS_LIST_STATE,
} from './analytics';

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 './pages/analytics_management/directive';
import './pages/analytics_management/route';

View file

@ -0,0 +1,26 @@
.mlAnalyticsTable {
// Using an override as a last resort because we cannot set custom classes on
// nested upstream components. The opening animation limits the height
// of the expanded row to 1000px which turned out to be not predictable.
// The animation could also result in flickering with expanded rows
// where the inner content would result in the DOM changing the height.
.euiTableRow-isExpandedRow .euiTableCellContent {
animation: none !important;
.euiTableCellContent__text {
width: 100%;
}
}
// Another override: Because an update to the table replaces the DOM, the same
// icon would still again fade in with an animation. If the table refreshes with
// e.g. 1s this would result in a blinking icon effect.
.euiIcon-isLoaded {
animation: none !important;
}
}
.mlAnalyticsProgressBar {
margin-bottom: $euiSizeM;
}
.mlTaskStateBadge, .mlTaskModeBadge {
max-width: 100px;
}

View file

@ -0,0 +1,118 @@
/*
* 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, useState } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiButtonEmpty,
EuiConfirmModal,
EuiOverlayMask,
EuiToolTip,
EUI_MODAL_CONFIRM_BUTTON,
} from '@elastic/eui';
import { deleteAnalytics } from '../../services/analytics_service';
import {
checkPermission,
createPermissionFailureMessage,
} from '../../../../../privilege/check_privilege';
import { DataFrameAnalyticsListRow, DATA_FRAME_TASK_STATE } from './common';
interface DeleteActionProps {
item: DataFrameAnalyticsListRow;
}
export const DeleteAction: FC<DeleteActionProps> = ({ item }) => {
const disabled = item.stats.state === DATA_FRAME_TASK_STATE.STARTED;
const canDeleteDataFrame: boolean = checkPermission('canDeleteDataFrame');
const [isModalVisible, setModalVisible] = useState(false);
const closeModal = () => setModalVisible(false);
const deleteAndCloseModal = () => {
setModalVisible(false);
deleteAnalytics(item);
};
const openModal = () => setModalVisible(true);
const buttonDeleteText = i18n.translate('xpack.ml.dataframe.analyticsList.deleteActionName', {
defaultMessage: 'Delete',
});
let deleteButton = (
<EuiButtonEmpty
size="xs"
color="text"
disabled={disabled || !canDeleteDataFrame}
iconType="trash"
onClick={openModal}
aria-label={buttonDeleteText}
>
{buttonDeleteText}
</EuiButtonEmpty>
);
if (disabled || !canDeleteDataFrame) {
deleteButton = (
<EuiToolTip
position="top"
content={
disabled
? i18n.translate(
'xpack.ml.dataframe.analyticsList.deleteActionDisabledToolTipContent',
{
defaultMessage: 'Stop the data frame analytics in order to delete it.',
}
)
: createPermissionFailureMessage('canStartStopDataFrameAnalytics')
}
>
{deleteButton}
</EuiToolTip>
);
}
return (
<Fragment>
{deleteButton}
{isModalVisible && (
<EuiOverlayMask>
<EuiConfirmModal
title={i18n.translate('xpack.ml.dataframe.analyticsList.deleteModalTitle', {
defaultMessage: 'Delete {analyticsId}',
values: { analyticsId: item.config.id },
})}
onCancel={closeModal}
onConfirm={deleteAndCloseModal}
cancelButtonText={i18n.translate(
'xpack.ml.dataframe.analyticsList.deleteModalCancelButton',
{
defaultMessage: 'Cancel',
}
)}
confirmButtonText={i18n.translate(
'xpack.ml.dataframe.analyticsList.deleteModalDeleteButton',
{
defaultMessage: 'Delete',
}
)}
defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON}
buttonColor="danger"
>
<p>
{i18n.translate('xpack.ml.dataframe.analyticsList.deleteModalBody', {
defaultMessage: `Are you sure you want to delete this analytics job? The analytics job's destination index and optional Kibana index pattern will not be deleted.`,
})}
</p>
</EuiConfirmModal>
</EuiOverlayMask>
)}
</Fragment>
);
};

View file

@ -0,0 +1,119 @@
/*
* 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, useState } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiButtonEmpty,
EuiConfirmModal,
EuiOverlayMask,
EuiToolTip,
EUI_MODAL_CONFIRM_BUTTON,
} from '@elastic/eui';
import { startAnalytics } from '../../services/analytics_service';
import {
checkPermission,
createPermissionFailureMessage,
} from '../../../../../privilege/check_privilege';
import { DataFrameAnalyticsListRow, isCompletedBatchAnalytics } from './common';
interface StartActionProps {
item: DataFrameAnalyticsListRow;
}
export const StartAction: FC<StartActionProps> = ({ item }) => {
const canStartStopDataFrameAnalytics: boolean = checkPermission('canStartStopDataFrameAnalytics');
const [isModalVisible, setModalVisible] = useState(false);
const closeModal = () => setModalVisible(false);
const startAndCloseModal = () => {
setModalVisible(false);
startAnalytics(item);
};
const openModal = () => setModalVisible(true);
const buttonStartText = i18n.translate('xpack.ml.dataframe.analyticsList.startActionName', {
defaultMessage: 'Start',
});
// Disable start for batch analytics which have completed.
const completedBatchAnalytics = isCompletedBatchAnalytics(item);
let startButton = (
<EuiButtonEmpty
size="xs"
color="text"
disabled={!canStartStopDataFrameAnalytics || completedBatchAnalytics}
iconType="play"
onClick={openModal}
aria-label={buttonStartText}
>
{buttonStartText}
</EuiButtonEmpty>
);
if (!canStartStopDataFrameAnalytics || completedBatchAnalytics) {
startButton = (
<EuiToolTip
position="top"
content={
!canStartStopDataFrameAnalytics
? createPermissionFailureMessage('canStartStopDataFrameAnalytics')
: i18n.translate('xpack.ml.dataframe.analyticsList.completeBatchAnalyticsToolTip', {
defaultMessage:
'{analyticsId} is a completed batch analytics job and cannot be restarted.',
values: { analyticsId: item.config.id },
})
}
>
{startButton}
</EuiToolTip>
);
}
return (
<Fragment>
{startButton}
{isModalVisible && (
<EuiOverlayMask>
<EuiConfirmModal
title={i18n.translate('xpack.ml.dataframe.analyticsList.startModalTitle', {
defaultMessage: 'Start {analyticsId}',
values: { analyticsId: item.config.id },
})}
onCancel={closeModal}
onConfirm={startAndCloseModal}
cancelButtonText={i18n.translate(
'xpack.ml.dataframe.analyticsList.startModalCancelButton',
{
defaultMessage: 'Cancel',
}
)}
confirmButtonText={i18n.translate(
'xpack.ml.dataframe.analyticsList.startModalStartButton',
{
defaultMessage: 'Start',
}
)}
defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON}
buttonColor="primary"
>
<p>
{i18n.translate('xpack.ml.dataframe.analyticsList.startModalBody', {
defaultMessage:
'A data frame analytics job will increase search and indexing load in your cluster. Please stop the analytics job if excessive load is experienced. Are you sure you want to start this analytics job?',
})}
</p>
</EuiConfirmModal>
</EuiOverlayMask>
)}
</Fragment>
);
};

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 from 'react';
import { i18n } from '@kbn/i18n';
import { EuiButtonEmpty, EuiToolTip } from '@elastic/eui';
import {
checkPermission,
createPermissionFailureMessage,
} from '../../../../../privilege/check_privilege';
import { DataFrameAnalyticsListRow, DATA_FRAME_TASK_STATE } from './common';
import { stopAnalytics } from '../../services/analytics_service';
import { StartAction } from './action_start';
import { DeleteAction } from './action_delete';
export const getActions = () => {
const canStartStopDataFrameAnalytics: boolean = checkPermission('canStartStopDataFrameAnalytics');
return [
{
isPrimary: true,
render: (item: DataFrameAnalyticsListRow) => {
if (
item.stats.state !== DATA_FRAME_TASK_STATE.STARTED &&
item.stats.state !== DATA_FRAME_TASK_STATE.REINDEXING
) {
return <StartAction item={item} />;
}
const buttonStopText = i18n.translate('xpack.ml.dataframe.analyticsList.stopActionName', {
defaultMessage: 'Stop',
});
const stopButton = (
<EuiButtonEmpty
size="xs"
color="text"
disabled={!canStartStopDataFrameAnalytics}
iconType="stop"
onClick={() => stopAnalytics(item)}
aria-label={buttonStopText}
>
{buttonStopText}
</EuiButtonEmpty>
);
if (!canStartStopDataFrameAnalytics) {
return (
<EuiToolTip
position="top"
content={createPermissionFailureMessage('canStartStopDataFrameAnalytics')}
>
{stopButton}
</EuiToolTip>
);
}
return stopButton;
},
},
{
render: (item: DataFrameAnalyticsListRow) => {
return <DeleteAction item={item} />;
},
},
];
};

View file

@ -0,0 +1,326 @@
/*
* 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, useState } from 'react';
import { i18n } from '@kbn/i18n';
import {
// EuiBadge,
EuiButtonEmpty,
EuiCallOut,
EuiEmptyPrompt,
SortDirection,
} from '@elastic/eui';
import {
DataFrameAnalyticsId,
moveToAnalyticsWizard,
useRefreshAnalyticsList,
} from '../../../../common';
import { checkPermission } from '../../../../../privilege/check_privilege';
import { getTaskStateBadge } from './columns';
import {
DataFrameAnalyticsListColumn,
DataFrameAnalyticsListRow,
ItemIdToExpandedRowMap,
DATA_FRAME_TASK_STATE,
// DATA_FRAME_MODE,
Query,
Clause,
} from './common';
import { getAnalyticsFactory } from '../../services/analytics_service';
import { getColumns } from './columns';
import { ExpandedRow } from './expanded_row';
import { ProgressBar, AnalyticsTable } from './analytics_table';
import { useRefreshInterval } from './use_refresh_interval';
function getItemIdToExpandedRowMap(
itemIds: DataFrameAnalyticsId[],
dataFrameAnalytics: DataFrameAnalyticsListRow[]
): ItemIdToExpandedRowMap {
return itemIds.reduce(
(m: ItemIdToExpandedRowMap, analyticsId: DataFrameAnalyticsId) => {
const item = dataFrameAnalytics.find(analytics => analytics.config.id === analyticsId);
if (item !== undefined) {
m[analyticsId] = <ExpandedRow item={item} />;
}
return m;
},
{} as ItemIdToExpandedRowMap
);
}
function stringMatch(str: string | undefined, substr: string) {
return (
typeof str === 'string' &&
typeof substr === 'string' &&
(str.toLowerCase().match(substr.toLowerCase()) === null) === false
);
}
export const DataFrameAnalyticsList: FC = () => {
const [isInitialized, setIsInitialized] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [blockRefresh, setBlockRefresh] = useState(false);
const [filterActive, setFilterActive] = useState(false);
const [analytics, setAnalytics] = useState<DataFrameAnalyticsListRow[]>([]);
const [filteredAnalytics, setFilteredAnalytics] = useState<DataFrameAnalyticsListRow[]>([]);
const [expandedRowItemIds, setExpandedRowItemIds] = useState<DataFrameAnalyticsId[]>([]);
const [errorMessage, setErrorMessage] = useState<any>(undefined);
const [searchError, setSearchError] = useState<any>(undefined);
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const [sortField, setSortField] = useState<string>(DataFrameAnalyticsListColumn.id);
const [sortDirection, setSortDirection] = useState<string>(SortDirection.ASC);
const disabled =
!checkPermission('canCreateDataFrameAnalytics') ||
!checkPermission('canStartStopDataFrameAnalytics');
const getAnalytics = getAnalyticsFactory(
setAnalytics,
setErrorMessage,
setIsInitialized,
blockRefresh
);
// Subscribe to the refresh observable to trigger reloading the analytics list.
useRefreshAnalyticsList({
isLoading: setIsLoading,
onRefresh: () => getAnalytics(true),
});
// Call useRefreshInterval() after the subscription above is set up.
useRefreshInterval(setBlockRefresh);
const onQueryChange = ({ query, error }: { query: Query; error: any }) => {
if (error) {
setSearchError(error.message);
} else {
let clauses: Clause[] = [];
if (query && query.ast !== undefined && query.ast.clauses !== undefined) {
clauses = query.ast.clauses;
}
if (clauses.length > 0) {
setFilterActive(true);
filterAnalytics(clauses);
} else {
setFilterActive(false);
}
setSearchError(undefined);
}
};
const filterAnalytics = (clauses: Clause[]) => {
setIsLoading(true);
// keep count of the number of matches we make as we're looping over the clauses
// we only want to return analytics which match all clauses, i.e. each search term is ANDed
// { analytics-one: { analytics: { id: analytics-one, config: {}, state: {}, ... }, count: 0 }, analytics-two: {...} }
const matches: Record<string, any> = analytics.reduce((p: Record<string, any>, c) => {
p[c.id] = {
analytics: c,
count: 0,
};
return p;
}, {});
clauses.forEach(c => {
// the search term could be negated with a minus, e.g. -bananas
const bool = c.match === 'must';
let ts = [];
if (c.type === 'term') {
// filter term based clauses, e.g. bananas
// match on id and description
// if the term has been negated, AND the matches
if (bool === true) {
ts = analytics.filter(
d => stringMatch(d.id, c.value) === bool // ||
// stringMatch(d.config.description, c.value) === bool
);
} else {
ts = analytics.filter(
d => stringMatch(d.id, c.value) === bool // &&
// stringMatch(d.config.description, c.value) === bool
);
}
} else {
// filter other clauses, i.e. the mode and status filters
if (Array.isArray(c.value)) {
// the status value is an array of string(s) e.g. ['failed', 'stopped']
ts = analytics.filter(d => c.value.includes(d.stats.state));
} else {
ts = analytics.filter(d => d.mode === c.value);
}
}
ts.forEach(t => matches[t.id].count++);
});
// loop through the matches and return only analytics which have match all the clauses
const filtered = Object.values(matches)
.filter(m => (m && m.count) >= clauses.length)
.map(m => m.analytics);
setFilteredAnalytics(filtered);
setIsLoading(false);
};
// Before the analytics have been loaded for the first time, display the loading indicator only.
// Otherwise a user would see 'No data frame analytics found' during the initial loading.
if (!isInitialized) {
return <ProgressBar isLoading={isLoading} />;
}
if (typeof errorMessage !== 'undefined') {
return (
<Fragment>
<ProgressBar isLoading={isLoading} />
<EuiCallOut
title={i18n.translate('xpack.ml.dataFrame.analyticsList.errorPromptTitle', {
defaultMessage: 'An error occurred getting the data frame analytics list.',
})}
color="danger"
iconType="alert"
>
<pre>{JSON.stringify(errorMessage)}</pre>
</EuiCallOut>
</Fragment>
);
}
if (analytics.length === 0) {
return (
<Fragment>
<ProgressBar isLoading={isLoading} />
<EuiEmptyPrompt
title={
<h2>
{i18n.translate('xpack.ml.dataFrame.analyticsList.emptyPromptTitle', {
defaultMessage: 'No data frame analytics found',
})}
</h2>
}
actions={[
<EuiButtonEmpty
onClick={moveToAnalyticsWizard}
isDisabled={disabled}
style={{ display: 'none' }}
>
{i18n.translate('xpack.ml.dataFrame.analyticsList.emptyPromptButtonText', {
defaultMessage: 'Create your first data frame analytics',
})}
</EuiButtonEmpty>,
]}
data-test-subj="mlNoDataFrameAnalyticsFound"
/>
</Fragment>
);
}
const columns = getColumns(expandedRowItemIds, setExpandedRowItemIds);
const sorting = {
sort: {
field: sortField,
direction: sortDirection,
},
};
const itemIdToExpandedRowMap = getItemIdToExpandedRowMap(expandedRowItemIds, analytics);
const pagination = {
initialPageIndex: pageIndex,
initialPageSize: pageSize,
totalItemCount: analytics.length,
pageSizeOptions: [10, 20, 50],
hidePerPageOptions: false,
};
const search = {
onChange: onQueryChange,
box: {
incremental: true,
},
filters: [
{
type: 'field_value_selection',
field: 'state.state',
name: i18n.translate('xpack.ml.dataframe.analyticsList.statusFilter', {
defaultMessage: 'Status',
}),
multiSelect: 'or',
options: Object.values(DATA_FRAME_TASK_STATE).map(val => ({
value: val,
name: val,
view: getTaskStateBadge(val),
})),
},
// For now analytics jobs are batch only
/*
{
type: 'field_value_selection',
field: 'mode',
name: i18n.translate('xpack.ml.dataframe.analyticsList.modeFilter', {
defaultMessage: 'Mode',
}),
multiSelect: false,
options: Object.values(DATA_FRAME_MODE).map(val => ({
value: val,
name: val,
view: (
<EuiBadge className="mlTaskModeBadge" color="hollow">
{val}
</EuiBadge>
),
})),
},
*/
],
};
const onTableChange = ({
page = { index: 0, size: 10 },
sort = { field: DataFrameAnalyticsListColumn.id, direction: SortDirection.ASC },
}: {
page: { index: number; size: number };
sort: { field: string; direction: string };
}) => {
const { index, size } = page;
setPageIndex(index);
setPageSize(size);
const { field, direction } = sort;
setSortField(field);
setSortDirection(direction);
};
return (
<Fragment>
<ProgressBar isLoading={isLoading} />
<AnalyticsTable
className="mlAnalyticsTable"
columns={columns}
error={searchError}
hasActions={false}
isExpandable={true}
isSelectable={false}
items={filterActive ? filteredAnalytics : analytics}
itemId={DataFrameAnalyticsListColumn.id}
itemIdToExpandedRowMap={itemIdToExpandedRowMap}
onChange={onTableChange}
pagination={pagination}
sorting={sorting}
search={search}
data-test-subj="mlDataFramesTableAnalytics"
/>
</Fragment>
);
};

View file

@ -0,0 +1,118 @@
/*
* 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.
*/
// This component extends EuiInMemoryTable with some
// fixes and TS specs until the changes become available upstream.
import React, { Component, Fragment } from 'react';
import { EuiInMemoryTable, EuiInMemoryTableProps, EuiProgress } from '@elastic/eui';
import { ItemIdToExpandedRowMap } from './common';
// 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
// our own progress bar on top of the table. `EuiProgress` after `isLoading` displays
// the loading indicator. The variation after `!isLoading` displays an empty progress
// bar fixed to 0%. Without it, the display would vertically jump when showing/hiding
// the progress bar.
export const ProgressBar = ({ isLoading = false }) => {
return (
<Fragment>
{isLoading && <EuiProgress className="mlAnalyticsProgressBar" size="xs" color="primary" />}
{!isLoading && (
<EuiProgress className="mlAnalyticsProgressBar" value={0} max={100} size="xs" />
)}
</Fragment>
);
};
// copied from EUI to be available to the extended getDerivedStateFromProps()
function findColumnByProp(columns: any, prop: any, value: any) {
for (let i = 0; i < columns.length; i++) {
const column = columns[i];
if (column[prop] === value) {
return column;
}
}
}
// copied from EUI to be available to the extended getDerivedStateFromProps()
const getInitialSorting = (columns: any, sorting: any) => {
if (!sorting || !sorting.sort) {
return {
sortName: undefined,
sortDirection: undefined,
};
}
const { field: sortable, direction: sortDirection } = sorting.sort;
// sortable could be a column's `field` or its `name`
// for backwards compatibility `field` must be checked first
let sortColumn = findColumnByProp(columns, 'field', sortable);
if (sortColumn == null) {
sortColumn = findColumnByProp(columns, 'name', sortable);
}
if (sortColumn == null) {
return {
sortName: undefined,
sortDirection: undefined,
};
}
const sortName = sortColumn.name;
return {
sortName,
sortDirection,
};
};
// TODO EUI's types for EuiInMemoryTable is missing these props
interface ExpandableTableProps extends EuiInMemoryTableProps {
itemIdToExpandedRowMap?: ItemIdToExpandedRowMap;
isExpandable?: boolean;
onChange({ page }: { page?: {} | undefined }): void;
loading?: boolean;
compressed?: boolean;
error?: string;
}
interface ComponentWithConstructor<T> extends Component {
new (): Component<T>;
}
const ExpandableTable = (EuiInMemoryTable as any) as ComponentWithConstructor<ExpandableTableProps>;
export class AnalyticsTable extends ExpandableTable {
static getDerivedStateFromProps(nextProps: any, prevState: any) {
const derivedState = {
...prevState.prevProps,
pageIndex: nextProps.pagination.initialPageIndex,
pageSize: nextProps.pagination.initialPageSize,
};
if (nextProps.items !== prevState.prevProps.items) {
Object.assign(derivedState, {
prevProps: {
items: nextProps.items,
},
});
}
const { sortName, sortDirection } = getInitialSorting(nextProps.columns, nextProps.sorting);
if (
sortName !== prevState.prevProps.sortName ||
sortDirection !== prevState.prevProps.sortDirection
) {
Object.assign(derivedState, {
sortName,
sortDirection,
});
}
return derivedState;
}
}

View file

@ -0,0 +1,216 @@
/*
* 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 } from 'react';
import { idx } from '@kbn/elastic-idx';
import { i18n } from '@kbn/i18n';
import {
EuiBadge,
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiProgress,
EuiText,
EuiToolTip,
RIGHT_ALIGNMENT,
} from '@elastic/eui';
import { DataFrameAnalyticsId } from '../../../../common';
import {
DATA_FRAME_TASK_STATE,
DataFrameAnalyticsListColumn,
DataFrameAnalyticsListRow,
DataFrameAnalyticsStats,
} from './common';
import { getActions } from './actions';
enum TASK_STATE_COLOR {
failed = 'danger',
reindexing = 'primary',
started = 'primary',
stopped = 'hollow',
}
export const getTaskStateBadge = (
state: DataFrameAnalyticsStats['state'],
reason?: DataFrameAnalyticsStats['reason']
) => {
const color = TASK_STATE_COLOR[state];
if (state === DATA_FRAME_TASK_STATE.FAILED && reason !== undefined) {
return (
<EuiToolTip content={reason}>
<EuiBadge className="mlTaskStateBadge" color={color}>
{state}
</EuiBadge>
</EuiToolTip>
);
}
return (
<EuiBadge className="mlTaskStateBadge" color={color}>
{state}
</EuiBadge>
);
};
export const getColumns = (
expandedRowItemIds: DataFrameAnalyticsId[],
setExpandedRowItemIds: React.Dispatch<React.SetStateAction<DataFrameAnalyticsId[]>>
) => {
const actions = getActions();
function toggleDetails(item: DataFrameAnalyticsListRow) {
const index = expandedRowItemIds.indexOf(item.config.id);
if (index !== -1) {
expandedRowItemIds.splice(index, 1);
setExpandedRowItemIds([...expandedRowItemIds]);
} else {
expandedRowItemIds.push(item.config.id);
}
// spread to a new array otherwise the component wouldn't re-render
setExpandedRowItemIds([...expandedRowItemIds]);
}
return [
{
align: RIGHT_ALIGNMENT,
width: '40px',
isExpander: true,
render: (item: DataFrameAnalyticsListRow) => (
<EuiButtonIcon
onClick={() => toggleDetails(item)}
aria-label={
expandedRowItemIds.includes(item.config.id)
? i18n.translate('xpack.ml.dataframe.analyticsList.rowCollapse', {
defaultMessage: 'Hide details for {analyticsId}',
values: { analyticsId: item.config.id },
})
: i18n.translate('xpack.ml.dataframe.analyticsList.rowExpand', {
defaultMessage: 'Show details for {analyticsId}',
values: { analyticsId: item.config.id },
})
}
iconType={expandedRowItemIds.includes(item.config.id) ? 'arrowUp' : 'arrowDown'}
/>
),
},
{
field: DataFrameAnalyticsListColumn.id,
name: 'ID',
sortable: true,
truncateText: true,
},
// Description is not supported yet by API
/*
{
field: DataFrameAnalyticsListColumn.description,
name: i18n.translate('xpack.ml.dataframe.analyticsList.description', {
defaultMessage: 'Description',
}),
sortable: true,
truncateText: true,
},
*/
{
field: DataFrameAnalyticsListColumn.configSourceIndex,
name: i18n.translate('xpack.ml.dataframe.analyticsList.sourceIndex', {
defaultMessage: 'Source index',
}),
sortable: true,
truncateText: true,
},
{
field: DataFrameAnalyticsListColumn.configDestIndex,
name: i18n.translate('xpack.ml.dataframe.analyticsList.destinationIndex', {
defaultMessage: 'Destination index',
}),
sortable: true,
truncateText: true,
},
{
name: i18n.translate('xpack.ml.dataframe.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',
},
// For now there is batch mode only so we hide this column for now.
/*
{
name: i18n.translate('xpack.ml.dataframe.analyticsList.mode', { defaultMessage: 'Mode' }),
sortable: (item: DataFrameAnalyticsListRow) => item.mode,
truncateText: true,
render(item: DataFrameAnalyticsListRow) {
const mode = item.mode;
const color = 'hollow';
return <EuiBadge color={color}>{mode}</EuiBadge>;
},
width: '100px',
},
*/
{
name: i18n.translate('xpack.ml.dataframe.analyticsList.progress', {
defaultMessage: 'Progress',
}),
sortable: (item: DataFrameAnalyticsListRow) => idx(item, _ => _.stats.progress_percent) || 0,
truncateText: true,
render(item: DataFrameAnalyticsListRow) {
if (item.stats.progress_percent === undefined) {
return null;
}
const progress = Math.round(item.stats.progress_percent);
// 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',
},
{
name: i18n.translate('xpack.ml.dataframe.analyticsList.tableActionLabel', {
defaultMessage: 'Actions',
}),
actions,
width: '200px',
},
];
};

View file

@ -0,0 +1,81 @@
/*
* 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 { DataFrameAnalyticsId, DataFrameAnalyticsOutlierConfig } from '../../../../common';
export enum DATA_FRAME_TASK_STATE {
FAILED = 'failed',
REINDEXING = 'reindexing',
STARTED = 'started',
STOPPED = 'stopped',
}
export enum DATA_FRAME_MODE {
BATCH = 'batch',
CONTINUOUS = 'continuous',
}
export interface Clause {
type: string;
value: string;
match: string;
}
export interface Query {
ast: {
clauses: Clause[];
};
text: string;
syntax: any;
}
export interface DataFrameAnalyticsStats {
assignment_explanation?: string;
id: DataFrameAnalyticsId;
node?: {
attributes: Record<string, any>;
ephemeral_id: string;
id: string;
name: string;
transport_address: string;
};
progress_percent?: number;
reason?: string;
state: DATA_FRAME_TASK_STATE;
}
export function isDataFrameAnalyticsStats(arg: any): arg is DataFrameAnalyticsStats {
return (
typeof arg === 'object' &&
arg !== null &&
{}.hasOwnProperty.call(arg, 'state') &&
Object.values(DATA_FRAME_TASK_STATE).includes(arg.state)
);
}
export interface DataFrameAnalyticsListRow {
id: DataFrameAnalyticsId;
checkpointing: object;
config: DataFrameAnalyticsOutlierConfig;
mode: string;
stats: DataFrameAnalyticsStats;
}
// Used to pass on attribute names to table columns
export enum DataFrameAnalyticsListColumn {
configDestIndex = 'config.dest.index',
configSourceIndex = 'config.source.index',
// Description attribute is not supported yet by API
// description = 'config.description',
id = 'id',
}
export type ItemIdToExpandedRowMap = Record<string, JSX.Element>;
export function isCompletedBatchAnalytics(item: DataFrameAnalyticsListRow) {
// For now all analytics jobs are batch jobs.
return false;
}

View file

@ -0,0 +1,106 @@
/*
* 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 moment from 'moment-timezone';
import { EuiTabbedContent } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { formatHumanReadableDateTimeSeconds } from '../../../../../util/date_utils';
import { DataFrameAnalyticsListRow } from './common';
import { ExpandedRowDetailsPane, SectionConfig } from './expanded_row_details_pane';
import { ExpandedRowJsonPane } from './expanded_row_json_pane';
// import { ExpandedRowMessagesPane } from './expanded_row_messages_pane';
function getItemDescription(value: any) {
if (typeof value === 'object') {
return JSON.stringify(value);
}
return value.toString();
}
interface Props {
item: DataFrameAnalyticsListRow;
}
export const ExpandedRow: FC<Props> = ({ item }) => {
const stateValues = { ...item.stats };
const state: SectionConfig = {
title: 'State',
items: Object.entries(stateValues).map(s => {
return { title: s[0].toString(), description: getItemDescription(s[1]) };
}),
position: 'left',
};
const checkpointing: SectionConfig = {
title: 'Checkpointing',
items: Object.entries(item.checkpointing).map(s => {
return { title: s[0].toString(), description: getItemDescription(s[1]) };
}),
position: 'left',
};
const stats: SectionConfig = {
title: 'Stats',
items: [
{
title: 'create_time',
description: formatHumanReadableDateTimeSeconds(
moment(item.config.create_time).unix() * 1000
),
},
{ title: 'model_memory_limit', description: item.config.model_memory_limit },
{ title: 'version', description: item.config.version },
],
position: 'right',
};
const tabs = [
{
id: 'analytics-details',
name: i18n.translate(
'xpack.ml.dataframe.analyticsList.analyticsDetails.tabs.analyticsSettingsLabel',
{
defaultMessage: 'Analytics details',
}
),
content: <ExpandedRowDetailsPane sections={[state, checkpointing, stats]} />,
},
{
id: 'analytics-json',
name: 'JSON',
content: <ExpandedRowJsonPane json={item.config} />,
},
// Audit messages are not yet supported by the analytics API.
/*
{
id: 'analytics-messages',
name: i18n.translate(
'xpack.ml.dataframe.analyticsList.analyticsDetails.tabs.analyticsMessagesLabel',
{
defaultMessage: 'Messages',
}
),
content: <ExpandedRowMessagesPane analyticsId={item.id} />,
},
*/
];
return (
<EuiTabbedContent
size="s"
tabs={tabs}
initialSelectedTab={tabs[0]}
onTabClick={() => {}}
expand={false}
/>
);
};

View file

@ -0,0 +1,76 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment, FC } from 'react';
import {
EuiDescriptionList,
EuiFlexGroup,
EuiFlexItem,
EuiPanel,
EuiTitle,
EuiSpacer,
} from '@elastic/eui';
export interface SectionItem {
title: string;
description: string;
}
export interface SectionConfig {
title: string;
position: 'left' | 'right';
items: SectionItem[];
}
interface SectionProps {
section: SectionConfig;
}
export const Section: FC<SectionProps> = ({ section }) => {
if (section.items.length === 0) {
return null;
}
return (
<EuiPanel>
<EuiTitle size="xs">
<span>{section.title}</span>
</EuiTitle>
<EuiDescriptionList compressed type="column" listItems={section.items} />
</EuiPanel>
);
};
interface ExpandedRowDetailsPaneProps {
sections: SectionConfig[];
}
export const ExpandedRowDetailsPane: FC<ExpandedRowDetailsPaneProps> = ({ sections }) => {
return (
<EuiFlexGroup>
<EuiFlexItem style={{ width: '50%' }}>
{sections
.filter(s => s.position === 'left')
.map(s => (
<Fragment key={s.title}>
<EuiSpacer size="s" />
<Section section={s} />
</Fragment>
))}
</EuiFlexItem>
<EuiFlexItem style={{ width: '50%' }}>
{sections
.filter(s => s.position === 'right')
.map(s => (
<Fragment key={s.title}>
<EuiSpacer size="s" />
<Section section={s} />
</Fragment>
))}
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,36 @@
/*
* 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 {
// @ts-ignore
EuiCodeEditor,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
} from '@elastic/eui';
interface Props {
json: object;
}
export const ExpandedRowJsonPane: FC<Props> = ({ json }) => {
return (
<EuiFlexGroup>
<EuiFlexItem>
<EuiSpacer size="s" />
<EuiCodeEditor
value={JSON.stringify(json, null, 2)}
readOnly={true}
mode="json"
style={{ width: '100%' }}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>&nbsp;</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,105 @@
/*
* 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, useState } from 'react';
import { EuiSpacer, EuiBasicTable } from '@elastic/eui';
// @ts-ignore
import { formatDate } from '@elastic/eui/lib/services/format';
import { i18n } from '@kbn/i18n';
import theme from '@elastic/eui/dist/eui_theme_light.json';
import { ml } from '../../../../../services/ml_api_service';
// @ts-ignore
import { JobIcon } from '../../../../../components/job_message_icon';
import { AnalyticsMessage } from '../../../../../../common/types/audit_message';
import { useRefreshAnalyticsList } from '../../../../common';
const TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss';
interface Props {
analyticsId: string;
}
export const ExpandedRowMessagesPane: FC<Props> = ({ analyticsId }) => {
const [messages, setMessages] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const getMessagesFactory = () => {
let concurrentLoads = 0;
return async function getMessages() {
try {
concurrentLoads++;
if (concurrentLoads > 1) {
return;
}
setIsLoading(true);
const messagesResp = await ml.dataFrameAnalytics.getAnalyticsAuditMessages(analyticsId);
setIsLoading(false);
setMessages(messagesResp);
concurrentLoads--;
if (concurrentLoads > 0) {
concurrentLoads = 0;
getMessages();
}
} catch (error) {
setIsLoading(false);
setErrorMessage(
i18n.translate('xpack.ml.dfAnalyticsList.analyticsDetails.messagesPane.errorMessage', {
defaultMessage: 'Messages could not be loaded',
})
);
}
};
};
useRefreshAnalyticsList({ onRefresh: getMessagesFactory() });
const columns = [
{
name: '',
render: (message: AnalyticsMessage) => <JobIcon message={message} />,
width: `${theme.euiSizeXL}px`,
},
{
name: i18n.translate('xpack.ml.dfAnalyticsList.analyticsDetails.messagesPane.timeLabel', {
defaultMessage: 'Time',
}),
render: (message: any) => formatDate(message.timestamp, TIME_FORMAT),
},
{
field: 'node_name',
name: i18n.translate('xpack.ml.dfAnalyticsList.analyticsDetails.messagesPane.nodeLabel', {
defaultMessage: 'Node',
}),
},
{
field: 'message',
name: i18n.translate('xpack.ml.dfAnalyticsList.analyticsDetails.messagesPane.messageLabel', {
defaultMessage: 'Message',
}),
width: '50%',
},
];
return (
<Fragment>
<EuiSpacer size="s" />
<EuiBasicTable
items={messages}
columns={columns}
compressed={true}
loading={isLoading}
error={errorMessage}
/>
</Fragment>
);
};

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 { DataFrameAnalyticsList } from './analytics_list';

View file

@ -0,0 +1,85 @@
/*
* 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, { useEffect } from 'react';
import { timefilter } from 'ui/timefilter';
import {
DEFAULT_REFRESH_INTERVAL_MS,
MINIMUM_REFRESH_INTERVAL_MS,
} from '../../../../../../common/constants/jobs_list';
import { useRefreshAnalyticsList } from '../../../../common';
export const useRefreshInterval = (
setBlockRefresh: React.Dispatch<React.SetStateAction<boolean>>
) => {
const { refresh } = useRefreshAnalyticsList();
useEffect(() => {
let analyticsRefreshInterval: null | number = null;
timefilter.disableTimeRangeSelector();
timefilter.enableAutoRefreshSelector();
initAutoRefresh();
initAutoRefreshUpdate();
function initAutoRefresh() {
const { value } = timefilter.getRefreshInterval();
if (value === 0) {
// the auto refresher starts in an off state
// so switch it on and set the interval to 30s
timefilter.setRefreshInterval({
pause: false,
value: DEFAULT_REFRESH_INTERVAL_MS,
});
}
setAutoRefresh();
}
function initAutoRefreshUpdate() {
// update the interval if it changes
timefilter.on('refreshIntervalUpdate', () => {
setAutoRefresh();
});
}
function setAutoRefresh() {
const { value, pause } = timefilter.getRefreshInterval();
if (pause) {
clearRefreshInterval();
} else {
setRefreshInterval(value);
}
refresh();
}
function setRefreshInterval(interval: number) {
clearRefreshInterval();
if (interval >= MINIMUM_REFRESH_INTERVAL_MS) {
setBlockRefresh(false);
const intervalId = window.setInterval(() => {
refresh();
}, interval);
analyticsRefreshInterval = intervalId;
}
}
function clearRefreshInterval() {
setBlockRefresh(true);
if (analyticsRefreshInterval !== null) {
window.clearInterval(analyticsRefreshInterval);
}
}
// useEffect cleanup
return () => {
clearRefreshInterval();
};
}, []); // [] as comparator makes sure this only runs once
};

View file

@ -0,0 +1,53 @@
/*
* 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 { EuiButton, EuiToolTip } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import {
checkPermission,
createPermissionFailureMessage,
} from '../../../../../privilege/check_privilege';
import { moveToAnalyticsWizard } from '../../../../common';
export const CreateAnalyticsButton: FC = () => {
const disabled =
!checkPermission('canCreateDataFrameAnalytics') ||
!checkPermission('canStartStopDataFrameAnalytics');
const button = (
<EuiButton
disabled={true}
fill
onClick={moveToAnalyticsWizard}
iconType="plusInCircle"
size="s"
data-test-subj="mlDataFrameAnalyticsButtonCreate"
>
<FormattedMessage
id="xpack.ml.dataframe.analyticsList.createDataFrameAnalyticsButton"
defaultMessage="Create data frame analytics job"
/>
</EuiButton>
);
if (disabled) {
return (
<EuiToolTip
position="top"
content={createPermissionFailureMessage('canCreateDataFrameAnalytics')}
>
{button}
</EuiToolTip>
);
}
return button;
};

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 { CreateAnalyticsButton } from './create_analytics_button';

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 { RefreshAnalyticsListButton } from './refresh_analytics_list_button';

View file

@ -0,0 +1,30 @@
/*
* 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 { EuiButtonEmpty } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
interface RefreshAnalyticsListButtonProps {
isLoading: boolean;
onClick(): void;
}
export const RefreshAnalyticsListButton: FC<RefreshAnalyticsListButtonProps> = ({
onClick,
isLoading,
}) => (
<EuiButtonEmpty
data-test-subj="mlRefreshAnalyticsListButton"
onClick={onClick}
isLoading={isLoading}
>
<FormattedMessage
id="xpack.ml.dataframe.analyticsList.refreshButtonLabel"
defaultMessage="Refresh"
/>
</EuiButtonEmpty>
);

View file

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

View file

@ -0,0 +1,90 @@
/*
* 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, useState } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import {
EuiBetaBadge,
EuiFlexGroup,
EuiFlexItem,
EuiPage,
EuiPageBody,
EuiPageContentBody,
EuiPageContentHeader,
EuiPageContentHeaderSection,
EuiPanel,
EuiSpacer,
EuiTitle,
} from '@elastic/eui';
import { NavigationMenu } from '../../../components/navigation_menu/navigation_menu';
import { useRefreshAnalyticsList } from '../../common';
import { CreateAnalyticsButton } from './components/create_analytics_button';
import { DataFrameAnalyticsList } from './components/analytics_list';
import { RefreshAnalyticsListButton } from './components/refresh_analytics_list_button';
export const Page: FC = () => {
const [isLoading, setIsLoading] = useState(false);
const { refresh } = useRefreshAnalyticsList({ isLoading: setIsLoading });
return (
<Fragment>
<NavigationMenu tabId="data_frame_analytics" />
<EuiPage data-test-subj="mlPageDataFrameAnalytics">
<EuiPageBody>
<EuiPageContentHeader>
<EuiPageContentHeaderSection>
<EuiTitle>
<h1>
<FormattedMessage
id="xpack.ml.dataframe.analyticsList.title"
defaultMessage="Analytics jobs"
/>
<span>&nbsp;</span>
<EuiBetaBadge
label={i18n.translate(
'xpack.ml.dataframe.analyticsList.experimentalBadgeLabel',
{
defaultMessage: 'Experimental',
}
)}
tooltipContent={i18n.translate(
'xpack.ml.dataframe.analyticsList.experimentalBadgeTooltipContent',
{
defaultMessage: `Data frame analytics are an experimental feature. We'd love to hear your feedback.`,
}
)}
/>
</h1>
</EuiTitle>
</EuiPageContentHeaderSection>
<EuiPageContentHeaderSection>
<EuiFlexGroup alignItems="center">
{/* grow={false} fixes IE11 issue with nested flex */}
<EuiFlexItem grow={false}>
{<RefreshAnalyticsListButton onClick={refresh} isLoading={isLoading} />}
</EuiFlexItem>
{/* grow={false} fixes IE11 issue with nested flex */}
<EuiFlexItem grow={false}>
<CreateAnalyticsButton />
</EuiFlexItem>
</EuiFlexGroup>
</EuiPageContentHeaderSection>
</EuiPageContentHeader>
<EuiPageContentBody>
<EuiSpacer size="l" />
<EuiPanel>
<DataFrameAnalyticsList />
</EuiPanel>
</EuiPageContentBody>
</EuiPageBody>
</EuiPage>
</Fragment>
);
};

View file

@ -0,0 +1,28 @@
/*
* 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
import { checkFullLicense } from '../../../license/check_license';
// @ts-ignore
import { checkGetJobsPrivilege } from '../../../privilege/check_privilege';
// @ts-ignore
import { loadIndexPatterns } from '../../../util/index_utils';
// @ts-ignore
import { getDataFrameAnalyticsBreadcrumbs } from '../../breadcrumbs';
const template = `<ml-data-frame-analytics-management />`;
uiRoutes.when('/data_frame_analytics/?', {
template,
k7Breadcrumbs: getDataFrameAnalyticsBreadcrumbs,
resolve: {
CheckLicense: checkFullLicense,
privileges: checkGetJobsPrivilege,
indexPatterns: loadIndexPatterns,
},
});

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 { i18n } from '@kbn/i18n';
import { toastNotifications } from 'ui/notify';
import { ml } from '../../../../../services/ml_api_service';
import { refreshAnalyticsList$, REFRESH_ANALYTICS_LIST_STATE } from '../../../../common';
import {
DATA_FRAME_TASK_STATE,
DataFrameAnalyticsListRow,
} from '../../components/analytics_list/common';
export const deleteAnalytics = async (d: DataFrameAnalyticsListRow) => {
try {
if (d.stats.state === DATA_FRAME_TASK_STATE.FAILED) {
await ml.dataFrameAnalytics.stopDataFrameAnalytics(
d.config.id,
d.stats.state === DATA_FRAME_TASK_STATE.FAILED,
true
);
}
await ml.dataFrameAnalytics.deleteDataFrameAnalytics(d.config.id);
toastNotifications.addSuccess(
i18n.translate('xpack.ml.dataframe.analyticsList.deleteAnalyticsSuccessMessage', {
defaultMessage: 'Data frame analytics {analyticsId} delete request acknowledged.',
values: { analyticsId: d.config.id },
})
);
} catch (e) {
toastNotifications.addDanger(
i18n.translate('xpack.ml.dataframe.analyticsList.deleteAnalyticsErrorMessage', {
defaultMessage:
'An error occurred deleting the data frame analytics {analyticsId}: {error}',
values: { analyticsId: d.config.id, error: JSON.stringify(e) },
})
);
}
refreshAnalyticsList$.next(REFRESH_ANALYTICS_LIST_STATE.REFRESH);
};

View file

@ -0,0 +1,126 @@
/*
* 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 { ml } from '../../../../../services/ml_api_service';
import {
DataFrameAnalyticsOutlierConfig,
refreshAnalyticsList$,
REFRESH_ANALYTICS_LIST_STATE,
} from '../../../../common';
import {
DataFrameAnalyticsListRow,
DataFrameAnalyticsStats,
DATA_FRAME_MODE,
isDataFrameAnalyticsStats,
} from '../../components/analytics_list/common';
interface GetDataFrameAnalyticsResponse {
count: number;
data_frame_analytics: DataFrameAnalyticsOutlierConfig[];
}
interface GetDataFrameAnalyticsStatsResponseOk {
node_failures?: object;
count: number;
data_frame_analytics: DataFrameAnalyticsStats[];
}
const isGetDataFrameAnalyticsStatsResponseOk = (
arg: any
): arg is GetDataFrameAnalyticsStatsResponseOk => {
return (
{}.hasOwnProperty.call(arg, 'count') &&
{}.hasOwnProperty.call(arg, 'data_frame_analytics') &&
Array.isArray(arg.data_frame_analytics)
);
};
interface GetDataFrameAnalyticsStatsResponseError {
statusCode: number;
error: string;
message: string;
}
type GetDataFrameAnalyticsStatsResponse =
| GetDataFrameAnalyticsStatsResponseOk
| GetDataFrameAnalyticsStatsResponseError;
export type GetAnalytics = (forceRefresh?: boolean) => void;
export const getAnalyticsFactory = (
setAnalytics: React.Dispatch<React.SetStateAction<DataFrameAnalyticsListRow[]>>,
setErrorMessage: React.Dispatch<
React.SetStateAction<GetDataFrameAnalyticsStatsResponseError | undefined>
>,
setIsInitialized: React.Dispatch<React.SetStateAction<boolean>>,
blockRefresh: boolean
): GetAnalytics => {
let concurrentLoads = 0;
const getAnalytics = async (forceRefresh = false) => {
if (forceRefresh === true || blockRefresh === false) {
refreshAnalyticsList$.next(REFRESH_ANALYTICS_LIST_STATE.LOADING);
concurrentLoads++;
if (concurrentLoads > 1) {
return;
}
try {
const analyticsConfigs: GetDataFrameAnalyticsResponse = await ml.dataFrameAnalytics.getDataFrameAnalytics();
const analyticsStats: GetDataFrameAnalyticsStatsResponse = await ml.dataFrameAnalytics.getDataFrameAnalyticsStats();
const tableRows = analyticsConfigs.data_frame_analytics.reduce(
(reducedtableRows, config) => {
const stats = isGetDataFrameAnalyticsStatsResponseOk(analyticsStats)
? analyticsStats.data_frame_analytics.find(d => config.id === d.id)
: undefined;
// A newly created analytics job might not have corresponding stats yet.
// If that's the case we just skip the job and don't add it to the analytics jobs list yet.
if (!isDataFrameAnalyticsStats(stats)) {
return reducedtableRows;
}
// Table with expandable rows requires `id` on the outer most level
reducedtableRows.push({
config,
id: config.id,
checkpointing: {},
mode: DATA_FRAME_MODE.BATCH,
stats,
});
return reducedtableRows;
},
[] as DataFrameAnalyticsListRow[]
);
setAnalytics(tableRows);
setErrorMessage(undefined);
setIsInitialized(true);
refreshAnalyticsList$.next(REFRESH_ANALYTICS_LIST_STATE.IDLE);
} catch (e) {
// An error is followed immediately by setting the state to idle.
// This way we're able to treat ERROR as a one-time-event like REFRESH.
refreshAnalyticsList$.next(REFRESH_ANALYTICS_LIST_STATE.ERROR);
refreshAnalyticsList$.next(REFRESH_ANALYTICS_LIST_STATE.IDLE);
setAnalytics([]);
setErrorMessage(e);
setIsInitialized(true);
}
concurrentLoads--;
if (concurrentLoads > 0) {
concurrentLoads = 0;
getAnalytics(true);
return;
}
}
};
return getAnalytics;
};

View file

@ -0,0 +1,10 @@
/*
* 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 { getAnalyticsFactory } from './get_analytics';
export { deleteAnalytics } from './delete_analytics';
export { startAnalytics } from './start_analytics';
export { stopAnalytics } from './stop_analytics';

View file

@ -0,0 +1,34 @@
/*
* 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 { toastNotifications } from 'ui/notify';
import { ml } from '../../../../../services/ml_api_service';
import { refreshAnalyticsList$, REFRESH_ANALYTICS_LIST_STATE } from '../../../../common';
import { DataFrameAnalyticsListRow } from '../../components/analytics_list/common';
export const startAnalytics = async (d: DataFrameAnalyticsListRow) => {
try {
await ml.dataFrameAnalytics.startDataFrameAnalytics(d.config.id);
toastNotifications.addSuccess(
i18n.translate('xpack.ml.dataframe.analyticsList.startAnalyticsSuccessMessage', {
defaultMessage: 'Data frame analytics {analyticsId} start request acknowledged.',
values: { analyticsId: d.config.id },
})
);
} catch (e) {
toastNotifications.addDanger(
i18n.translate('xpack.ml.dataframe.analyticsList.startAnalyticsErrorMessage', {
defaultMessage:
'An error occurred starting the data frame analytics {analyticsId}: {error}',
values: { analyticsId: d.config.id, error: JSON.stringify(e) },
})
);
}
refreshAnalyticsList$.next(REFRESH_ANALYTICS_LIST_STATE.REFRESH);
};

View file

@ -0,0 +1,41 @@
/*
* 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 { toastNotifications } from 'ui/notify';
import { ml } from '../../../../../services/ml_api_service';
import { refreshAnalyticsList$, REFRESH_ANALYTICS_LIST_STATE } from '../../../../common';
import {
DATA_FRAME_TASK_STATE,
DataFrameAnalyticsListRow,
} from '../../components/analytics_list/common';
export const stopAnalytics = async (d: DataFrameAnalyticsListRow) => {
try {
await ml.dataFrameAnalytics.stopDataFrameAnalytics(
d.config.id,
d.stats.state === DATA_FRAME_TASK_STATE.FAILED,
true
);
toastNotifications.addSuccess(
i18n.translate('xpack.ml.dataframe.analyticsList.stopAnalyticsSuccessMessage', {
defaultMessage: 'Data frame analytics {analyticsId} stop request acknowledged.',
values: { analyticsId: d.config.id },
})
);
} catch (e) {
toastNotifications.addDanger(
i18n.translate('xpack.ml.dataframe.analyticsList.stopAnalyticsErrorMessage', {
defaultMessage:
'An error occurred stopping the data frame analytics {analyticsId}: {error}',
values: { analyticsId: d.config.id, error: JSON.stringify(e) },
})
);
}
refreshAnalyticsList$.next(REFRESH_ANALYTICS_LIST_STATE.REFRESH);
};

View file

@ -17,6 +17,7 @@
// Sub applications
@import 'data_frame/index';
@import 'data_frame_analytics/index';
@import 'data_visualizer/index';
@import 'datavisualizer/index';
@import 'explorer/index'; // SASSTODO: This file needs to be rewritten

View file

@ -0,0 +1,67 @@
/*
* 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 chrome from 'ui/chrome';
import { http } from '../../services/http_service';
const basePath = chrome.addBasePath('/api/ml');
export const dataFrameAnalytics = {
getDataFrameAnalytics(analyticsId) {
const analyticsIdString = analyticsId !== undefined ? `/${analyticsId}` : '';
return http({
url: `${basePath}/data_frame/analytics${analyticsIdString}`,
method: 'GET'
});
},
getDataFrameAnalyticsStats(analyticsId) {
if (analyticsId !== undefined) {
return http({
url: `${basePath}/data_frame/analytics/${analyticsId}/_stats`,
method: 'GET'
});
}
return http({
url: `${basePath}/data_frame/analytics/_stats`,
method: 'GET'
});
},
createDataFrameAnalytics(analyticsId, analyticsConfig) {
return http({
url: `${basePath}/data_frame/analytics/${analyticsId}`,
method: 'PUT',
data: analyticsConfig
});
},
deleteDataFrameAnalytics(analyticsId) {
return http({
url: `${basePath}/data_frame/analytics/${analyticsId}`,
method: 'DELETE',
});
},
startDataFrameAnalytics(analyticsId) {
return http({
url: `${basePath}/data_frame/analytics/${analyticsId}/_start`,
method: 'POST',
});
},
stopDataFrameAnalytics(analyticsId, force = false) {
return http({
url: `${basePath}/data_frame/analytics/${analyticsId}/_stop?force=${force}`,
method: 'POST',
});
},
getAnalyticsAuditMessages(analyticsId) {
return http({
url: `${basePath}/data_frame/analytics/${analyticsId}/messages`,
method: 'GET',
});
},
};

View file

@ -29,6 +29,20 @@ declare interface Ml {
indexAnnotation(annotation: Annotation): Promise<object>;
};
dataFrameAnalytics: {
getDataFrameAnalytics(analyticsId?: string): Promise<any>;
getDataFrameAnalyticsStats(analyticsId?: string): Promise<any>;
createDataFrameAnalytics(analyticsId: string, analyticsConfig: any): Promise<any>;
deleteDataFrameAnalytics(analyticsId: string): Promise<any>;
startDataFrameAnalytics(analyticsId: string): Promise<any>;
stopDataFrameAnalytics(
analyticsId: string,
force?: boolean,
waitForCompletion?: boolean
): Promise<any>;
getAnalyticsAuditMessages(analyticsId: string): Promise<any>;
};
dataFrame: {
getDataFrameTransforms(jobId?: string): Promise<any>;
getDataFrameTransformsStats(jobId?: string): Promise<any>;

View file

@ -13,6 +13,7 @@ import { http } from '../../services/http_service';
import { annotations } from './annotations';
import { dataFrame } from './data_frame';
import { dataFrameAnalytics } from './data_frame_analytics';
import { filters } from './filters';
import { results } from './results';
import { jobs } from './jobs';
@ -443,6 +444,7 @@ export const ml = {
annotations,
dataFrame,
dataFrameAnalytics,
filters,
results,
jobs,

View file

@ -105,6 +105,106 @@ export const elasticsearchJsPlugin = (Client, config, components) => {
method: 'POST'
});
// Currently the endpoint uses a default size of 100 unless a size is supplied.
// So until paging is supported in the UI, explicitly supply a size of 1000
// to match the max number of docs that the endpoint can return.
ml.getDataFrameAnalytics = ca({
urls: [
{
fmt: '/_ml/data_frame/analytics/<%=analyticsId%>',
req: {
analyticsId: {
type: 'string'
}
}
},
{
fmt: '/_ml/data_frame/analytics/_all?size=1000',
}
],
method: 'GET'
});
ml.getDataFrameAnalyticsStats = ca({
urls: [
{
fmt: '/_ml/data_frame/analytics/<%=analyticsId%>/_stats',
req: {
analyticsId: {
type: 'string'
}
}
},
{
// Currently the endpoint uses a default size of 100 unless a size is supplied.
// So until paging is supported in the UI, explicitly supply a size of 1000
// to match the max number of docs that the endpoint can return.
fmt: '/_ml/data_frame/analytics/_all/_stats?size=1000',
}
],
method: 'GET'
});
ml.createDataFrameAnalytics = ca({
urls: [
{
fmt: '/_ml/data_frame/analytics/<%=analyticsId%>',
req: {
analyticsId: {
type: 'string'
}
}
}
],
needBody: true,
method: 'PUT'
});
ml.deleteDataFrameAnalytics = ca({
urls: [
{
fmt: '/_ml/data_frame/analytics/<%=analyticsId%>',
req: {
analyticsId: {
type: 'string'
}
}
}
],
method: 'DELETE'
});
ml.startDataFrameAnalytics = ca({
urls: [
{
fmt: '/_ml/data_frame/analytics/<%=analyticsId%>/_start',
req: {
analyticsId: {
type: 'string'
}
}
}
],
method: 'POST'
});
ml.stopDataFrameAnalytics = ca({
urls: [
{
fmt: '/_ml/data_frame/analytics/<%=analyticsId%>/_stop?&force=<%=force%>',
req: {
analyticsId: {
type: 'string'
},
force: {
type: 'boolean'
},
}
}
],
method: 'POST'
});
// Currently the endpoint uses a default size of 100 unless a size is supplied.
// So until paging is supported in the UI, explicitly supply a size of 1000
// to match the max number of docs that the endpoint can return.

View file

@ -104,7 +104,7 @@ describe('check_privileges', () => {
);
const { capabilities } = await getPrivileges();
const count = Object.keys(capabilities).length;
expect(count).toBe(23);
expect(count).toBe(27);
done();
});
});
@ -143,6 +143,10 @@ describe('check_privileges', () => {
expect(capabilities.canPreviewDataFrame).toBe(false);
expect(capabilities.canCreateDataFrame).toBe(false);
expect(capabilities.canStartStopDataFrame).toBe(false);
expect(capabilities.canGetDataFrameAnalytics).toBe(true);
expect(capabilities.canDeleteDataFrameAnalytics).toBe(false);
expect(capabilities.canCreateDataFrameAnalytics).toBe(false);
expect(capabilities.canStartStopDataFrameAnalytics).toBe(false);
done();
});
@ -179,6 +183,10 @@ describe('check_privileges', () => {
expect(capabilities.canPreviewDataFrame).toBe(true);
expect(capabilities.canCreateDataFrame).toBe(true);
expect(capabilities.canStartStopDataFrame).toBe(true);
expect(capabilities.canGetDataFrameAnalytics).toBe(true);
expect(capabilities.canDeleteDataFrameAnalytics).toBe(true);
expect(capabilities.canCreateDataFrameAnalytics).toBe(true);
expect(capabilities.canStartStopDataFrameAnalytics).toBe(true);
done();
});
@ -215,6 +223,10 @@ describe('check_privileges', () => {
expect(capabilities.canPreviewDataFrame).toBe(false);
expect(capabilities.canCreateDataFrame).toBe(false);
expect(capabilities.canStartStopDataFrame).toBe(false);
expect(capabilities.canGetDataFrameAnalytics).toBe(true);
expect(capabilities.canDeleteDataFrameAnalytics).toBe(false);
expect(capabilities.canCreateDataFrameAnalytics).toBe(false);
expect(capabilities.canStartStopDataFrameAnalytics).toBe(false);
done();
});
@ -251,6 +263,10 @@ describe('check_privileges', () => {
expect(capabilities.canPreviewDataFrame).toBe(false);
expect(capabilities.canCreateDataFrame).toBe(false);
expect(capabilities.canStartStopDataFrame).toBe(false);
expect(capabilities.canGetDataFrameAnalytics).toBe(true);
expect(capabilities.canDeleteDataFrameAnalytics).toBe(false);
expect(capabilities.canCreateDataFrameAnalytics).toBe(false);
expect(capabilities.canStartStopDataFrameAnalytics).toBe(false);
done();
});
@ -287,6 +303,10 @@ describe('check_privileges', () => {
expect(capabilities.canPreviewDataFrame).toBe(false);
expect(capabilities.canCreateDataFrame).toBe(false);
expect(capabilities.canStartStopDataFrame).toBe(false);
expect(capabilities.canGetDataFrameAnalytics).toBe(false);
expect(capabilities.canDeleteDataFrameAnalytics).toBe(false);
expect(capabilities.canCreateDataFrameAnalytics).toBe(false);
expect(capabilities.canStartStopDataFrameAnalytics).toBe(false);
done();
});
@ -323,6 +343,10 @@ describe('check_privileges', () => {
expect(capabilities.canPreviewDataFrame).toBe(true);
expect(capabilities.canCreateDataFrame).toBe(true);
expect(capabilities.canStartStopDataFrame).toBe(true);
expect(capabilities.canGetDataFrameAnalytics).toBe(false);
expect(capabilities.canDeleteDataFrameAnalytics).toBe(false);
expect(capabilities.canCreateDataFrameAnalytics).toBe(false);
expect(capabilities.canStartStopDataFrameAnalytics).toBe(false);
done();
});
@ -359,6 +383,10 @@ describe('check_privileges', () => {
expect(capabilities.canPreviewDataFrame).toBe(false);
expect(capabilities.canCreateDataFrame).toBe(false);
expect(capabilities.canStartStopDataFrame).toBe(false);
expect(capabilities.canGetDataFrameAnalytics).toBe(false);
expect(capabilities.canDeleteDataFrameAnalytics).toBe(false);
expect(capabilities.canCreateDataFrameAnalytics).toBe(false);
expect(capabilities.canStartStopDataFrameAnalytics).toBe(false);
done();
});
});
@ -397,6 +425,10 @@ describe('check_privileges', () => {
expect(capabilities.canPreviewDataFrame).toBe(true);
expect(capabilities.canCreateDataFrame).toBe(true);
expect(capabilities.canStartStopDataFrame).toBe(true);
expect(capabilities.canGetDataFrameAnalytics).toBe(true);
expect(capabilities.canDeleteDataFrameAnalytics).toBe(true);
expect(capabilities.canCreateDataFrameAnalytics).toBe(true);
expect(capabilities.canStartStopDataFrameAnalytics).toBe(true);
done();
});
@ -433,6 +465,10 @@ describe('check_privileges', () => {
expect(capabilities.canPreviewDataFrame).toBe(false);
expect(capabilities.canCreateDataFrame).toBe(false);
expect(capabilities.canStartStopDataFrame).toBe(false);
expect(capabilities.canGetDataFrameAnalytics).toBe(true);
expect(capabilities.canDeleteDataFrameAnalytics).toBe(false);
expect(capabilities.canCreateDataFrameAnalytics).toBe(false);
expect(capabilities.canStartStopDataFrameAnalytics).toBe(false);
done();
});
@ -469,6 +505,10 @@ describe('check_privileges', () => {
expect(capabilities.canPreviewDataFrame).toBe(false);
expect(capabilities.canCreateDataFrame).toBe(false);
expect(capabilities.canStartStopDataFrame).toBe(false);
expect(capabilities.canGetDataFrameAnalytics).toBe(true);
expect(capabilities.canDeleteDataFrameAnalytics).toBe(false);
expect(capabilities.canCreateDataFrameAnalytics).toBe(false);
expect(capabilities.canStartStopDataFrameAnalytics).toBe(false);
done();
});
@ -505,6 +545,10 @@ describe('check_privileges', () => {
expect(capabilities.canPreviewDataFrame).toBe(true);
expect(capabilities.canCreateDataFrame).toBe(true);
expect(capabilities.canStartStopDataFrame).toBe(true);
expect(capabilities.canGetDataFrameAnalytics).toBe(false);
expect(capabilities.canDeleteDataFrameAnalytics).toBe(false);
expect(capabilities.canCreateDataFrameAnalytics).toBe(false);
expect(capabilities.canStartStopDataFrameAnalytics).toBe(false);
done();
});
@ -541,6 +585,10 @@ describe('check_privileges', () => {
expect(capabilities.canPreviewDataFrame).toBe(true);
expect(capabilities.canCreateDataFrame).toBe(true);
expect(capabilities.canStartStopDataFrame).toBe(true);
expect(capabilities.canGetDataFrameAnalytics).toBe(false);
expect(capabilities.canDeleteDataFrameAnalytics).toBe(false);
expect(capabilities.canCreateDataFrameAnalytics).toBe(false);
expect(capabilities.canStartStopDataFrameAnalytics).toBe(false);
done();
});
@ -577,6 +625,10 @@ describe('check_privileges', () => {
expect(capabilities.canPreviewDataFrame).toBe(false);
expect(capabilities.canCreateDataFrame).toBe(false);
expect(capabilities.canStartStopDataFrame).toBe(false);
expect(capabilities.canGetDataFrameAnalytics).toBe(false);
expect(capabilities.canDeleteDataFrameAnalytics).toBe(false);
expect(capabilities.canCreateDataFrameAnalytics).toBe(false);
expect(capabilities.canStartStopDataFrameAnalytics).toBe(false);
done();
});
});

View file

@ -128,13 +128,22 @@ function setFullGettingPrivileges(
privileges.canFindFileStructure = true;
}
// Data Frames
// Data Frame Transforms
if (
forceTrue ||
(cluster['cluster:monitor/data_frame/get'] && cluster['cluster:monitor/data_frame/stats/get'])
) {
privileges.canGetDataFrame = true;
}
// Data Frame Analytics
if (
forceTrue ||
(cluster['cluster:monitor/xpack/ml/job/get'] &&
cluster['cluster:monitor/xpack/ml/job/stats/get'])
) {
privileges.canGetDataFrameAnalytics = true;
}
}
function setFullActionPrivileges(
@ -224,7 +233,7 @@ function setFullActionPrivileges(
privileges.canDeleteFilter = true;
}
// Data Frames
// Data Frame Transforms
if (forceTrue || cluster['cluster:admin/data_frame/put']) {
privileges.canCreateDataFrame = true;
}
@ -245,6 +254,33 @@ function setFullActionPrivileges(
) {
privileges.canStartStopDataFrame = true;
}
// Data Frame Analytics
if (
forceTrue ||
(cluster['cluster:admin/xpack/ml/job/put'] &&
cluster['cluster:admin/xpack/ml/job/open'] &&
cluster['cluster:admin/xpack/ml/datafeeds/put'])
) {
privileges.canCreateDataFrameAnalytics = true;
}
if (
forceTrue ||
(cluster['cluster:admin/xpack/ml/job/delete'] &&
cluster['cluster:admin/xpack/ml/datafeeds/delete'])
) {
privileges.canDeleteDataFrameAnalytics = true;
}
if (
forceTrue ||
(cluster['cluster:admin/xpack/ml/job/open'] &&
cluster['cluster:admin/xpack/ml/datafeeds/start'] &&
cluster['cluster:admin/xpack/ml/datafeeds/stop'])
) {
privileges.canStartStopDataFrameAnalytics = true;
}
}
function setBasicGettingPrivileges(
@ -257,7 +293,7 @@ function setBasicGettingPrivileges(
privileges.canFindFileStructure = true;
}
// Data Frames
// Data Frame Transforms
if (
forceTrue ||
(cluster['cluster:monitor/data_frame/get'] && cluster['cluster:monitor/data_frame/stats/get'])
@ -271,7 +307,7 @@ function setBasicActionPrivileges(
privileges: Privileges,
forceTrue = false
) {
// Data Frames
// Data Frame Transforms
if (forceTrue || cluster['cluster:admin/data_frame/put']) {
privileges.canCreateDataFrame = true;
}

View file

@ -0,0 +1,92 @@
/*
* 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 { ML_DF_NOTIFICATION_INDEX_PATTERN } from '../../../common/constants/index_patterns';
import { callWithRequestType } from '../../../common/types/kibana';
import { AnalyticsMessage } from '../../../common/types/audit_message';
const SIZE = 50;
interface Message {
_index: string;
_type: string;
_id: string;
_score: null | number;
_source: AnalyticsMessage;
sort?: any;
}
interface BoolQuery {
bool: { [key: string]: any };
}
export function analyticsAuditMessagesProvider(callWithRequest: callWithRequestType) {
// search for audit messages,
// analyticsId is optional. without it, all analytics will be listed.
async function getAnalyticsAuditMessages(analyticsId: string) {
const query: BoolQuery = {
bool: {
filter: [
{
bool: {
must_not: {
term: {
level: 'activity',
},
},
},
},
],
},
};
// if no analyticsId specified, load all of the messages
if (analyticsId !== undefined) {
query.bool.filter.push({
bool: {
should: [
{
term: {
analytics_id: '', // catch system messages
},
},
{
term: {
analytics_id: analyticsId, // messages for specified analyticsId
},
},
],
},
});
}
try {
const resp = await callWithRequest('search', {
index: ML_DF_NOTIFICATION_INDEX_PATTERN,
ignore_unavailable: true,
rest_total_hits_as_int: true,
size: SIZE,
body: {
sort: [{ timestamp: { order: 'desc' } }, { analytics_id: { order: 'asc' } }],
query,
},
});
let messages = [];
if (resp.hits.total !== 0) {
messages = resp.hits.hits.map((hit: Message) => hit._source);
messages.reverse();
}
return messages;
} catch (e) {
throw e;
}
}
return {
getAnalyticsAuditMessages,
};
}

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 { analyticsAuditMessagesProvider } from './analytics_audit_messages';

View file

@ -39,6 +39,8 @@ import { systemRoutes } from '../routes/system';
// @ts-ignore: could not find declaration file for module
import { dataFrameRoutes } from '../routes/data_frame';
// @ts-ignore: could not find declaration file for module
import { dataFrameAnalyticsRoutes } from '../routes/data_frame_analytics';
// @ts-ignore: could not find declaration file for module
import { dataRecognizer } from '../routes/modules';
// @ts-ignore: could not find declaration file for module
import { dataVisualizerRoutes } from '../routes/data_visualizer';
@ -220,6 +222,7 @@ export class Plugin {
jobRoutes(routeInitializationDeps);
dataFeedRoutes(routeInitializationDeps);
dataFrameRoutes(routeInitializationDeps);
dataFrameAnalyticsRoutes(routeInitializationDeps);
indicesRoutes(routeInitializationDeps);
jobValidationRoutes(extendedRouteInitializationDeps);
notificationRoutes(routeInitializationDeps);

View file

@ -0,0 +1,148 @@
/*
* 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 { callWithRequestFactory } from '../client/call_with_request_factory';
import { wrapError } from '../client/errors';
import { analyticsAuditMessagesProvider } from '../models/data_frame_analytics/analytics_audit_messages';
export function dataFrameAnalyticsRoutes({ commonRouteConfig, elasticsearchPlugin, route }) {
route({
method: 'GET',
path: '/api/ml/data_frame/analytics',
handler(request) {
const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request);
return callWithRequest('ml.getDataFrameAnalytics')
.catch(resp => wrapError(resp));
},
config: {
...commonRouteConfig
}
});
route({
method: 'GET',
path: '/api/ml/data_frame/analytics/{analyticsId}',
handler(request) {
const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request);
const { analyticsId } = request.params;
return callWithRequest('ml.getDataFrameAnalytics', { analyticsId })
.catch(resp => wrapError(resp));
},
config: {
...commonRouteConfig
}
});
route({
method: 'GET',
path: '/api/ml/data_frame/analytics/_stats',
handler(request) {
const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request);
return callWithRequest('ml.getDataFrameAnalyticsStats')
.catch(resp => wrapError(resp));
},
config: {
...commonRouteConfig
}
});
route({
method: 'GET',
path: '/api/ml/data_frame/analytics/{analyticsId}/_stats',
handler(request) {
const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request);
const { analyticsId } = request.params;
return callWithRequest('ml.getDataFrameAnalyticsStats', { analyticsId })
.catch(resp => wrapError(resp));
},
config: {
...commonRouteConfig
}
});
route({
method: 'PUT',
path: '/api/ml/data_frame/analytics/{analyticsId}',
handler(request) {
const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request);
const { analyticsId } = request.params;
return callWithRequest('ml.createDataFrameAnalytics', { body: request.payload, analyticsId })
.catch(resp => wrapError(resp));
},
config: {
...commonRouteConfig
}
});
route({
method: 'DELETE',
path: '/api/ml/data_frame/analytics/{analyticsId}',
handler(request) {
const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request);
const { analyticsId } = request.params;
return callWithRequest('ml.deleteDataFrameAnalytics', { analyticsId })
.catch(resp => wrapError(resp));
},
config: {
...commonRouteConfig
}
});
route({
method: 'POST',
path: '/api/ml/data_frame/analytics/{analyticsId}/_start',
handler(request) {
const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request);
const options = {
analyticsId: request.params.analyticsId
};
return callWithRequest('ml.startDataFrameAnalytics', options)
.catch(resp => wrapError(resp));
},
config: {
...commonRouteConfig
}
});
route({
method: 'POST',
path: '/api/ml/data_frame/analytics/{analyticsId}/_stop',
handler(request) {
const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request);
const options = {
analyticsId: request.params.analyticsId
};
if (request.query.force !== undefined) {
options.force = request.query.force;
}
return callWithRequest('ml.stopDataFrameAnalytics', options)
.catch(resp => wrapError(resp));
},
config: {
...commonRouteConfig
}
});
route({
method: 'GET',
path: '/api/ml/data_frame/analytics/{analyticsId}/messages',
handler(request) {
const callWithRequest = callWithRequestFactory(elasticsearchPlugin, request);
const { getAnalyticsAuditMessages } = analyticsAuditMessagesProvider(callWithRequest);
const { analyticsId } = request.params;
return getAnalyticsAuditMessages(analyticsId)
.catch(resp => wrapError(resp));
},
config: {
...commonRouteConfig
}
});
}

View file

@ -31,6 +31,10 @@ export const emptyMlCapabilities: MlCapabilities = {
canPreviewDataFrame: false,
canCreateDataFrame: false,
canStartStopDataFrame: false,
canGetDataFrameAnalytics: false,
canDeleteDataFrameAnalytics: false,
canCreateDataFrameAnalytics: false,
canStartStopDataFrameAnalytics: false,
},
isPlatinumOrTrialLicense: false,
mlFeatureEnabledInSpace: false,

View file

@ -125,6 +125,10 @@ export interface MlCapabilities {
canPreviewDataFrame: boolean;
canCreateDataFrame: boolean;
canStartStopDataFrame: boolean;
canGetDataFrameAnalytics: boolean;
canDeleteDataFrameAnalytics: boolean;
canCreateDataFrameAnalytics: boolean;
canStartStopDataFrameAnalytics: boolean;
};
isPlatinumOrTrialLicense: boolean;
mlFeatureEnabledInSpace: boolean;