[ML] Data Frame UI (#33427)

This commit is contained in:
Walter Rafelsberger 2019-04-23 16:46:52 +02:00 committed by GitHub
parent 8a7d570ce3
commit d316dca98a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
55 changed files with 2998 additions and 1 deletions

View file

@ -21,6 +21,7 @@ import mappings from './mappings';
import { makeMlUsageCollector } from './server/lib/ml_telemetry';
import { notificationRoutes } from './server/routes/notification_settings';
import { systemRoutes } from './server/routes/system';
import { dataFrameRoutes } from './server/routes/data_frame';
import { dataRecognizer } from './server/routes/modules';
import { dataVisualizerRoutes } from './server/routes/data_visualizer';
import { calendars } from './server/routes/calendars';
@ -128,6 +129,7 @@ export const ml = (kibana) => {
annotationRoutes(server, commonRouteConfig);
jobRoutes(server, commonRouteConfig);
dataFeedRoutes(server, commonRouteConfig);
dataFrameRoutes(server, commonRouteConfig);
indicesRoutes(server, commonRouteConfig);
jobValidationRoutes(server, commonRouteConfig);
notificationRoutes(server, commonRouteConfig);

View file

@ -20,6 +20,7 @@ import 'plugins/ml/lib/angular_bootstrap_patch';
import 'plugins/ml/jobs';
import 'plugins/ml/services/calendar_service';
import 'plugins/ml/components/messagebar';
import 'plugins/ml/data_frame';
import 'plugins/ml/datavisualizer';
import 'plugins/ml/explorer';
import 'plugins/ml/timeseriesexplorer';

View file

@ -7,4 +7,3 @@
import './form_filter_input_directive';

View file

@ -24,6 +24,13 @@
i18n-default-message="Single Metric Viewer"
></span>
</a>
<a kbn-href="#/data_frame" class="kuiLocalTab" role="tab"
ng-class="{'kuiLocalTab-isSelected': isActiveTab('data_frame')}">
<span
i18n-id="xpack.ml.navMenu.dataFrameTabLinkText"
i18n-default-message="Data Frame"
></span>
</a>
<a kbn-href="#/datavisualizer" class="kuiLocalTab" role="tab"
ng-class="{'kuiLocalTab-isSelected': isActiveTab('datavisualizer')}">
<span

View file

@ -26,6 +26,7 @@ module.directive('mlNavMenu', function () {
scope.showTabs = false;
if (scope.name === 'jobs' ||
scope.name === 'settings' ||
scope.name === 'data_frame' ||
scope.name === 'datavisualizer' ||
scope.name === 'filedatavisualizer' ||
scope.name === 'timeseriesexplorer' ||

View file

@ -0,0 +1,61 @@
/*
* 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 getJobManagementBreadcrumbs() {
// Whilst top level nav menu with tabs remains,
// use root ML breadcrumb.
return [ML_BREADCRUMB];
}
export function getDataFrameBreadcrumbs() {
return [
ML_BREADCRUMB,
{
text: i18n.translate('xpack.ml.dataFrameBreadcrumbs.dataFrameLabel', {
defaultMessage: 'Data Frame',
}),
href: '',
},
];
}
const DATA_FRAME_HOME = {
text: i18n.translate('xpack.ml.dataFrameBreadcrumbs.dataFrameLabel', {
defaultMessage: 'Data Frame',
}),
href: '#/data_frame',
};
export function getDataFrameCreateBreadcrumbs() {
return [
ML_BREADCRUMB,
DATA_FRAME_HOME,
{
text: i18n.translate('xpack.ml.dataFrameBreadcrumbs.dataFrameCreateLabel', {
defaultMessage: 'Create data frame',
}),
href: '',
},
];
}
export function getDataFrameIndexOrSearchBreadcrumbs() {
return [
ML_BREADCRUMB,
DATA_FRAME_HOME,
{
text: i18n.translate('xpack.ml.dataFrameBreadcrumbs.selectIndexOrSearchLabel', {
defaultMessage: 'Select index or search',
}),
href: '',
},
];
}

View file

@ -0,0 +1,171 @@
/*
* 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 { DefaultOperator } from 'elasticsearch';
import { StaticIndexPattern } from 'ui/index_patterns';
import { Dictionary } from '../../../common/types/common';
import { DefinePivotExposedState } from '../components/define_pivot/define_pivot_form';
// The display label used for an aggregation e.g. sum(bytes).
export type Label = string;
// Label object structured for EUI's ComboBox.
export interface DropDownLabel {
label: Label;
}
// Label object structure for EUI's ComboBox with support for nesting.
export interface DropDownOption {
label: Label;
options: DropDownLabel[];
}
// The internal representation of an aggregation definition.
type AggName = string;
type FieldName = string;
export interface OptionsDataElement {
agg: PivotAggSupportedAggs;
field: FieldName;
formRowLabel: AggName;
}
export type OptionsDataElementDict = Dictionary<OptionsDataElement>;
export interface SimpleQuery {
query_string: {
query: string;
default_operator: DefaultOperator;
};
}
// DataFramePreviewRequest
type PivotGroupBySupportedAggs = 'terms';
type PivotGroupBy = {
[key in PivotGroupBySupportedAggs]: {
field: string;
}
};
type PivotGroupByDict = Dictionary<PivotGroupBy>;
export enum PIVOT_SUPPORTED_AGGS {
AVG = 'avg',
CARDINALITY = 'cardinality',
MAX = 'max',
MIN = 'min',
SUM = 'sum',
VALUE_COUNT = 'value_count',
}
type PivotAggSupportedAggs =
| PIVOT_SUPPORTED_AGGS.AVG
| PIVOT_SUPPORTED_AGGS.CARDINALITY
| PIVOT_SUPPORTED_AGGS.MAX
| PIVOT_SUPPORTED_AGGS.MIN
| PIVOT_SUPPORTED_AGGS.SUM
| PIVOT_SUPPORTED_AGGS.VALUE_COUNT;
type PivotAgg = {
[key in PivotAggSupportedAggs]?: {
field: FieldName;
}
};
type PivotAggDict = { [key in AggName]: PivotAgg };
export interface DataFramePreviewRequest {
pivot: {
group_by: PivotGroupByDict;
aggregations: PivotAggDict;
};
source: {
index: string;
query?: any;
};
}
export interface DataFrameRequest extends DataFramePreviewRequest {
dest: {
index: string;
};
}
export const pivotSupportedAggs = [
PIVOT_SUPPORTED_AGGS.AVG,
PIVOT_SUPPORTED_AGGS.CARDINALITY,
PIVOT_SUPPORTED_AGGS.MAX,
PIVOT_SUPPORTED_AGGS.MIN,
PIVOT_SUPPORTED_AGGS.SUM,
PIVOT_SUPPORTED_AGGS.VALUE_COUNT,
] as PivotAggSupportedAggs[];
export function getPivotQuery(search: string): SimpleQuery {
return {
query_string: {
query: search,
default_operator: 'AND',
},
};
}
export function getDataFramePreviewRequest(
indexPatternTitle: StaticIndexPattern['title'],
query: SimpleQuery,
groupBy: string[],
aggs: OptionsDataElement[]
): DataFramePreviewRequest {
const request: DataFramePreviewRequest = {
source: {
index: indexPatternTitle,
query,
},
pivot: {
group_by: {},
aggregations: {},
},
};
groupBy.forEach(g => {
request.pivot.group_by[g] = {
terms: {
field: g,
},
};
});
aggs.forEach(agg => {
request.pivot.aggregations[agg.formRowLabel] = {
[agg.agg]: {
field: agg.field,
},
};
});
return request;
}
export function getDataFrameRequest(
indexPatternTitle: StaticIndexPattern['title'],
pivotState: DefinePivotExposedState,
jobDetailsState: any
): DataFrameRequest {
const request: DataFrameRequest = {
...getDataFramePreviewRequest(
indexPatternTitle,
getPivotQuery(pivotState.search),
pivotState.groupBy,
pivotState.aggs
),
dest: {
index: jobDetailsState.targetIndex,
},
};
return request;
}
export * from './index_pattern_context';

View file

@ -0,0 +1,16 @@
/*
* 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 { StaticIndexPattern } from 'ui/index_patterns';
// Because we're only getting the actual contextvalue within a wrapping angular component,
// we need to initialize here with `null` because TypeScript doesn't allow createContext()
// without a default value. The union type `IndexPatternContextValue` takes care of allowing
// the actual required type and `null`.
export type IndexPatternContextValue = StaticIndexPattern | null;
export const IndexPatternContext = React.createContext<IndexPatternContextValue>(null);

View file

@ -0,0 +1,32 @@
/*
* 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 { EuiComboBox, EuiComboBoxOptionProps } from '@elastic/eui';
interface Props {
options: EuiComboBoxOptionProps[];
placeholder?: string;
changeHandler(d: EuiComboBoxOptionProps[]): void;
}
export const DropDown: React.SFC<Props> = ({
changeHandler,
options,
placeholder = 'Search ...',
}) => {
return (
<EuiComboBox
placeholder={placeholder}
singleSelection={{ asPlainText: true }}
options={options}
selectedOptions={[]}
onChange={changeHandler}
isClearable={false}
/>
);
};

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 * from './list_form';
export * from './list_summary';

View file

@ -0,0 +1,83 @@
/*
* 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 {
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiListGroup,
EuiListGroupItem,
} from '@elastic/eui';
import { Dictionary } from '../../../../common/types/common';
interface OptionsDataElement {
agg: string;
field: string;
formRowLabel: string;
}
interface ListProps {
list: string[];
optionsData: Dictionary<OptionsDataElement>;
deleteHandler?(l: string): void;
}
export const AggListForm: React.SFC<ListProps> = ({ deleteHandler, list, optionsData }) => (
<EuiListGroup flush={true}>
{list.map((optionsDataId: string) => (
<EuiListGroupItem
key={optionsDataId}
label={
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
label={i18n.translate('xpack.ml.dataframe.aggregationListForm.customNameLabel', {
defaultMessage: 'Custom name',
})}
>
<EuiFieldText defaultValue={optionsData[optionsDataId].formRowLabel} />
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
label={i18n.translate('xpack.ml.dataframe.aggregationListForm.aggregationLabel', {
defaultMessage: 'Aggregation',
})}
>
<EuiFieldText defaultValue={optionsData[optionsDataId].agg} />
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
label={i18n.translate('xpack.ml.dataframe.aggregationListForm.fieldLabel', {
defaultMessage: 'Field',
})}
>
<EuiFieldText defaultValue={optionsData[optionsDataId].field} />
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
}
extraAction={
(deleteHandler && {
onClick: () => deleteHandler(optionsDataId),
iconType: 'cross',
iconSize: 's',
'aria-label': optionsDataId,
alwaysShow: false,
}) ||
undefined
}
/>
))}
</EuiListGroup>
);

View file

@ -0,0 +1,33 @@
/*
* 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 { EuiForm, EuiFormRow } from '@elastic/eui';
import { Dictionary } from '../../../../common/types/common';
interface OptionsDataElement {
agg: string;
field: string;
formRowLabel: string;
}
interface ListProps {
list: string[];
optionsData: Dictionary<OptionsDataElement>;
deleteHandler?(l: string): void;
}
export const AggListSummary: React.SFC<ListProps> = ({ list, optionsData }) => (
<EuiForm>
{list.map((l: string) => (
<EuiFormRow key={l} label={optionsData[l].formRowLabel}>
<span>{l}</span>
</EuiFormRow>
))}
</EuiForm>
);

View file

@ -0,0 +1,11 @@
/*
* 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 enum FIELD_TYPE {
IP = 'ip',
NUMBER = 'number',
STRING = 'string',
}

View file

@ -0,0 +1,252 @@
/*
* 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 { uniq } from 'lodash';
import { i18n } from '@kbn/i18n';
import React, { ChangeEvent, Fragment, SFC, useContext, useEffect, useState } from 'react';
import {
EuiComboBoxOptionProps,
EuiFieldSearch,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiFormHelpText,
EuiFormRow,
EuiSpacer,
} from '@elastic/eui';
import { DropDown } from '../../components/aggregation_dropdown/dropdown';
import { AggListForm } from '../../components/aggregation_list';
import { GroupByList } from '../../components/group_by_list/list';
import { SourceIndexPreview } from '../../components/source_index_preview';
import { PivotPreview } from './pivot_preview';
import { Dictionary } from '../../../../common/types/common';
import {
DropDownLabel,
DropDownOption,
getPivotQuery,
Label,
OptionsDataElement,
pivotSupportedAggs,
PIVOT_SUPPORTED_AGGS,
} from '../../common';
import { IndexPatternContext } from '../../common';
import { FIELD_TYPE } from './common';
export interface DefinePivotExposedState {
aggList: Label[];
aggs: OptionsDataElement[];
groupBy: Label[];
search: string;
valid: boolean;
}
const defaultSearch = '*';
const emptySearch = '';
export function getDefaultPivotState(): DefinePivotExposedState {
return {
aggList: [] as Label[],
aggs: [] as OptionsDataElement[],
groupBy: [] as Label[],
search: defaultSearch,
valid: false,
};
}
interface Props {
overrides?: DefinePivotExposedState;
onChange(s: DefinePivotExposedState): void;
}
export const DefinePivotForm: SFC<Props> = React.memo(({ overrides = {}, onChange }) => {
const defaults = { ...getDefaultPivotState(), ...overrides };
const indexPattern = useContext(IndexPatternContext);
if (indexPattern === null) {
return null;
}
const fields = indexPattern.fields
.filter(field => field.aggregatable === true)
.map(field => ({ name: field.name, type: field.type }));
// The search filter
const [search, setSearch] = useState(defaults.search);
const addToSearch = (newSearch: string) => {
const currentDisplaySearch = search === defaultSearch ? emptySearch : search;
setSearch(`${currentDisplaySearch} ${newSearch}`.trim());
};
const searchHandler = (d: ChangeEvent<HTMLInputElement>) => {
const newSearch = d.currentTarget.value === emptySearch ? defaultSearch : d.currentTarget.value;
setSearch(newSearch);
};
// The list of selected group by fields
const [groupBy, setGroupBy] = useState(defaults.groupBy as Label[]);
const addGroupBy = (d: DropDownLabel[]) => {
const label: Label = d[0].label;
const newList = uniq([...groupBy, label]);
setGroupBy(newList);
};
const deleteGroupBy = (label: Label) => {
const newList = groupBy.filter(l => l !== label);
setGroupBy(newList);
};
// The list of selected aggregations
const [aggList, setAggList] = useState(defaults.aggList as Label[]);
const addAggregation = (d: DropDownLabel[]) => {
const label: Label = d[0].label;
const newList = uniq([...aggList, label]);
setAggList(newList);
};
const deleteAggregation = (label: Label) => {
const newList = aggList.filter(l => l !== label);
setAggList(newList);
};
// The available fields for group by
const groupByOptions: EuiComboBoxOptionProps[] = [];
fields.forEach(field => {
const o: DropDownLabel = { label: field.name };
groupByOptions.push(o);
});
// The available aggregations
const aggOptions: EuiComboBoxOptionProps[] = [];
const aggOptionsData: Dictionary<OptionsDataElement> = {};
fields.forEach(field => {
const o: DropDownOption = { label: field.name, options: [] };
pivotSupportedAggs.forEach(agg => {
if (
(agg === PIVOT_SUPPORTED_AGGS.CARDINALITY &&
(field.type === FIELD_TYPE.STRING || field.type === FIELD_TYPE.IP)) ||
(agg !== PIVOT_SUPPORTED_AGGS.CARDINALITY && field.type === FIELD_TYPE.NUMBER)
) {
const label = `${agg}(${field.name})`;
o.options.push({ label });
const formRowLabel = `${agg}_${field.name}`;
aggOptionsData[label] = { agg, field: field.name, formRowLabel };
}
});
aggOptions.push(o);
});
const pivotAggs = aggList.map(l => aggOptionsData[l]);
const pivotGroupBy = groupBy;
const pivotQuery = getPivotQuery(search);
const valid = pivotGroupBy.length > 0 && aggList.length > 0;
useEffect(
() => {
onChange({ aggList, aggs: pivotAggs, groupBy: pivotGroupBy, search, valid });
},
[
aggList,
pivotAggs.map(d => `${d.agg} ${d.field} ${d.formRowLabel}`).join(' '),
pivotGroupBy,
search,
valid,
]
);
const displaySearch = search === defaultSearch ? emptySearch : search;
return (
<EuiFlexGroup>
<EuiFlexItem grow={false} style={{ minWidth: '420px' }}>
<EuiForm>
<EuiFormRow
label={i18n.translate('xpack.ml.dataframe.definePivotForm.queryLabel', {
defaultMessage: 'Query',
})}
>
<EuiFieldSearch
placeholder={i18n.translate('xpack.ml.dataframe.definePivotForm.queryPlaceholder', {
defaultMessage: 'Search...',
})}
onChange={searchHandler}
value={displaySearch}
/>
</EuiFormRow>
<EuiFormRow
label={i18n.translate('xpack.ml.dataframe.definePivotForm.groupByLabel', {
defaultMessage: 'Group by',
})}
>
<Fragment>
<GroupByList list={pivotGroupBy} deleteHandler={deleteGroupBy} />
<DropDown
changeHandler={addGroupBy}
options={groupByOptions}
placeholder={i18n.translate(
'xpack.ml.dataframe.definePivotForm.groupByPlaceholder',
{
defaultMessage: 'Add a group by field ...',
}
)}
/>
</Fragment>
</EuiFormRow>
<EuiFormRow
label={i18n.translate('xpack.ml.dataframe.definePivotForm.aggregationsLabel', {
defaultMessage: 'Aggregations',
})}
>
<Fragment>
<AggListForm
list={aggList}
optionsData={aggOptionsData}
deleteHandler={deleteAggregation}
/>
<DropDown
changeHandler={addAggregation}
options={aggOptions}
placeholder={i18n.translate(
'xpack.ml.dataframe.definePivotForm.aggregationsPlaceholder',
{
defaultMessage: 'Add an aggregation ...',
}
)}
/>
</Fragment>
</EuiFormRow>
{!valid && (
<EuiFormHelpText style={{ maxWidth: '320px' }}>
{i18n.translate('xpack.ml.dataframe.definePivotForm.formHelp', {
defaultMessage:
'Data frame transforms are scalable and automated processes for pivoting. Choose at least one group-by and aggregation to get started.',
})}
</EuiFormHelpText>
)}
</EuiForm>
</EuiFlexItem>
<EuiFlexItem>
<SourceIndexPreview cellClick={addToSearch} query={pivotQuery} />
<EuiSpacer size="l" />
<PivotPreview aggs={pivotAggs} groupBy={pivotGroupBy} query={pivotQuery} />
</EuiFlexItem>
</EuiFlexGroup>
);
});

View file

@ -0,0 +1,134 @@
/*
* 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, { SFC, useContext } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiComboBoxOptionProps,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiFormRow,
EuiText,
} from '@elastic/eui';
import { AggListSummary } from '../../components/aggregation_list';
import { GroupByList } from '../../components/group_by_list/list';
import { PivotPreview } from './pivot_preview';
import { Dictionary } from '../../../../common/types/common';
import {
DropDownLabel,
DropDownOption,
IndexPatternContext,
Label,
OptionsDataElement,
PIVOT_SUPPORTED_AGGS,
pivotSupportedAggs,
SimpleQuery,
} from '../../common';
import { FIELD_TYPE } from './common';
const defaultSearch = '*';
const emptySearch = '';
interface Props {
search: string;
groupBy: Label[];
aggList: Label[];
}
export const DefinePivotSummary: SFC<Props> = ({ search, groupBy, aggList }) => {
const indexPattern = useContext(IndexPatternContext);
if (indexPattern === null) {
return null;
}
const fields = indexPattern.fields
.filter(field => field.aggregatable === true)
.map(field => ({ name: field.name, type: field.type }));
// The available fields for group by
const groupByOptions: EuiComboBoxOptionProps[] = [];
fields.forEach(field => {
const o: DropDownLabel = { label: field.name };
groupByOptions.push(o);
});
// The available aggregations
const aggOptions: EuiComboBoxOptionProps[] = [];
const aggOptionsData: Dictionary<OptionsDataElement> = {};
fields.forEach(field => {
const o: DropDownOption = { label: field.name, options: [] };
pivotSupportedAggs.forEach(agg => {
if (
(agg === PIVOT_SUPPORTED_AGGS.CARDINALITY &&
(field.type === FIELD_TYPE.STRING || field.type === FIELD_TYPE.IP)) ||
(agg !== PIVOT_SUPPORTED_AGGS.CARDINALITY && field.type === FIELD_TYPE.NUMBER)
) {
const label = `${agg}(${field.name})`;
o.options.push({ label });
const formRowLabel = `${agg}_${field.name}`;
aggOptionsData[label] = { agg, field: field.name, formRowLabel };
}
});
aggOptions.push(o);
});
const pivotAggs = aggList.map(l => aggOptionsData[l]);
const pivotGroupBy = groupBy;
const pivotQuery: SimpleQuery = {
query_string: {
query: search,
default_operator: 'AND',
},
};
const displaySearch = search === defaultSearch ? emptySearch : search;
return (
<EuiFlexGroup>
<EuiFlexItem grow={false} style={{ minWidth: '420px' }}>
<EuiForm>
<EuiFormRow
label={i18n.translate('xpack.ml.dataframe.definePivotSummary.queryLabel', {
defaultMessage: 'Query',
})}
>
<span>{displaySearch}</span>
</EuiFormRow>
<EuiFormRow
label={i18n.translate('xpack.ml.dataframe.definePivotSummary.groupByLabel', {
defaultMessage: 'Group by',
})}
>
<GroupByList list={pivotGroupBy} />
</EuiFormRow>
<EuiFormRow
label={i18n.translate('xpack.ml.dataframe.definePivotSummary.aggregationsLabel', {
defaultMessage: 'Aggregations',
})}
>
<AggListSummary list={aggList} optionsData={aggOptionsData} />
</EuiFormRow>
</EuiForm>
</EuiFlexItem>
<EuiFlexItem>
<EuiText>
<PivotPreview aggs={pivotAggs} groupBy={pivotGroupBy} query={pivotQuery} />
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export {
DefinePivotExposedState,
DefinePivotForm,
getDefaultPivotState,
} from './define_pivot_form';
export { DefinePivotSummary } from './define_pivot_summary';

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, { useContext, useEffect, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiInMemoryTable, EuiPanel, EuiProgress, EuiTitle, SortDirection } from '@elastic/eui';
import { ml } from '../../../services/ml_api_service';
import {
getDataFramePreviewRequest,
IndexPatternContext,
OptionsDataElement,
SimpleQuery,
} from '../../common';
interface Props {
aggs: OptionsDataElement[];
groupBy: string[];
query: SimpleQuery;
}
export const PivotPreview: React.SFC<Props> = React.memo(({ aggs, groupBy, query }) => {
const indexPattern = useContext(IndexPatternContext);
if (indexPattern === null) {
return null;
}
const [loading, setLoading] = useState(false);
const [dataFramePreviewData, setDataFramePreviewData] = useState([]);
useEffect(
() => {
if (aggs.length === 0) {
setDataFramePreviewData([]);
return;
}
setLoading(true);
const request = getDataFramePreviewRequest(indexPattern.title, query, groupBy, aggs);
ml.dataFrame
.getDataFrameTransformsPreview(request)
.then((resp: any) => {
setDataFramePreviewData(resp.preview);
setLoading(false);
})
.catch((resp: any) => {
setDataFramePreviewData([]);
setLoading(false);
});
},
[indexPattern.title, aggs, groupBy, query]
);
if (dataFramePreviewData.length === 0) {
return null;
}
const columnKeys = Object.keys(dataFramePreviewData[0]);
columnKeys.sort((a, b) => {
// make sure groupBy fields are always most left columns
if (groupBy.some(d => d === a) && groupBy.some(d => d === b)) {
return a.localeCompare(b);
}
if (groupBy.some(d => d === a)) {
return -1;
}
if (groupBy.some(d => d === b)) {
return 1;
}
return a.localeCompare(b);
});
const columns = columnKeys.map(k => {
return {
field: k,
name: k,
sortable: true,
truncateText: true,
};
});
const sorting = {
sort: {
field: columns[0].field,
direction: SortDirection.ASC,
},
};
return (
<EuiPanel>
<EuiTitle size="xs">
<span>
{i18n.translate('xpack.ml.dataframe.pivotPreview.dataFramePivotPreviewTitle', {
defaultMessage: 'Data Frame Pivot Preview',
})}
</span>
</EuiTitle>
{loading && <EuiProgress size="xs" color="accent" />}
{!loading && <EuiProgress size="xs" color="accent" max={1} value={0} />}
{dataFramePreviewData.length > 0 && (
<EuiInMemoryTable
items={dataFramePreviewData}
columns={columns}
pagination={true}
sorting={sorting}
/>
)}
</EuiPanel>
);
});

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 React, { Fragment } from 'react';
import { EuiListGroup, EuiListGroupItem, EuiPanel, EuiSpacer } from '@elastic/eui';
interface ListProps {
list: string[];
deleteHandler?(l: string): void;
}
export const GroupByList: React.SFC<ListProps> = ({ deleteHandler, list }) => (
<EuiListGroup flush={true}>
{list.map((fieldName: string) => (
<Fragment key={fieldName}>
<EuiPanel paddingSize="s">
<EuiListGroupItem
iconType="string"
label={fieldName}
extraAction={
(deleteHandler && {
onClick: () => deleteHandler(fieldName),
iconType: 'cross',
iconSize: 's',
'aria-label': fieldName,
alwaysShow: false,
}) ||
undefined
}
style={{ padding: 0 }}
/>
</EuiPanel>
{list.length > 0 && <EuiSpacer size="s" />}
</Fragment>
))}
</EuiListGroup>
);

View file

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

View file

@ -0,0 +1,159 @@
/*
* 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, SFC, useEffect, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { toastNotifications } from 'ui/notify';
import {
EuiButton,
// Module '"@elastic/eui"' has no exported member 'EuiCard'.
// @ts-ignore
EuiCard,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiSpacer,
} from '@elastic/eui';
import { ml } from '../../../services/ml_api_service';
export interface JobDetailsExposedState {
created: boolean;
started: boolean;
}
export function getDefaultJobCreateState(): JobDetailsExposedState {
return {
created: false,
started: false,
};
}
function gotToDataFrameJobManagement() {
window.location.href = '#/data_frame';
}
interface Props {
jobId: string;
jobConfig: any;
overrides: JobDetailsExposedState;
onChange(s: JobDetailsExposedState): void;
}
export const JobCreateForm: SFC<Props> = React.memo(({ jobConfig, jobId, onChange, overrides }) => {
const defaults = { ...getDefaultJobCreateState(), ...overrides };
const [created, setCreated] = useState(defaults.created);
const [started, setStarted] = useState(defaults.started);
useEffect(
() => {
onChange({ created, started });
},
[created, started]
);
async function createDataFrame() {
setCreated(true);
try {
await ml.dataFrame.createDataFrameTransformsJob(jobId, jobConfig);
toastNotifications.addSuccess(
i18n.translate('xpack.ml.dataframe.jobCreateForm.createJobSuccessMessage', {
defaultMessage: 'Data frame job {jobId} created successfully.',
values: { jobId },
})
);
return true;
} catch (e) {
setCreated(false);
toastNotifications.addDanger(
i18n.translate('xpack.ml.dataframe.jobCreateForm.createJobErrorMessage', {
defaultMessage: 'An error occurred creating the data frame job {jobId}: {error}',
values: { jobId, error: JSON.stringify(e) },
})
);
return false;
}
}
async function startDataFrame() {
setStarted(true);
try {
await ml.dataFrame.startDataFrameTransformsJob(jobId);
toastNotifications.addSuccess(
i18n.translate('xpack.ml.dataframe.jobCreateForm.startJobSuccessMessage', {
defaultMessage: 'Data frame job {jobId} started successfully.',
values: { jobId },
})
);
} catch (e) {
setStarted(false);
toastNotifications.addDanger(
i18n.translate('xpack.ml.dataframe.jobCreateForm.startJobErrorMessage', {
defaultMessage: 'An error occurred starting the data frame job {jobId}: {error}',
values: { jobId, error: JSON.stringify(e) },
})
);
}
}
async function createAndStartDataFrame() {
const success = await createDataFrame();
if (success) {
await startDataFrame();
}
}
return (
<Fragment>
<EuiButton isDisabled={created} onClick={createDataFrame}>
{i18n.translate('xpack.ml.dataframe.jobCreateForm.createDataFrameButton', {
defaultMessage: 'Create data frame',
})}
</EuiButton>
&nbsp;
{!created && (
<EuiButton fill isDisabled={created && started} onClick={createAndStartDataFrame}>
{i18n.translate('xpack.ml.dataframe.jobCreateForm.createAndStartDataFrameButton', {
defaultMessage: 'Create and start data frame',
})}
</EuiButton>
)}
{created && (
<EuiButton isDisabled={created && started} onClick={startDataFrame}>
{i18n.translate('xpack.ml.dataframe.jobCreateForm.startDataFrameButton', {
defaultMessage: 'Start data frame',
})}
</EuiButton>
)}
{created && started && (
<Fragment>
<EuiSpacer size="m" />
<EuiFlexGroup gutterSize="l">
<EuiFlexItem>
<EuiCard
icon={<EuiIcon size="xxl" type="list" />}
title={i18n.translate('xpack.ml.dataframe.jobCreateForm.jobManagementCardTitle', {
defaultMessage: 'Job management',
})}
description={i18n.translate(
'xpack.ml.dataframe.jobCreateForm.jobManagementCardDescription',
{
defaultMessage: 'Return to the data frame job management page.',
}
)}
onClick={gotToDataFrameJobManagement}
/>
</EuiFlexItem>
</EuiFlexGroup>
</Fragment>
)}
</Fragment>
);
});

View file

@ -0,0 +1,11 @@
/*
* 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, { SFC } from 'react';
export const JobCreateSummary: SFC = React.memo(() => {
return null;
});

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 type JobId = string;
export type TargetIndex = string;

View file

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

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, { Fragment, SFC, useEffect, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiFieldText, EuiFormRow } from '@elastic/eui';
import { JobId, TargetIndex } from './common';
export interface JobDetailsExposedState {
jobId: JobId;
targetIndex: TargetIndex;
touched: boolean;
valid: boolean;
}
export function getDefaultJobDetailsState(): JobDetailsExposedState {
return {
jobId: '',
targetIndex: '',
touched: false,
valid: false,
};
}
interface Props {
overrides?: JobDetailsExposedState;
onChange(s: JobDetailsExposedState): void;
}
export const JobDetailsForm: SFC<Props> = React.memo(({ overrides = {}, onChange }) => {
const defaults = { ...getDefaultJobDetailsState(), ...overrides };
const [jobId, setJobId] = useState(defaults.jobId);
const [targetIndex, setTargetIndex] = useState(defaults.targetIndex);
useEffect(
() => {
const valid = jobId !== '' && targetIndex !== '';
onChange({ jobId, targetIndex, touched: true, valid });
},
[jobId, targetIndex]
);
return (
<Fragment>
<EuiFormRow
label={i18n.translate('xpack.ml.dataframe.jobDetailsForm.jobIdLabel', {
defaultMessage: 'Job id',
})}
>
<EuiFieldText
placeholder="job id"
value={jobId}
onChange={e => setJobId(e.target.value)}
aria-label={i18n.translate('xpack.ml.dataframe.jobDetailsForm.jobIdInputAriaLabel', {
defaultMessage: 'Choose a unique job id.',
})}
/>
</EuiFormRow>
<EuiFormRow
label={i18n.translate('xpack.ml.dataframe.jobDetailsForm.targetIndexLabel', {
defaultMessage: 'Target index',
})}
>
<EuiFieldText
placeholder="target index"
value={targetIndex}
onChange={e => setTargetIndex(e.target.value)}
aria-label={i18n.translate(
'xpack.ml.dataframe.jobDetailsForm.targetIndexInputAriaLabel',
{
defaultMessage: 'Choose a unique target index name.',
}
)}
/>
</EuiFormRow>
</Fragment>
);
});

View file

@ -0,0 +1,44 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment, SFC } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiFormRow } from '@elastic/eui';
import { JobId, TargetIndex } from './common';
interface Props {
jobId: JobId;
targetIndex: TargetIndex;
touched: boolean;
}
export const JobDetailsSummary: SFC<Props> = React.memo(({ jobId, targetIndex, touched }) => {
if (touched === false) {
return null;
}
return (
<Fragment>
<EuiFormRow
label={i18n.translate('xpack.ml.dataframe.jobDetailsSummary.jobIdLabel', {
defaultMessage: 'Job id',
})}
>
<span>{jobId}</span>
</EuiFormRow>
<EuiFormRow
label={i18n.translate('xpack.ml.dataframe.jobDetailsSummary.targetIndexLabel', {
defaultMessage: 'Target index',
})}
>
<span>{targetIndex}</span>
</EuiFormRow>
</Fragment>
);
});

View file

@ -0,0 +1,80 @@
/*
* 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 { Dictionary } from '../../../../common/types/common';
export type EsFieldName = string;
type EsId = string;
type EsDocSource = Dictionary<any>;
export interface EsDoc extends Dictionary<any> {
_id: EsId;
_source: EsDocSource;
}
export const MAX_COLUMNS = 5;
function getFlattenedFields(obj: EsDocSource): EsFieldName[] {
const flatDocFields: EsFieldName[] = [];
const newDocFields = Object.keys(obj);
newDocFields.forEach(f => {
const fieldValue = obj[f];
if (typeof fieldValue !== 'object' || fieldValue === null || Array.isArray(fieldValue)) {
flatDocFields.push(f);
} else {
const innerFields = getFlattenedFields(fieldValue);
const flattenedFields = innerFields.map(d => `${f}.${d}`);
flatDocFields.push(...flattenedFields);
}
});
return flatDocFields;
}
export const getSelectableFields = (docs: EsDoc[]): EsFieldName[] => {
if (docs.length === 0) {
return [];
}
const newDocFields = getFlattenedFields(docs[0]._source);
newDocFields.sort();
return newDocFields;
};
export const getDefaultSelectableFields = (docs: EsDoc[]): EsFieldName[] => {
if (docs.length === 0) {
return [];
}
const newDocFields = getFlattenedFields(docs[0]._source);
newDocFields.sort();
return newDocFields
.filter(k => {
let value = false;
docs.forEach(row => {
const source = row._source;
if (source[k] !== null) {
value = true;
}
});
return value;
})
.slice(0, MAX_COLUMNS);
};
export const toggleSelectedField = (
selectedFields: EsFieldName[],
column: EsFieldName
): EsFieldName[] => {
const index = selectedFields.indexOf(column);
if (index === -1) {
selectedFields.push(column);
} else {
selectedFields.splice(index, 1);
}
selectedFields.sort();
return selectedFields;
};

View file

@ -0,0 +1,33 @@
/*
* 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 { get } from 'lodash';
import React from 'react';
import { EuiBadge, EuiText } from '@elastic/eui';
import { EsDoc } from './common';
import { getSelectableFields } from './common';
interface ExpandedRowProps {
item: EsDoc;
}
export const ExpandedRow: React.SFC<ExpandedRowProps> = ({ item }) => {
const keys = getSelectableFields([item]);
const list = keys.map(k => {
const value = get(item._source, k, '');
return (
<span key={k}>
<EuiBadge>{k}:</EuiBadge>
<small> {value}&nbsp;&nbsp;</small>
</span>
);
});
return <EuiText>{list}</EuiText>;
};

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 { SourceIndexPreview } from './source_index_preview';

View file

@ -0,0 +1,305 @@
/*
* 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, { FunctionComponent, useContext, useEffect, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { SearchResponse } from 'elasticsearch';
import {
EuiButtonEmpty,
EuiButtonIcon,
EuiCheckbox,
EuiEmptyPrompt,
EuiFlexGroup,
EuiFlexItem,
EuiInMemoryTable,
EuiInMemoryTableProps,
EuiPanel,
EuiPopover,
EuiPopoverTitle,
EuiProgress,
EuiText,
EuiTitle,
RIGHT_ALIGNMENT,
} from '@elastic/eui';
// TODO EUI's types for EuiInMemoryTable is missing these props
interface ExpandableTableProps extends EuiInMemoryTableProps {
itemIdToExpandedRowMap: ItemIdToExpandedRowMap;
isExpandable: boolean;
}
const ExpandableTable = (EuiInMemoryTable as any) as FunctionComponent<ExpandableTableProps>;
import { ml } from '../../../services/ml_api_service';
import { Dictionary } from '../../../../common/types/common';
import { IndexPatternContext, SimpleQuery } from '../../common';
import {
EsDoc,
EsFieldName,
getDefaultSelectableFields,
getSelectableFields,
MAX_COLUMNS,
toggleSelectedField,
} from './common';
import { ExpandedRow } from './expanded_row';
type ItemIdToExpandedRowMap = Dictionary<JSX.Element>;
// Defining our own ENUM here.
// EUI's SortDirection wasn't usable as a union type
// required for the Sorting interface.
enum SORT_DIRECTON {
ASC = 'asc',
DESC = 'desc',
}
interface Sorting {
sort: {
field: string;
direction: SORT_DIRECTON.ASC | SORT_DIRECTON.DESC;
};
}
type TableSorting = Sorting | boolean;
interface Props {
query: SimpleQuery;
cellClick?(search: string): void;
}
const SEARCH_SIZE = 1000;
export const SourceIndexPreview: React.SFC<Props> = React.memo(({ cellClick, query }) => {
const indexPattern = useContext(IndexPatternContext);
if (indexPattern === null) {
return null;
}
const [loading, setLoading] = useState(false);
const [tableItems, setTableItems] = useState([] as EsDoc[]);
const [selectedFields, setSelectedFields] = useState([] as EsFieldName[]);
const [isColumnsPopoverVisible, setColumnsPopoverVisible] = useState(false);
function toggleColumnsPopover() {
setColumnsPopoverVisible(!isColumnsPopoverVisible);
}
function closeColumnsPopover() {
setColumnsPopoverVisible(false);
}
function toggleColumn(column: EsFieldName) {
// spread to a new array otherwise the component wouldn't re-render
setSelectedFields([...toggleSelectedField(selectedFields, column)]);
}
let docFields: EsFieldName[] = [];
let docFieldsCount = 0;
if (tableItems.length > 0) {
docFields = getSelectableFields(tableItems);
docFields.sort();
docFieldsCount = docFields.length;
}
const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState(
{} as ItemIdToExpandedRowMap
);
function toggleDetails(item: EsDoc) {
if (itemIdToExpandedRowMap[item._id]) {
delete itemIdToExpandedRowMap[item._id];
} else {
itemIdToExpandedRowMap[item._id] = <ExpandedRow item={item} />;
}
// spread to a new object otherwise the component wouldn't re-render
setItemIdToExpandedRowMap({ ...itemIdToExpandedRowMap });
}
useEffect(
() => {
setLoading(true);
ml.esSearch({
index: indexPattern.title,
rest_total_hits_as_int: true,
size: SEARCH_SIZE,
body: { query },
})
.then((resp: SearchResponse<any>) => {
const docs = resp.hits.hits;
if (selectedFields.length === 0) {
const newSelectedFields = getDefaultSelectableFields(docs);
setSelectedFields(newSelectedFields);
}
setTableItems(docs as EsDoc[]);
setLoading(false);
})
.catch((resp: any) => {
setTableItems([] as EsDoc[]);
setLoading(false);
});
},
[indexPattern.title, query.query_string.query]
);
const columns = selectedFields.map(k => {
const column = {
field: `_source.${k}`,
name: k,
render: undefined,
sortable: true,
truncateText: true,
} as Dictionary<any>;
if (cellClick) {
column.render = (d: string) => (
<EuiButtonEmpty size="xs" onClick={() => cellClick(`${k}:(${d})`)}>
{d}
</EuiButtonEmpty>
);
}
return column;
});
let sorting: TableSorting = false;
if (columns.length > 0) {
sorting = {
sort: {
field: columns[0].field,
direction: SORT_DIRECTON.ASC,
},
};
}
if (docFieldsCount > MAX_COLUMNS || docFieldsCount > selectedFields.length) {
columns.unshift({
align: RIGHT_ALIGNMENT,
width: '40px',
isExpander: true,
render: (item: EsDoc) => (
<EuiButtonIcon
onClick={() => toggleDetails(item)}
aria-label={
itemIdToExpandedRowMap[item._id]
? i18n.translate('xpack.ml.dataframe.sourceIndexPreview.rowCollapse', {
defaultMessage: 'Collapse',
})
: i18n.translate('xpack.ml.dataframe.sourceIndexPreview.rowExpand', {
defaultMessage: 'Expand',
})
}
iconType={itemIdToExpandedRowMap[item._id] ? 'arrowUp' : 'arrowDown'}
/>
),
});
}
if (!loading && tableItems.length === 0) {
return (
<EuiEmptyPrompt title={<h2>No results</h2>} body={<p>Check the syntax of your query.</p>} />
);
}
return (
<EuiPanel>
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiTitle size="xs">
<span>
{i18n.translate('xpack.ml.dataframe.sourceIndexPreview.sourceIndexPatternTitle', {
defaultMessage: 'Source Index {indexPatternTitle}',
values: { indexPatternTitle: indexPattern.title },
})}
</span>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup alignItems="center">
<EuiFlexItem>
{docFieldsCount > MAX_COLUMNS && (
<EuiText size="s">
{i18n.translate('xpack.ml.dataframe.sourceIndexPreview.fieldSelection', {
defaultMessage:
'showing {selectedFieldsLength, number} of {docFieldsCount, number} {docFieldsCount, plural, one {field} other {fields}}',
values: { selectedFieldsLength: selectedFields.length, docFieldsCount },
})}
</EuiText>
)}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="s">
<EuiPopover
id="popover"
button={
<EuiButtonIcon
iconType="gear"
onClick={toggleColumnsPopover}
aria-label={i18n.translate(
'xpack.ml.dataframe.sourceIndexPreview.selectColumnsAriaLabel',
{
defaultMessage: 'Select columns',
}
)}
/>
}
isOpen={isColumnsPopoverVisible}
closePopover={closeColumnsPopover}
ownFocus
>
<EuiPopoverTitle>
{i18n.translate(
'xpack.ml.dataframe.sourceIndexPreview.selectFieldsPopoverTitle',
{
defaultMessage: 'Select fields',
}
)}
</EuiPopoverTitle>
<div style={{ maxHeight: '400px', overflowY: 'scroll' }}>
{docFields.map(d => (
<EuiCheckbox
key={d}
id={d}
label={d}
checked={selectedFields.includes(d)}
onChange={() => toggleColumn(d)}
disabled={selectedFields.includes(d) && selectedFields.length === 1}
/>
))}
</div>
</EuiPopover>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
{loading && <EuiProgress size="xs" color="accent" />}
{!loading && <EuiProgress size="xs" color="accent" max={1} value={0} />}
<ExpandableTable
items={tableItems}
columns={columns}
pagination={true}
hasActions={false}
isSelectable={false}
itemId="_id"
itemIdToExpandedRowMap={itemIdToExpandedRowMap}
isExpandable={true}
sorting={sorting}
/>
</EuiPanel>
);
});

View file

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

View file

@ -0,0 +1,47 @@
/*
* 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, { SFC } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
interface StepsNavProps {
previousActive?: boolean;
nextActive?: boolean;
previous?(): void;
next?(): void;
}
export const WizardNav: SFC<StepsNavProps> = ({
previous,
previousActive = true,
next,
nextActive = true,
}) => (
<EuiFlexGroup>
<EuiFlexItem />
{previous && (
<EuiFlexItem grow={false}>
<EuiButton disabled={!previousActive} onClick={previous} iconType="arrowLeft" size="s">
{i18n.translate('xpack.ml.dataframe.wizard.previousStepButton', {
defaultMessage: 'Previous',
})}
</EuiButton>
</EuiFlexItem>
)}
{next && (
<EuiFlexItem grow={false}>
<EuiButton disabled={!nextActive} onClick={next} iconType="arrowRight" size="s">
{i18n.translate('xpack.ml.dataframe.wizard.nextStepButton', {
defaultMessage: 'Next',
})}
</EuiButton>
</EuiFlexItem>
)}
</EuiFlexGroup>
);

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

View file

@ -0,0 +1,45 @@
/*
* 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';
// @ts-ignore
import { SearchItemsProvider } from '../../../jobs/new_job/utils/new_job_utils';
import { IndexPatternContext } from '../../common';
import { Page } from './page';
module.directive('mlNewDataFrame', ($route: any, Private: any) => {
return {
scope: {},
restrict: 'E',
link: (scope: ng.IScope, element: ng.IAugmentedJQuery) => {
const createSearchItems = Private(SearchItemsProvider);
const { indexPattern } = createSearchItems();
ReactDOM.render(
<I18nContext>
<IndexPatternContext.Provider value={indexPattern}>
{React.createElement(Page)}
</IndexPatternContext.Provider>
</I18nContext>,
element[0]
);
element.on('$destroy', () => {
ReactDOM.unmountComponentAtNode(element[0]);
scope.$destroy();
});
},
};
});

View file

@ -0,0 +1,44 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { SFC } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiPage,
EuiPageBody,
EuiPageContentBody,
EuiPageContentHeader,
EuiPageContentHeaderSection,
EuiSpacer,
EuiTitle,
} from '@elastic/eui';
import { Wizard } from './wizard';
export const Page: SFC = () => (
<EuiPage>
<EuiPageBody>
<EuiPageContentHeader>
<EuiPageContentHeaderSection>
<EuiTitle>
<h1>
<FormattedMessage
id="xpack.ml.dataframe.transformsWizard.newDataFrameTitle"
defaultMessage="New data frame"
/>
</h1>
</EuiTitle>
</EuiPageContentHeaderSection>
</EuiPageContentHeader>
<EuiPageContentBody>
<EuiSpacer size="l" />
<Wizard />
</EuiPageContentBody>
</EuiPageBody>
</EuiPage>
);

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 { loadCurrentIndexPattern } from '../../../util/index_utils';
// @ts-ignore
import { getDataFrameCreateBreadcrumbs } from '../../breadcrumbs';
const template = `<ml-nav-menu name="new_data_frame" /><ml-new-data-frame />`;
uiRoutes.when('/data_frame/new_job/step/pivot?', {
template,
k7Breadcrumbs: getDataFrameCreateBreadcrumbs,
resolve: {
CheckLicense: checkFullLicense,
privileges: checkGetJobsPrivilege,
indexPattern: loadCurrentIndexPattern,
},
});

View file

@ -0,0 +1,181 @@
/*
* 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, SFC, useContext, useRef, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiSteps, EuiStepStatus } from '@elastic/eui';
import { WizardNav } from '../../components/wizard_nav';
import {
DefinePivotExposedState,
DefinePivotForm,
DefinePivotSummary,
getDefaultPivotState,
} from '../../components/define_pivot';
import {
getDefaultJobCreateState,
JobCreateForm,
JobCreateSummary,
} from '../../components/job_create';
import { getDataFrameRequest } from '../../common';
import {
getDefaultJobDetailsState,
JobDetailsForm,
JobDetailsSummary,
} from '../../components/job_details';
import { IndexPatternContext } from '../../common';
enum WIZARD_STEPS {
DEFINE_PIVOT,
JOB_DETAILS,
JOB_CREATE,
}
interface DefinePivotStepProps {
isCurrentStep: boolean;
pivotState: DefinePivotExposedState;
setCurrentStep: React.Dispatch<React.SetStateAction<WIZARD_STEPS>>;
setPivot: React.Dispatch<React.SetStateAction<DefinePivotExposedState>>;
}
const DefinePivotStep: SFC<DefinePivotStepProps> = ({
isCurrentStep,
pivotState,
setCurrentStep,
setPivot,
}) => {
const definePivotRef = useRef(null);
return (
<Fragment>
<div ref={definePivotRef} />
{isCurrentStep && (
<Fragment>
<DefinePivotForm onChange={setPivot} overrides={pivotState} />
<WizardNav
next={() => setCurrentStep(WIZARD_STEPS.JOB_DETAILS)}
nextActive={pivotState.valid}
/>
</Fragment>
)}
{!isCurrentStep && <DefinePivotSummary {...pivotState} />}
</Fragment>
);
};
export const Wizard: SFC = React.memo(() => {
// indexPattern from context
const indexPattern = useContext(IndexPatternContext);
if (indexPattern === null) {
return null;
}
// The current WIZARD_STEP
const [currentStep, setCurrentStep] = useState(WIZARD_STEPS.DEFINE_PIVOT);
// The DEFINE_PIVOT state
const [pivotState, setPivot] = useState(getDefaultPivotState());
// The JOB_DETAILS state
const [jobDetailsState, setJobDetails] = useState(getDefaultJobDetailsState());
const jobDetails =
currentStep === WIZARD_STEPS.JOB_DETAILS ? (
<JobDetailsForm onChange={setJobDetails} overrides={jobDetailsState} />
) : (
<JobDetailsSummary {...jobDetailsState} />
);
// The JOB_CREATE state
const [jobCreateState, setJobCreate] = useState(getDefaultJobCreateState);
const jobCreate =
currentStep === WIZARD_STEPS.JOB_CREATE ? (
<JobCreateForm
jobId={jobDetailsState.jobId}
jobConfig={getDataFrameRequest(indexPattern.title, pivotState, jobDetailsState)}
onChange={setJobCreate}
overrides={jobCreateState}
/>
) : (
<JobCreateSummary />
);
// scroll to the currently selected wizard step
/*
function scrollToRef() {
if (definePivotRef !== null && definePivotRef.current !== null) {
// TODO Fix types
const dummy = definePivotRef as any;
const headerOffset = 70;
window.scrollTo(0, dummy.current.offsetTop - headerOffset);
}
}
*/
const stepsConfig = [
{
title: i18n.translate('xpack.ml.dataframe.transformsWizard.definePivotStepTitle', {
defaultMessage: 'Define pivot',
}),
children: (
<DefinePivotStep
isCurrentStep={currentStep === WIZARD_STEPS.DEFINE_PIVOT}
pivotState={pivotState}
setCurrentStep={setCurrentStep}
setPivot={setPivot}
/>
),
},
{
title: i18n.translate('xpack.ml.dataframe.transformsWizard.jobDetailsStepTitle', {
defaultMessage: 'Job details',
}),
children: (
<Fragment>
{jobDetails}
{currentStep === WIZARD_STEPS.JOB_DETAILS && (
<WizardNav
previous={() => {
setCurrentStep(WIZARD_STEPS.DEFINE_PIVOT);
// scrollToRef();
}}
next={() => setCurrentStep(WIZARD_STEPS.JOB_CREATE)}
nextActive={jobDetailsState.valid}
/>
)}
</Fragment>
),
status: currentStep >= WIZARD_STEPS.JOB_DETAILS ? undefined : ('incomplete' as EuiStepStatus),
},
{
title: i18n.translate('xpack.ml.dataframe.transformsWizard.createStepTitle', {
defaultMessage: 'Create',
}),
children: (
<Fragment>
{jobCreate}
{currentStep === WIZARD_STEPS.JOB_CREATE && (
<WizardNav
previous={() => setCurrentStep(WIZARD_STEPS.JOB_DETAILS)}
previousActive={!jobCreateState.created}
/>
)}
</Fragment>
),
status: currentStep >= WIZARD_STEPS.JOB_CREATE ? undefined : ('incomplete' as EuiStepStatus),
},
];
return <EuiSteps steps={stepsConfig} />;
});

View file

@ -0,0 +1,133 @@
/*
* 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, SFC, useState } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiButtonEmpty,
EuiConfirmModal,
EuiOverlayMask,
EUI_MODAL_CONFIRM_BUTTON,
} from '@elastic/eui';
import { DataFrameJobListRow, DATA_FRAME_RUNNING_STATE } from './common';
import { deleteJobFactory, startJobFactory, stopJobFactory } from './job_service';
interface DeleteActionProps {
disabled: boolean;
item: DataFrameJobListRow;
deleteJob(d: DataFrameJobListRow): void;
}
const DeleteAction: SFC<DeleteActionProps> = ({ deleteJob, disabled, item }) => {
const [isModalVisible, setModalVisible] = useState(false);
const closeModal = () => setModalVisible(false);
const deleteAndCloseModal = () => {
setModalVisible(false);
deleteJob(item);
};
const openModal = () => setModalVisible(true);
return (
<Fragment>
<EuiButtonEmpty
color="danger"
disabled={disabled}
iconType="trash"
onClick={openModal}
aria-label={i18n.translate('xpack.ml.dataframe.jobsList.deleteActionName', {
defaultMessage: 'Delete',
})}
/>
{isModalVisible && (
<EuiOverlayMask>
<EuiConfirmModal
title={i18n.translate('xpack.ml.dataframe.jobsList.deleteModalTitle', {
defaultMessage: 'Delete {jobId}',
values: { jobId: item.config.id },
})}
onCancel={closeModal}
onConfirm={deleteAndCloseModal}
cancelButtonText={i18n.translate(
'xpack.ml.dataframe.jobsList.deleteModalCancelButton',
{
defaultMessage: 'Cancel',
}
)}
confirmButtonText={i18n.translate(
'xpack.ml.dataframe.jobsList.deleteModalDeleteButton',
{
defaultMessage: 'Delete',
}
)}
defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON}
buttonColor="danger"
>
<p>
{i18n.translate('xpack.ml.dataframe.jobsList.deleteModalBody', {
defaultMessage: 'Are you sure you want to delete this job?',
})}
</p>
</EuiConfirmModal>
</EuiOverlayMask>
)}
</Fragment>
);
};
export const getActions = (getJobs: () => void) => {
const deleteJob = deleteJobFactory(getJobs);
const startJob = startJobFactory(getJobs);
const stopJob = stopJobFactory(getJobs);
return [
{
isPrimary: true,
render: (item: DataFrameJobListRow) => {
if (
item.state.indexer_state !== DATA_FRAME_RUNNING_STATE.STARTED &&
item.state.task_state !== DATA_FRAME_RUNNING_STATE.STARTED
) {
return (
<EuiButtonEmpty
iconType="play"
onClick={() => startJob(item)}
aria-label={i18n.translate('xpack.ml.dataframe.jobsList.startActionName', {
defaultMessage: 'Start',
})}
/>
);
}
return (
<EuiButtonEmpty
color="danger"
iconType="stop"
onClick={() => stopJob(item)}
aria-label={i18n.translate('xpack.ml.dataframe.jobsList.stopActionName', {
defaultMessage: 'Stop',
})}
/>
);
},
},
{
render: (item: DataFrameJobListRow) => {
return (
<DeleteAction
deleteJob={deleteJob}
disabled={
item.state.indexer_state === DATA_FRAME_RUNNING_STATE.STARTED ||
item.state.task_state === DATA_FRAME_RUNNING_STATE.STARTED
}
item={item}
/>
);
},
},
];
};

View file

@ -0,0 +1,75 @@
/*
* 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 { EuiButtonIcon, RIGHT_ALIGNMENT } from '@elastic/eui';
import { DataFrameJobListColumn, DataFrameJobListRow, ItemIdToExpandedRowMap } from './common';
import { getActions } from './actions';
export const getColumns = (
getJobs: () => void,
itemIdToExpandedRowMap: ItemIdToExpandedRowMap,
setItemIdToExpandedRowMap: React.Dispatch<React.SetStateAction<ItemIdToExpandedRowMap>>
) => {
const actions = getActions(getJobs);
function toggleDetails(item: DataFrameJobListRow) {
if (itemIdToExpandedRowMap[item.config.id]) {
delete itemIdToExpandedRowMap[item.config.id];
} else {
itemIdToExpandedRowMap[item.config.id] = <div>EXPAND {item.config.id}</div>;
}
// spread to a new object otherwise the component wouldn't re-render
setItemIdToExpandedRowMap({ ...itemIdToExpandedRowMap });
}
return [
{
align: RIGHT_ALIGNMENT,
width: '40px',
isExpander: true,
render: (item: DataFrameJobListRow) => (
<EuiButtonIcon
onClick={() => toggleDetails(item)}
aria-label={
itemIdToExpandedRowMap[item.config.id]
? i18n.translate('xpack.ml.dataframe.jobsList.rowCollapse', {
defaultMessage: 'Collapse',
})
: i18n.translate('xpack.ml.dataframe.jobsList.rowExpand', {
defaultMessage: 'Expand',
})
}
iconType={itemIdToExpandedRowMap[item.config.id] ? 'arrowUp' : 'arrowDown'}
/>
),
},
{
field: DataFrameJobListColumn.id,
name: 'ID',
sortable: true,
truncateText: true,
},
{
field: DataFrameJobListColumn.configSourceIndex,
name: i18n.translate('xpack.ml.dataframe.sourceIndex', { defaultMessage: 'Source index' }),
sortable: true,
truncateText: true,
},
{
field: DataFrameJobListColumn.configDestIndex,
name: i18n.translate('xpack.ml.dataframe.targetIndex', { defaultMessage: 'Target index' }),
sortable: true,
truncateText: true,
},
{
name: i18n.translate('xpack.ml.dataframe.tableActionLabel', { defaultMessage: 'Actions' }),
actions,
},
];
};

View file

@ -0,0 +1,56 @@
/*
* 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 { Dictionary } from '../../../../../../common/types/common';
export type jobId = string;
export interface DataFrameJob {
dest: string;
id: jobId;
source: string;
}
export enum DATA_FRAME_RUNNING_STATE {
STARTED = 'started',
STOPPED = 'stopped',
}
type RunningState = DATA_FRAME_RUNNING_STATE.STARTED | DATA_FRAME_RUNNING_STATE.STOPPED;
export interface DataFrameJobState {
checkpoint: number;
current_position: Dictionary<any>;
indexer_state: RunningState;
task_state: RunningState;
}
export interface DataFrameJobStats {
documents_indexed: number;
documents_processed: number;
index_failures: number;
index_time_in_ms: number;
index_total: number;
pages_processed: number;
search_failures: number;
search_time_in_ms: number;
search_total: number;
trigger_count: number;
}
export interface DataFrameJobListRow {
state: DataFrameJobState;
stats: DataFrameJobStats;
config: DataFrameJob;
}
// Used to pass on attribute names to table columns
export enum DataFrameJobListColumn {
configDestIndex = 'config.dest.index',
configSourceIndex = 'config.source.index',
id = 'id',
}
export type ItemIdToExpandedRowMap = Dictionary<JSX.Element>;

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 { DataFrameJobList } from './job_list';

View file

@ -0,0 +1,65 @@
/*
* 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, { FunctionComponent, SFC, useEffect, useState } from 'react';
import {
EuiEmptyPrompt,
EuiInMemoryTable,
EuiInMemoryTableProps,
SortDirection,
} from '@elastic/eui';
import { DataFrameJobListColumn, DataFrameJobListRow, ItemIdToExpandedRowMap } from './common';
import { getJobsFactory } from './job_service';
import { getColumns } from './columns';
// TODO EUI's types for EuiInMemoryTable is missing these props
interface ExpandableTableProps extends EuiInMemoryTableProps {
itemIdToExpandedRowMap: ItemIdToExpandedRowMap;
isExpandable: boolean;
}
const ExpandableTable = (EuiInMemoryTable as any) as FunctionComponent<ExpandableTableProps>;
export const DataFrameJobList: SFC = () => {
const [dataFrameJobs, setDataFrameJobs] = useState<DataFrameJobListRow[]>([]);
const getJobs = getJobsFactory(setDataFrameJobs);
const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState<ItemIdToExpandedRowMap>({});
// use this pattern so we don't return a promise, useEffects doesn't like that
useEffect(() => {
getJobs();
}, []);
if (dataFrameJobs.length === 0) {
return <EuiEmptyPrompt title={<h2>Here be Data Frame dragons!</h2>} iconType="editorStrike" />;
}
const columns = getColumns(getJobs, itemIdToExpandedRowMap, setItemIdToExpandedRowMap);
const sorting = {
sort: {
field: DataFrameJobListColumn.id,
direction: SortDirection.ASC,
},
};
return (
<ExpandableTable
columns={columns}
hasActions={false}
isExpandable={true}
isSelectable={false}
items={dataFrameJobs}
itemId={DataFrameJobListColumn.id}
itemIdToExpandedRowMap={itemIdToExpandedRowMap}
pagination={true}
sorting={sorting}
/>
);
};

View file

@ -0,0 +1,31 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* 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 { DataFrameJobListRow } from '../common';
export const deleteJobFactory = (getJobs: () => void) => async (d: DataFrameJobListRow) => {
try {
await ml.dataFrame.deleteDataFrameTransformsJob(d.config.id);
getJobs();
toastNotifications.addSuccess(
i18n.translate('xpack.ml.dataframe.jobsList.deleteJobSuccessMessage', {
defaultMessage: 'Data frame job {jobId} deleted successfully.',
values: { jobId: d.config.id },
})
);
} catch (e) {
toastNotifications.addDanger(
i18n.translate('xpack.ml.dataframe.jobsList.deleteJobErrorMessage', {
defaultMessage: 'An error occurred deleting the data frame job {jobId}: {error}',
values: { jobId: d.config.id, error: JSON.stringify(e) },
})
);
}
};

View file

@ -0,0 +1,61 @@
/*
* 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 {
DataFrameJob,
DataFrameJobListRow,
DataFrameJobState,
DataFrameJobStats,
jobId,
} from '../common';
interface DataFrameJobStateStats {
id: jobId;
state: DataFrameJobState;
stats: DataFrameJobStats;
}
interface GetDataFrameTransformsResponse {
count: number;
transforms: DataFrameJob[];
}
interface GetDataFrameTransformsStatsResponse {
count: number;
transforms: DataFrameJobStateStats[];
}
export const getJobsFactory = (
setDataFrameJobs: React.Dispatch<React.SetStateAction<DataFrameJobListRow[]>>
) => async () => {
try {
const jobConfigs: GetDataFrameTransformsResponse = await ml.dataFrame.getDataFrameTransforms();
const jobStats: GetDataFrameTransformsStatsResponse = await ml.dataFrame.getDataFrameTransformsStats();
const tableRows = jobConfigs.transforms.map(config => {
const stats = jobStats.transforms.find(d => config.id === d.id);
if (stats === undefined) {
throw new Error('job stats not available');
}
// table with expandable rows requires `id` on the outer most level
return { config, id: config.id, state: stats.state, stats: stats.stats };
});
setDataFrameJobs(tableRows);
} catch (e) {
toastNotifications.addDanger(
i18n.translate('xpack.ml.dataframe.jobsList.errorGettingDataFrameJobsList', {
defaultMessage: 'An error occurred getting the data frame jobs list: {error}',
values: { error: JSON.stringify(e) },
})
);
}
};

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 { getJobsFactory } from './get_jobs';
export { deleteJobFactory } from './delete_job';
export { startJobFactory } from './start_job';
export { stopJobFactory } from './stop_job';

View file

@ -0,0 +1,31 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* 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 { DataFrameJobListRow } from '../common';
export const startJobFactory = (getJobs: () => void) => async (d: DataFrameJobListRow) => {
try {
await ml.dataFrame.startDataFrameTransformsJob(d.config.id);
toastNotifications.addSuccess(
i18n.translate('xpack.ml.dataframe.jobsList.startJobSuccessMessage', {
defaultMessage: 'Data frame job {jobId} started successfully.',
values: { jobId: d.config.id },
})
);
getJobs();
} catch (e) {
toastNotifications.addDanger(
i18n.translate('xpack.ml.dataframe.jobsList.startJobErrorMessage', {
defaultMessage: 'An error occurred starting the data frame job {jobId}: {error}',
values: { jobId: d.config.id, error: JSON.stringify(e) },
})
);
}
};

View file

@ -0,0 +1,31 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* 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 { DataFrameJobListRow } from '../common';
export const stopJobFactory = (getJobs: () => void) => async (d: DataFrameJobListRow) => {
try {
await ml.dataFrame.stopDataFrameTransformsJob(d.config.id);
getJobs();
toastNotifications.addSuccess(
i18n.translate('xpack.ml.dataframe.jobsList.stopJobSuccessMessage', {
defaultMessage: 'Data frame job {jobId} stopped successfully.',
values: { jobId: d.config.id },
})
);
} catch (e) {
toastNotifications.addDanger(
i18n.translate('xpack.ml.dataframe.jobsList.stopJobErrorMessage', {
defaultMessage: 'An error occurred stopping the data frame job {jobId}: {error}',
values: { jobId: d.config.id, error: JSON.stringify(e) },
})
);
}
};

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 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('mlDataFramePage', () => {
return {
scope: {},
restrict: 'E',
link: (scope: ng.IScope, element: ng.IAugmentedJQuery) => {
ReactDOM.render(<I18nContext>{React.createElement(Page)}</I18nContext>, element[0]);
element.on('$destroy', () => {
ReactDOM.unmountComponentAtNode(element[0]);
scope.$destroy();
});
},
};
});

View file

@ -0,0 +1,60 @@
/*
* 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, { SFC } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiButton,
EuiPage,
EuiPageBody,
EuiPageContentBody,
EuiPageContentHeader,
EuiPageContentHeaderSection,
EuiPanel,
EuiSpacer,
EuiTitle,
} from '@elastic/eui';
import { DataFrameJobList } from './components/job_list';
function newJob() {
window.location.href = `#/data_frame/new_job`;
}
export const Page: SFC = () => (
<EuiPage>
<EuiPageBody>
<EuiPageContentHeader>
<EuiPageContentHeaderSection>
<EuiTitle>
<h1>
<FormattedMessage
id="xpack.ml.dataframe.jobsList.dataFrameTitle"
defaultMessage="Data frame jobs"
/>
</h1>
</EuiTitle>
</EuiPageContentHeaderSection>
<EuiPageContentHeaderSection>
<EuiButton fill onClick={newJob} iconType="plusInCircle" size="s">
<FormattedMessage
id="xpack.ml.dataframe.jobsList.createDataFrameButton"
defaultMessage="Create data frame"
/>
</EuiButton>
</EuiPageContentHeaderSection>
</EuiPageContentHeader>
<EuiPageContentBody>
<EuiSpacer size="l" />
<EuiPanel>
<DataFrameJobList />
</EuiPanel>
</EuiPageContentBody>
</EuiPageBody>
</EuiPage>
);

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 { getDataFrameBreadcrumbs } from '../../breadcrumbs';
const template = `<ml-nav-menu name="data_frame" /><ml-data-frame-page />`;
uiRoutes.when('/data_frame/?', {
template,
k7Breadcrumbs: getDataFrameBreadcrumbs,
resolve: {
CheckLicense: checkFullLicense,
privileges: checkGetJobsPrivilege,
indexPatterns: loadIndexPatterns,
},
});

View file

@ -14,6 +14,7 @@
import uiRoutes from 'ui/routes';
import { checkLicenseExpired, checkBasicLicense } from 'plugins/ml/license/check_license';
import { getCreateJobBreadcrumbs, getDataVisualizerIndexOrSearchBreadcrumbs } from 'plugins/ml/jobs/breadcrumbs';
import { getDataFrameIndexOrSearchBreadcrumbs } from 'plugins/ml/data_frame/breadcrumbs';
import { preConfiguredJobRedirect } from 'plugins/ml/jobs/new_job/wizard/preconfigured_job_redirect';
import { checkCreateJobsPrivilege, checkFindFileStructurePrivilege } from 'plugins/ml/privilege/check_privilege';
import { loadIndexPatterns, getIndexPatterns } from 'plugins/ml/util/index_utils';
@ -28,6 +29,11 @@ uiRoutes
redirectTo: '/jobs/new_job/step/index_or_search'
});
uiRoutes
.when('/data_frame/new_job', {
redirectTo: '/data_frame/new_job/step/index_or_search'
});
uiRoutes
.when('/jobs/new_job/step/index_or_search', {
template,
@ -54,6 +60,18 @@ uiRoutes
}
});
uiRoutes
.when('/data_frame/new_job/step/index_or_search', {
template,
k7Breadcrumbs: getDataFrameIndexOrSearchBreadcrumbs,
resolve: {
CheckLicense: checkBasicLicense,
privileges: checkFindFileStructurePrivilege,
indexPatterns: loadIndexPatterns,
nextStepPath: () => '#data_frame/new_job/step/pivot',
}
});
import { uiModules } from 'ui/modules';
const module = uiModules.get('apps/ml');

View file

@ -0,0 +1,60 @@
/*
* 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 dataFrame = {
getDataFrameTransforms() {
return http({
url: `${basePath}/_data_frame/transforms`,
method: 'GET'
});
},
getDataFrameTransformsStats() {
return http({
url: `${basePath}/_data_frame/transforms/_stats`,
method: 'GET'
});
},
createDataFrameTransformsJob(jobId, jobConfig) {
return http({
url: `${basePath}/_data_frame/transforms/${jobId}`,
method: 'PUT',
data: jobConfig
});
},
deleteDataFrameTransformsJob(jobId) {
return http({
url: `${basePath}/_data_frame/transforms/${jobId}`,
method: 'DELETE',
});
},
getDataFrameTransformsPreview(obj) {
return http({
url: `${basePath}/_data_frame/transforms/_preview`,
method: 'POST',
data: obj
});
},
startDataFrameTransformsJob(jobId) {
return http({
url: `${basePath}/_data_frame/transforms/${jobId}/_start`,
method: 'POST',
});
},
stopDataFrameTransformsJob(jobId) {
return http({
url: `${basePath}/_data_frame/transforms/${jobId}/_stop`,
method: 'POST',
});
},
};

View file

@ -16,6 +16,17 @@ declare interface Ml {
indexAnnotation(annotation: Annotation): Promise<object>;
};
dataFrame: {
getDataFrameTransforms(): Promise<any>;
getDataFrameTransformsStats(): Promise<any>;
createDataFrameTransformsJob(jobId: string, jobConfig: any): Promise<any>;
deleteDataFrameTransformsJob(jobId: string): Promise<any>;
getDataFrameTransformsPreview(payload: any): Promise<any>;
startDataFrameTransformsJob(jobId: string): Promise<any>;
stopDataFrameTransformsJob(jobId: string): Promise<any>;
};
esSearch: any;
getTimeFieldRange(obj: object): Promise<any>;
}

View file

@ -12,6 +12,7 @@ import chrome from 'ui/chrome';
import { http } from '../../services/http_service';
import { annotations } from './annotations';
import { dataFrame } from './data_frame';
import { filters } from './filters';
import { results } from './results';
import { jobs } from './jobs';
@ -427,6 +428,7 @@ export const ml = {
},
annotations,
dataFrame,
filters,
results,
jobs,

View file

@ -105,6 +105,91 @@ export const elasticsearchJsPlugin = (Client, config, components) => {
method: 'POST'
});
ml.getDataFrameTransforms = ca({
urls: [
{
fmt: '/_data_frame/transforms',
}
],
method: 'GET'
});
ml.getDataFrameTransformsStats = ca({
urls: [
{
fmt: '/_data_frame/transforms/_stats',
}
],
method: 'GET'
});
ml.createDataFrameTransformsJob = ca({
urls: [
{
fmt: '/_data_frame/transforms/<%=jobId%>',
req: {
jobId: {
type: 'string'
}
}
}
],
needBody: true,
method: 'PUT'
});
ml.deleteDataFrameTransformsJob = ca({
urls: [
{
fmt: '/_data_frame/transforms/<%=jobId%>',
req: {
jobId: {
type: 'string'
}
}
}
],
method: 'DELETE'
});
ml.getDataFrameTransformsPreview = ca({
urls: [
{
fmt: '/_data_frame/transforms/_preview'
}
],
needBody: true,
method: 'POST'
});
ml.startDataFrameTransformsJob = ca({
urls: [
{
fmt: '/_data_frame/transforms/<%=jobId%>/_start',
req: {
jobId: {
type: 'string'
}
}
}
],
method: 'POST'
});
ml.stopDataFrameTransformsJob = ca({
urls: [
{
fmt: '/_data_frame/transforms/<%=jobId%>/_stop',
req: {
jobId: {
type: 'string'
}
}
}
],
method: 'POST'
});
ml.deleteJob = ca({
urls: [
{

View file

@ -0,0 +1,107 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { callWithRequestFactory } from '../client/call_with_request_factory';
import { wrapError } from '../client/errors';
export function dataFrameRoutes(server, commonRouteConfig) {
server.route({
method: 'GET',
path: '/api/ml/_data_frame/transforms',
handler(request) {
const callWithRequest = callWithRequestFactory(server, request);
return callWithRequest('ml.getDataFrameTransforms')
.catch(resp => wrapError(resp));
},
config: {
...commonRouteConfig
}
});
server.route({
method: 'GET',
path: '/api/ml/_data_frame/transforms/_stats',
handler(request) {
const callWithRequest = callWithRequestFactory(server, request);
return callWithRequest('ml.getDataFrameTransformsStats')
.catch(resp => wrapError(resp));
},
config: {
...commonRouteConfig
}
});
server.route({
method: 'PUT',
path: '/api/ml/_data_frame/transforms/{jobId}',
handler(request) {
const callWithRequest = callWithRequestFactory(server, request);
const { jobId } = request.params;
return callWithRequest('ml.createDataFrameTransformsJob', { body: request.payload, jobId })
.catch(resp => wrapError(resp));
},
config: {
...commonRouteConfig
}
});
server.route({
method: 'DELETE',
path: '/api/ml/_data_frame/transforms/{jobId}',
handler(request) {
const callWithRequest = callWithRequestFactory(server, request);
const { jobId } = request.params;
return callWithRequest('ml.deleteDataFrameTransformsJob', { jobId })
.catch(resp => wrapError(resp));
},
config: {
...commonRouteConfig
}
});
server.route({
method: 'POST',
path: '/api/ml/_data_frame/transforms/_preview',
handler(request) {
const callWithRequest = callWithRequestFactory(server, request);
return callWithRequest('ml.getDataFrameTransformsPreview', { body: request.payload })
.catch(resp => wrapError(resp));
},
config: {
...commonRouteConfig
}
});
server.route({
method: 'POST',
path: '/api/ml/_data_frame/transforms/{jobId}/_start',
handler(request) {
const callWithRequest = callWithRequestFactory(server, request);
const { jobId } = request.params;
return callWithRequest('ml.startDataFrameTransformsJob', { jobId })
.catch(resp => wrapError(resp));
},
config: {
...commonRouteConfig
}
});
server.route({
method: 'POST',
path: '/api/ml/_data_frame/transforms/{jobId}/_stop',
handler(request) {
const callWithRequest = callWithRequestFactory(server, request);
const { jobId } = request.params;
return callWithRequest('ml.stopDataFrameTransformsJob', { jobId })
.catch(resp => wrapError(resp));
},
config: {
...commonRouteConfig
}
});
}