mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
* wip: create react jobSelector wrapper + main component * Load jobs and select first if none selected via url * wip: create flyout content * Add endpoint for fetching jobs with timerange for table * display selected ids in flyout * Add custom table allowing external selection * add groups table in groups tab * Get groups and jobs in initial api call * add ability to select groups * Hook jobSelector into SingleMetricView * Show selected group badges with count * Organize jobSelector component directories * Move timerange logic to server * Move group color selection to utils * hide/show badges and add localization * fetch jobs in route to enable selector jobid validation * upate globalState on setting jobId in SingleMetricView * Add pager options.Retain search query on tab change * Ensure gantBar timeRanges correct * cleanup old commented code. tweak flyout header/footer style * running gantt bar and remove unnecessary api call * GanttBar running style. Pass timezone to server. * Running gantt bar limited to timerange. Clean up comments. * Refactor jobSelector endpoint to use fullJobs * Retain group selection in globalState * Recalculate ganttbars on resize * add test for JobSelectorTable
This commit is contained in:
parent
bac7060590
commit
b9f826f011
28 changed files with 2020 additions and 187 deletions
37
x-pack/plugins/ml/common/util/group_color_utils.js
Normal file
37
x-pack/plugins/ml/common/util/group_color_utils.js
Normal file
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* 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 * as euiVars from '@elastic/eui/dist/eui_theme_dark.json';
|
||||
import { stringHash } from './string_utils';
|
||||
|
||||
|
||||
const COLORS = [
|
||||
euiVars.euiColorVis0,
|
||||
euiVars.euiColorVis1,
|
||||
euiVars.euiColorVis2,
|
||||
euiVars.euiColorVis3,
|
||||
// euiVars.euiColorVis4, // light pink, too hard to read with white text
|
||||
euiVars.euiColorVis5,
|
||||
euiVars.euiColorVis6,
|
||||
euiVars.euiColorVis7,
|
||||
euiVars.euiColorVis8,
|
||||
euiVars.euiColorVis9,
|
||||
euiVars.euiColorDarkShade,
|
||||
euiVars.euiColorPrimary
|
||||
];
|
||||
|
||||
const colorMap = {};
|
||||
|
||||
export function tabColor(name) {
|
||||
if (colorMap[name] === undefined) {
|
||||
const n = stringHash(name);
|
||||
const color = COLORS[(n % COLORS.length)];
|
||||
colorMap[name] = color;
|
||||
return color;
|
||||
} else {
|
||||
return colorMap[name];
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
@import 'job_selector';
|
|
@ -0,0 +1,80 @@
|
|||
.mlJobSelectorBar {
|
||||
padding: 10px;
|
||||
background-color: $euiColorLightShade
|
||||
}
|
||||
|
||||
.mlJobSelectorFlyoutBody > .euiFlyoutBody__overflow {
|
||||
padding-top: $euiSizeS;
|
||||
}
|
||||
|
||||
.mlJobSelector__ganttBar {
|
||||
background-color: #79adda;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.mlJobSelector__ganttBarBackEdge {
|
||||
height: 18px;
|
||||
border-left: 1px solid #d6d6d6;
|
||||
border-right: 1px solid #d6d6d6;
|
||||
margin-bottom: -16px;
|
||||
padding-top: 9px;
|
||||
}
|
||||
|
||||
.mlJobSelector__ganttBarDashed {
|
||||
height: 1px;
|
||||
border-top: 1px dashed #d6d6d6;
|
||||
}
|
||||
|
||||
.mlJobSelector__ganttBarRunning {
|
||||
background-image:-webkit-gradient(linear,
|
||||
0 100%, 100% 0,
|
||||
color-stop(0.25, rgba(255, 255, 255, 0.15)),
|
||||
color-stop(0.25, transparent),
|
||||
color-stop(0.5, transparent),
|
||||
color-stop(0.5, rgba(255, 255, 255, 0.15)),
|
||||
color-stop(0.75, rgba(255, 255, 255, 0.15)),
|
||||
color-stop(0.75, transparent),
|
||||
to(transparent));
|
||||
background-image:-webkit-linear-gradient(45deg,
|
||||
rgba(255, 255, 255, 0.15) 25%,
|
||||
transparent 25%, transparent 50%,
|
||||
rgba(255, 255, 255, 0.15) 50%,
|
||||
rgba(255, 255, 255, 0.15) 75%,
|
||||
transparent 75%,
|
||||
transparent);
|
||||
background-image:-moz-linear-gradient(45deg,
|
||||
rgba(255, 255, 255, 0.15) 25%,
|
||||
transparent 25%,
|
||||
transparent 50%,
|
||||
rgba(255, 255, 255, 0.15) 50%,
|
||||
rgba(255, 255, 255, 0.15) 75%,
|
||||
transparent 75%,
|
||||
transparent);
|
||||
background-image:-o-linear-gradient(45deg,
|
||||
rgba(255, 255, 255, 0.15) 25%,
|
||||
transparent 25%,
|
||||
transparent 50%,
|
||||
rgba(255, 255, 255, 0.15) 50%,
|
||||
rgba(255, 255, 255, 0.15) 75%,
|
||||
transparent 75%,
|
||||
transparent);
|
||||
background-image:linear-gradient(45deg,
|
||||
rgba(255, 255, 255, 0.15) 25%,
|
||||
transparent 25%,
|
||||
transparent 50%,
|
||||
rgba(255, 255, 255, 0.15) 50%,
|
||||
rgba(255, 255, 255, 0.15) 75%,
|
||||
transparent 75%,
|
||||
transparent);
|
||||
-webkit-background-size:40px 40px;
|
||||
-moz-background-size:40px 40px;
|
||||
-o-background-size:40px 40px;
|
||||
background-size:40px 40px;
|
||||
|
||||
-webkit-animation:progress-bar-stripes 2s linear infinite;
|
||||
-moz-animation:progress-bar-stripes 2s linear infinite;
|
||||
-ms-animation:progress-bar-stripes 2s linear infinite;
|
||||
-o-animation:progress-bar-stripes 2s linear infinite;
|
||||
animation:progress-bar-stripes 2s linear infinite;
|
||||
}
|
|
@ -0,0 +1,390 @@
|
|||
/*
|
||||
* 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, useState, useEffect } from 'react';
|
||||
import { PropTypes } from 'prop-types';
|
||||
import {
|
||||
EuiCheckbox,
|
||||
EuiSearchBar,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiFormRow,
|
||||
EuiRadio,
|
||||
EuiSpacer,
|
||||
EuiTable,
|
||||
EuiTableBody,
|
||||
EuiTableHeader,
|
||||
EuiTableHeaderCell,
|
||||
EuiTableHeaderCellCheckbox,
|
||||
EuiTablePagination,
|
||||
EuiTableRow,
|
||||
EuiTableRowCell,
|
||||
EuiTableRowCellCheckbox,
|
||||
EuiTableHeaderMobile,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { Pager } from '@elastic/eui/lib/services';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
|
||||
const JOBS_PER_PAGE = 20;
|
||||
|
||||
function getError(error) {
|
||||
if (error !== null) {
|
||||
return i18n.translate('xpack.ml.jobSelector.filterBar.invalidSearchErrorMessage', {
|
||||
defaultMessage: `Invalid search: {errorMessage}`,
|
||||
values: { errorMessage: error.message },
|
||||
});
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
export function CustomSelectionTable({
|
||||
columns,
|
||||
filterDefaultFields,
|
||||
filters,
|
||||
items,
|
||||
onTableChange,
|
||||
selectedIds,
|
||||
singleSelection,
|
||||
sortableProperties,
|
||||
timeseriesOnly
|
||||
}) {
|
||||
const [itemIdToSelectedMap, setItemIdToSelectedMap] = useState(getCurrentlySelectedItemIdsMap());
|
||||
const [currentItems, setCurrentItems] = useState(items);
|
||||
const [lastSelected, setLastSelected] = useState(selectedIds);
|
||||
const [sortedColumn, setSortedColumn] = useState('');
|
||||
const [pager, setPager] = useState();
|
||||
const [pagerSettings, setPagerSettings] = useState({
|
||||
itemsPerPage: JOBS_PER_PAGE,
|
||||
firstItemIndex: 0,
|
||||
lastItemIndex: 1
|
||||
});
|
||||
const [query, setQuery] = useState(EuiSearchBar.Query.MATCH_ALL);
|
||||
const [error, setError] = useState(null); // eslint-disable-line
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentItems(items);
|
||||
handleQueryChange({ query: query });
|
||||
}, [items]); // eslint-disable-line
|
||||
|
||||
// When changes to selected ids made via badge removal - update selection in the table accordingly
|
||||
useEffect(() => {
|
||||
setItemIdToSelectedMap(getCurrentlySelectedItemIdsMap());
|
||||
}, [selectedIds]); // eslint-disable-line
|
||||
|
||||
useEffect(() => {
|
||||
const tablePager = new Pager(currentItems.length, JOBS_PER_PAGE);
|
||||
setPagerSettings({
|
||||
itemsPerPage: JOBS_PER_PAGE,
|
||||
firstItemIndex: tablePager.getFirstItemIndex(),
|
||||
lastItemIndex: tablePager.getLastItemIndex()
|
||||
});
|
||||
setPager(tablePager);
|
||||
}, [currentItems]);
|
||||
|
||||
function getCurrentlySelectedItemIdsMap() {
|
||||
const selectedIdsMap = { 'all': false };
|
||||
selectedIds.forEach(id => { selectedIdsMap[id] = true; });
|
||||
return selectedIdsMap;
|
||||
}
|
||||
|
||||
function handleSingleSelectionTableChange(itemId) {
|
||||
onTableChange([itemId]);
|
||||
}
|
||||
|
||||
function handleTableChange({ isSelected, itemId }) {
|
||||
const allIds = Object.getOwnPropertyNames(itemIdToSelectedMap);
|
||||
let currentSelected = allIds;
|
||||
|
||||
if (itemId !== 'all') {
|
||||
currentSelected = allIds.filter((id) =>
|
||||
itemIdToSelectedMap[id] === true && id !== itemId);
|
||||
|
||||
if (isSelected === true) {
|
||||
currentSelected.push(itemId);
|
||||
}
|
||||
} else {
|
||||
if (isSelected === false) {
|
||||
currentSelected = [];
|
||||
} else {
|
||||
// grab all id's
|
||||
currentSelected = currentItems.map((item) => item.id);
|
||||
}
|
||||
}
|
||||
|
||||
onTableChange(currentSelected);
|
||||
}
|
||||
|
||||
function handleChangeItemsPerPage(itemsPerPage) {
|
||||
pager.setItemsPerPage(itemsPerPage);
|
||||
setPagerSettings({
|
||||
...pagerSettings,
|
||||
itemsPerPage,
|
||||
firstItemIndex: pager.getFirstItemIndex(),
|
||||
lastItemIndex: pager.getLastItemIndex()
|
||||
});
|
||||
}
|
||||
|
||||
function handlePageChange(pageIndex) {
|
||||
pager.goToPageIndex(pageIndex);
|
||||
setPagerSettings({
|
||||
...pagerSettings,
|
||||
firstItemIndex: pager.getFirstItemIndex(),
|
||||
lastItemIndex: pager.getLastItemIndex()
|
||||
});
|
||||
}
|
||||
|
||||
function handleQueryChange({ query: incomingQuery, error: newError }) {
|
||||
if (newError) {
|
||||
setError(newError);
|
||||
} else {
|
||||
const queriedItems = EuiSearchBar.Query.execute(incomingQuery, items, { defaultFields: filterDefaultFields });
|
||||
setError(null);
|
||||
setCurrentItems(queriedItems);
|
||||
setQuery(incomingQuery);
|
||||
}
|
||||
}
|
||||
|
||||
function isItemSelected(itemId) {
|
||||
return itemIdToSelectedMap[itemId] === true;
|
||||
}
|
||||
|
||||
function areAllItemsSelected() {
|
||||
const indexOfUnselectedItem = currentItems.findIndex(item => !isItemSelected(item.id));
|
||||
return indexOfUnselectedItem === -1;
|
||||
}
|
||||
|
||||
function renderSelectAll(mobile) {
|
||||
const selectAll = i18n.translate('xpack.ml.jobSelector.customTable.selectAllCheckboxLabel', {
|
||||
defaultMessage: 'Select all'
|
||||
});
|
||||
|
||||
return (
|
||||
<EuiCheckbox
|
||||
id="selectAllCheckbox"
|
||||
label={mobile ? selectAll : null}
|
||||
checked={areAllItemsSelected()}
|
||||
onChange={toggleAll}
|
||||
type={mobile ? null : 'inList'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function toggleItem(itemId) {
|
||||
// If enforcing singleSelection select incoming and deselect the last selected
|
||||
if (singleSelection) {
|
||||
const lastId = lastSelected[0];
|
||||
// deselect last selected and select incoming id
|
||||
setItemIdToSelectedMap({ ...itemIdToSelectedMap, [lastId]: false, [itemId]: true });
|
||||
handleSingleSelectionTableChange(itemId);
|
||||
setLastSelected([itemId]);
|
||||
} else {
|
||||
const isSelected = !isItemSelected(itemId);
|
||||
setItemIdToSelectedMap({ ...itemIdToSelectedMap, [itemId]: isSelected });
|
||||
handleTableChange({ isSelected, itemId });
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
const allSelected = areAllItemsSelected() || itemIdToSelectedMap.all === true;
|
||||
const newItemIdToSelectedMap = {};
|
||||
currentItems.forEach(item => newItemIdToSelectedMap[item.id] = !allSelected);
|
||||
setItemIdToSelectedMap(newItemIdToSelectedMap);
|
||||
handleTableChange({ isSelected: !allSelected, itemId: 'all' });
|
||||
}
|
||||
|
||||
function onSort(prop) {
|
||||
sortableProperties.sortOn(prop);
|
||||
const sortedItems = sortableProperties.sortItems(currentItems);
|
||||
setCurrentItems(sortedItems);
|
||||
setSortedColumn(prop);
|
||||
}
|
||||
|
||||
function renderHeaderCells() {
|
||||
const headers = [];
|
||||
|
||||
columns.forEach((column, columnIndex) => {
|
||||
if (column.isCheckbox && !singleSelection) {
|
||||
headers.push(
|
||||
<EuiTableHeaderCellCheckbox
|
||||
key={column.id}
|
||||
width={column.width}
|
||||
>
|
||||
{renderSelectAll()}
|
||||
</EuiTableHeaderCellCheckbox>
|
||||
);
|
||||
} else {
|
||||
headers.push(
|
||||
<EuiTableHeaderCell
|
||||
key={column.id}
|
||||
align={columns[columnIndex].alignment}
|
||||
width={column.width}
|
||||
onSort={column.isSortable ? () => onSort(column.id) : undefined}
|
||||
isSorted={sortedColumn === column.id}
|
||||
isSortAscending={sortableProperties ? sortableProperties.isAscendingByName(column.id) : true}
|
||||
mobileOptions={column.mobileOptions}
|
||||
>
|
||||
{column.label}
|
||||
</EuiTableHeaderCell>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return headers.length ? headers : null;
|
||||
}
|
||||
|
||||
function renderRows() {
|
||||
const renderRow = item => {
|
||||
const cells = columns.map(column => {
|
||||
const cell = item[column.id];
|
||||
|
||||
let child;
|
||||
|
||||
if (column.isCheckbox) {
|
||||
return (
|
||||
<EuiTableRowCellCheckbox key={column.id}>
|
||||
{!singleSelection &&
|
||||
<EuiCheckbox
|
||||
id={`${item.id}-checkbox`}
|
||||
data-testid={`${item.id}-checkbox`}
|
||||
checked={isItemSelected(item.id)}
|
||||
onChange={() => toggleItem(item.id)}
|
||||
type="inList"
|
||||
/>}
|
||||
{singleSelection &&
|
||||
<EuiRadio
|
||||
id={item.id}
|
||||
data-testid={`${item.id}-radio-button`}
|
||||
checked={isItemSelected(item.id)}
|
||||
onChange={() => toggleItem(item.id)}
|
||||
disabled={timeseriesOnly && item.isSingleMetricViewerJob === false}
|
||||
/>}
|
||||
</EuiTableRowCellCheckbox>
|
||||
);
|
||||
}
|
||||
|
||||
if (column.render) {
|
||||
child = column.render(item);
|
||||
} else {
|
||||
child = cell;
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiTableRowCell
|
||||
key={column.id}
|
||||
align={column.alignment}
|
||||
truncateText={cell && cell.truncateText}
|
||||
textOnly={cell ? cell.textOnly : true}
|
||||
mobileOptions={{
|
||||
header: column.label,
|
||||
...column.mobileOptions
|
||||
}}
|
||||
>
|
||||
{child}
|
||||
</EuiTableRowCell>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<EuiTableRow
|
||||
key={item.id}
|
||||
isSelected={isItemSelected(item.id)}
|
||||
isSelectable={true}
|
||||
hasActions={true}
|
||||
>
|
||||
{cells}
|
||||
</EuiTableRow>
|
||||
);
|
||||
};
|
||||
|
||||
const rows = [];
|
||||
|
||||
for (let itemIndex = pagerSettings.firstItemIndex; itemIndex <= pagerSettings.lastItemIndex; itemIndex++) {
|
||||
const item = currentItems[itemIndex];
|
||||
if (item === undefined) {
|
||||
break;
|
||||
}
|
||||
rows.push(renderRow(item));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<EuiSpacer size="s"/>
|
||||
<EuiFlexGroup direction="column">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiSearchBar
|
||||
defaultQuery={query}
|
||||
box={{
|
||||
incremental: true,
|
||||
placeholder: i18n.translate('xpack.ml.jobSelector.customTable.searchBarPlaceholder', {
|
||||
defaultMessage: 'Search...'
|
||||
})
|
||||
}}
|
||||
filters={filters}
|
||||
onChange={handleQueryChange}
|
||||
/>
|
||||
<EuiFormRow
|
||||
fullWidth
|
||||
isInvalid={(error !== null)}
|
||||
error={getError(error)}
|
||||
style={{ maxHeight: '0px' }}
|
||||
>
|
||||
<Fragment />
|
||||
</EuiFormRow>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="m" />
|
||||
<EuiTableHeaderMobile>
|
||||
<EuiFlexGroup
|
||||
responsive={false}
|
||||
justifyContent="spaceBetween"
|
||||
alignItems="baseline"
|
||||
>
|
||||
<EuiFlexItem grow={false}>
|
||||
{renderSelectAll(true)}
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiTableHeaderMobile>
|
||||
<EuiTable>
|
||||
<EuiTableHeader>
|
||||
{renderHeaderCells()}
|
||||
</EuiTableHeader>
|
||||
<EuiTableBody>
|
||||
{renderRows()}
|
||||
</EuiTableBody>
|
||||
</EuiTable>
|
||||
<EuiSpacer size="m" />
|
||||
{ pager !== undefined &&
|
||||
<EuiTablePagination
|
||||
activePage={pager.getCurrentPageIndex()}
|
||||
itemsPerPage={pagerSettings.itemsPerPage}
|
||||
itemsPerPageOptions={[10, JOBS_PER_PAGE, 50]}
|
||||
pageCount={pager.getTotalPages()}
|
||||
onChangeItemsPerPage={handleChangeItemsPerPage}
|
||||
onChangePage={(pageIndex) => handlePageChange(pageIndex)}
|
||||
/>}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
CustomSelectionTable.propTypes = {
|
||||
columns: PropTypes.array.isRequired,
|
||||
filterDefaultFields: PropTypes.array,
|
||||
filters: PropTypes.array,
|
||||
items: PropTypes.array.isRequired,
|
||||
onTableChange: PropTypes.func.isRequired,
|
||||
selectedId: PropTypes.array,
|
||||
singleSelection: PropTypes.string,
|
||||
sortableProperties: PropTypes.object,
|
||||
timeseriesOnly: PropTypes.string
|
||||
};
|
|
@ -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 { CustomSelectionTable } from './custom_selection_table';
|
10
x-pack/plugins/ml/public/components/job_selector/index.js
Normal file
10
x-pack/plugins/ml/public/components/job_selector/index.js
Normal 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 './job_selector_react_wrapper_directive';
|
|
@ -0,0 +1,189 @@
|
|||
/*
|
||||
* 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 { difference, find } from 'lodash'; // TODO: find a way to not rely on this anymore
|
||||
import { toastNotifications } from 'ui/notify';
|
||||
import { mlJobService } from '../../services/job_service';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import moment from 'moment';
|
||||
import d3 from 'd3';
|
||||
|
||||
|
||||
function warnAboutInvalidJobIds(invalidIds) {
|
||||
if (invalidIds.length > 0) {
|
||||
toastNotifications.addWarning(i18n.translate('xpack.ml.jobSelect.requestedJobsDoesNotExistWarningMessage', {
|
||||
defaultMessage: `Requested
|
||||
{invalidIdsLength, plural, one {job {invalidIds} does not exist} other {jobs {invalidIds} do not exist}}`,
|
||||
values: {
|
||||
invalidIdsLength: invalidIds.length,
|
||||
invalidIds,
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// check that the ids read from the url exist by comparing them to the
|
||||
// jobs loaded via mlJobsService.
|
||||
function getInvalidJobIds(ids) {
|
||||
return ids.filter(id => {
|
||||
const job = find(mlJobService.jobs, { 'job_id': id });
|
||||
return (job === undefined && id !== '*');
|
||||
});
|
||||
}
|
||||
|
||||
function checkGlobalState(globalState) {
|
||||
if (globalState.ml === undefined) {
|
||||
globalState.ml = {};
|
||||
globalState.save();
|
||||
}
|
||||
}
|
||||
|
||||
function loadJobIdsFromGlobalState(globalState) { // jobIds, groups
|
||||
const jobIds = [];
|
||||
let groups = [];
|
||||
|
||||
if (globalState.ml && globalState.ml.jobIds) {
|
||||
let tempJobIds = [];
|
||||
groups = globalState.ml.groups || [];
|
||||
|
||||
if (typeof globalState.ml.jobIds === 'string') {
|
||||
tempJobIds.push(globalState.ml.jobIds);
|
||||
} else {
|
||||
tempJobIds = globalState.ml.jobIds;
|
||||
}
|
||||
tempJobIds = tempJobIds.map(id => String(id));
|
||||
|
||||
const invalidIds = getInvalidJobIds(tempJobIds);
|
||||
warnAboutInvalidJobIds(invalidIds);
|
||||
|
||||
let validIds = difference(tempJobIds, invalidIds);
|
||||
// if there are no valid ids, warn and then select the first job
|
||||
if (validIds.length === 0) {
|
||||
toastNotifications.addWarning(i18n.translate('xpack.ml.jobSelect.noJobsSelectedWarningMessage', {
|
||||
defaultMessage: 'No jobs selected, auto selecting first job',
|
||||
}));
|
||||
|
||||
if (mlJobService.jobs.length) {
|
||||
validIds = [mlJobService.jobs[0].job_id];
|
||||
}
|
||||
}
|
||||
jobIds.push(...validIds);
|
||||
} else {
|
||||
// no jobs selected, use the first in the list
|
||||
if (mlJobService.jobs.length) {
|
||||
jobIds.push(mlJobService.jobs[0].job_id);
|
||||
}
|
||||
}
|
||||
return { jobIds, selectedGroups: groups };
|
||||
}
|
||||
|
||||
export function setGlobalState(globalState, { selectedIds, selectedGroups }) {
|
||||
checkGlobalState(globalState);
|
||||
globalState.ml.jobIds = selectedIds;
|
||||
globalState.ml.groups = selectedGroups;
|
||||
globalState.save();
|
||||
}
|
||||
|
||||
// called externally to retrieve the selected jobs ids.
|
||||
// passing in `true` will load the jobs ids from the URL first
|
||||
export function getSelectedJobIds(globalState) {
|
||||
return loadJobIdsFromGlobalState(globalState);
|
||||
}
|
||||
|
||||
export function getGroupsFromJobs(jobs) {
|
||||
const groups = {};
|
||||
const groupsMap = {};
|
||||
|
||||
jobs.forEach((job) => {
|
||||
// Organize job by group
|
||||
if (job.groups !== undefined) {
|
||||
job.groups.forEach((g) => {
|
||||
if (groups[g] === undefined) {
|
||||
groups[g] = {
|
||||
id: g,
|
||||
jobIds: [job.job_id],
|
||||
timeRange: {
|
||||
to: job.timeRange.to,
|
||||
toMoment: null,
|
||||
from: job.timeRange.from,
|
||||
fromMoment: null,
|
||||
fromPx: job.timeRange.fromPx,
|
||||
toPx: job.timeRange.toPx,
|
||||
widthPx: null,
|
||||
}
|
||||
};
|
||||
|
||||
groupsMap[g] = [job.job_id];
|
||||
} else {
|
||||
groups[g].jobIds.push(job.job_id);
|
||||
groupsMap[g].push(job.job_id);
|
||||
// keep track of earliest 'from' / latest 'to' for group range
|
||||
if (groups[g].timeRange.to === null || job.timeRange.to > groups[g].timeRange.to) {
|
||||
groups[g].timeRange.to = job.timeRange.to;
|
||||
groups[g].timeRange.toMoment = job.timeRange.toMoment;
|
||||
}
|
||||
if (groups[g].timeRange.from === null || job.timeRange.from < groups[g].timeRange.from) {
|
||||
groups[g].timeRange.from = job.timeRange.from;
|
||||
groups[g].timeRange.fromMoment = job.timeRange.fromMoment;
|
||||
}
|
||||
if (groups[g].timeRange.toPx === null || job.timeRange.toPx > groups[g].timeRange.toPx) {
|
||||
groups[g].timeRange.toPx = job.timeRange.toPx;
|
||||
}
|
||||
if (groups[g].timeRange.fromPx === null || job.timeRange.fromPx < groups[g].timeRange.fromPx) {
|
||||
groups[g].timeRange.fromPx = job.timeRange.fromPx;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Object.keys(groups).forEach((groupId) => {
|
||||
const group = groups[groupId];
|
||||
group.timeRange.widthPx = group.timeRange.toPx - group.timeRange.fromPx;
|
||||
group.timeRange.toMoment = moment(group.timeRange.to);
|
||||
group.timeRange.fromMoment = moment(group.timeRange.from);
|
||||
// create label
|
||||
const fromString = group.timeRange.fromMoment.format('MMM Do YYYY, HH:mm');
|
||||
const toString = group.timeRange.toMoment.format('MMM Do YYYY, HH:mm');
|
||||
group.timeRange.label = i18n.translate('xpack.ml.jobSelectList.groupTimeRangeLabel', {
|
||||
defaultMessage: '{fromString} to {toString}',
|
||||
values: {
|
||||
fromString,
|
||||
toString,
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return { groups: Object.keys(groups).map(g => groups[g]), groupsMap };
|
||||
}
|
||||
|
||||
export function normalizeTimes(jobs, dateFormatTz, ganttBarWidth) {
|
||||
const min = Math.min(...jobs.map(job => +job.timeRange.from));
|
||||
const max = Math.max(...jobs.map(job => +job.timeRange.to));
|
||||
const ganttScale = d3.scale.linear().domain([min, max]).range([1, ganttBarWidth]);
|
||||
|
||||
jobs.forEach(job => {
|
||||
if (job.timeRange.to !== undefined && job.timeRange.from !== undefined) {
|
||||
job.timeRange.fromPx = ganttScale(job.timeRange.from);
|
||||
job.timeRange.toPx = ganttScale(job.timeRange.to);
|
||||
job.timeRange.widthPx = job.timeRange.toPx - job.timeRange.fromPx;
|
||||
|
||||
job.timeRange.toMoment = moment(job.timeRange.to).tz(dateFormatTz);
|
||||
job.timeRange.fromMoment = moment(job.timeRange.from).tz(dateFormatTz);
|
||||
|
||||
const fromString = job.timeRange.fromMoment.format('MMM Do YYYY, HH:mm');
|
||||
const toString = job.timeRange.toMoment.format('MMM Do YYYY, HH:mm');
|
||||
job.timeRange.label = i18n.translate('xpack.ml.jobSelector.jobTimeRangeLabel', {
|
||||
defaultMessage: '{fromString} to {toString}',
|
||||
values: {
|
||||
fromString,
|
||||
toString,
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
return jobs;
|
||||
}
|
510
x-pack/plugins/ml/public/components/job_selector/job_selector.js
Normal file
510
x-pack/plugins/ml/public/components/job_selector/job_selector.js
Normal file
|
@ -0,0 +1,510 @@
|
|||
/*
|
||||
* 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, { useState, useEffect, useRef } from 'react';
|
||||
import { PropTypes } from 'prop-types';
|
||||
import moment from 'moment';
|
||||
|
||||
import { ml } from '../../services/ml_api_service';
|
||||
import { JobSelectorTable } from './job_selector_table/';
|
||||
import { timefilter } from 'ui/timefilter';
|
||||
import { tabColor } from '../../../common/util/group_color_utils';
|
||||
import { getGroupsFromJobs, normalizeTimes, setGlobalState } from './job_select_service_utils';
|
||||
import { toastNotifications } from 'ui/notify';
|
||||
import {
|
||||
EuiBadge,
|
||||
EuiButton,
|
||||
EuiButtonEmpty,
|
||||
EuiFlexItem,
|
||||
EuiFlexGroup,
|
||||
EuiFlyout,
|
||||
EuiFlyoutBody,
|
||||
EuiFlyoutFooter,
|
||||
EuiFlyoutHeader,
|
||||
EuiLink,
|
||||
EuiSwitch,
|
||||
EuiText,
|
||||
EuiTitle
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
|
||||
export function getBadge({ id, icon, isGroup = false, removeId, numJobs }) {
|
||||
const color = isGroup ? tabColor(id) : 'hollow';
|
||||
let props = { color };
|
||||
let jobCount;
|
||||
|
||||
if (icon === true) {
|
||||
props = {
|
||||
...props,
|
||||
iconType: 'cross',
|
||||
iconSide: 'right',
|
||||
onClick: () => removeId(id),
|
||||
onClickAriaLabel: 'Remove id'
|
||||
};
|
||||
}
|
||||
|
||||
if (numJobs !== undefined) {
|
||||
jobCount = i18n.translate('xpack.ml.jobSelector.selectedGroupJobs', {
|
||||
defaultMessage: `({jobsCount, plural, one {# job} other {# jobs}})`,
|
||||
values: { jobsCount: numJobs },
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiBadge key={`${id}-id`} {...props} >
|
||||
{`${id}${jobCount ? jobCount : ''}`}
|
||||
</EuiBadge>
|
||||
);
|
||||
}
|
||||
|
||||
function mergeSelection(jobIds, groupObjs, singleSelection) {
|
||||
if (singleSelection) {
|
||||
return jobIds;
|
||||
}
|
||||
|
||||
const selectedIds = [];
|
||||
const alreadySelected = [];
|
||||
|
||||
groupObjs.forEach((group) => {
|
||||
selectedIds.push(group.groupId);
|
||||
alreadySelected.push(...group.jobIds);
|
||||
});
|
||||
|
||||
jobIds.forEach((jobId) => {
|
||||
// Add jobId if not already included in group selection
|
||||
if (alreadySelected.includes(jobId) === false) {
|
||||
selectedIds.push(jobId);
|
||||
}
|
||||
});
|
||||
|
||||
return selectedIds;
|
||||
}
|
||||
|
||||
function getInitialGroupsMap(selectedGroups) {
|
||||
const map = {};
|
||||
|
||||
if (selectedGroups.length) {
|
||||
selectedGroups.forEach((group) => {
|
||||
map[group.groupId] = group.jobIds;
|
||||
});
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
const BADGE_LIMIT = 10;
|
||||
const DEFAULT_GANTT_BAR_WIDTH = 299; // pixels
|
||||
|
||||
export function JobSelector({
|
||||
config,
|
||||
globalState,
|
||||
jobSelectService,
|
||||
selectedJobIds,
|
||||
selectedGroups,
|
||||
singleSelection,
|
||||
timeseriesOnly
|
||||
}) {
|
||||
const [jobs, setJobs] = useState([]);
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [maps, setMaps] = useState({ groupsMap: getInitialGroupsMap(selectedGroups), jobsMap: {} });
|
||||
const [selectedIds, setSelectedIds] = useState(mergeSelection(selectedJobIds, selectedGroups, singleSelection));
|
||||
const [newSelection, setNewSelection] = useState(mergeSelection(selectedJobIds, selectedGroups, singleSelection));
|
||||
const [showAllBadges, setShowAllBadges] = useState(false);
|
||||
const [showAllBarBadges, setShowAllBarBadges] = useState(false);
|
||||
const [applyTimeRange, setApplyTimeRange] = useState(true);
|
||||
const [isFlyoutVisible, setIsFlyoutVisible] = useState(false);
|
||||
const [ganttBarWidth, setGanttBarWidth] = useState(DEFAULT_GANTT_BAR_WIDTH);
|
||||
const flyoutEl = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
// listen for update from Single Metric Viewer
|
||||
const subscription = jobSelectService.subscribe(({ selection, resetSelection }) => {
|
||||
if (resetSelection === true) {
|
||||
setSelectedIds(selection);
|
||||
}
|
||||
});
|
||||
|
||||
return function cleanup() {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, []); // eslint-disable-line
|
||||
|
||||
// Ensure current selected ids always show up in flyout
|
||||
useEffect(() => {
|
||||
setNewSelection(selectedIds);
|
||||
}, [isFlyoutVisible]); // eslint-disable-line
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (jobs.length > 0 && flyoutEl && flyoutEl.current && flyoutEl.current.flyout) {
|
||||
const tzConfig = config.get('dateFormat:tz');
|
||||
const dateFormatTz = (tzConfig !== 'Browser') ? tzConfig : moment.tz.guess();
|
||||
const derivedWidth = Math.round(flyoutEl.current.flyout.offsetWidth / 4);
|
||||
const normalizedJobs = normalizeTimes(jobs, dateFormatTz, derivedWidth);
|
||||
setJobs(normalizedJobs);
|
||||
const { groups: updatedGroups } = getGroupsFromJobs(normalizedJobs);
|
||||
setGroups(updatedGroups);
|
||||
setGanttBarWidth(derivedWidth);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, [config, jobs]);
|
||||
|
||||
function closeFlyout() {
|
||||
setIsFlyoutVisible(false);
|
||||
}
|
||||
|
||||
function showFlyout() {
|
||||
setIsFlyoutVisible(true);
|
||||
}
|
||||
|
||||
function handleJobSelectionClick() {
|
||||
showFlyout();
|
||||
const tzConfig = config.get('dateFormat:tz');
|
||||
const dateFormatTz = (tzConfig !== 'Browser') ? tzConfig : moment.tz.guess();
|
||||
|
||||
ml.jobs.jobsWithTimerange(dateFormatTz)
|
||||
.then((resp) => {
|
||||
const normalizedJobs = normalizeTimes(resp.jobs, dateFormatTz, DEFAULT_GANTT_BAR_WIDTH);
|
||||
const { groups: groupsWithTimerange, groupsMap } = getGroupsFromJobs(normalizedJobs);
|
||||
setJobs(normalizedJobs);
|
||||
setGroups(groupsWithTimerange);
|
||||
setMaps({ groupsMap, jobsMap: resp.jobsMap });
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log('Error fetching jobs', err);
|
||||
toastNotifications.addDanger({
|
||||
title: i18n.translate('xpack.ml.jobSelector.jobFetchErrorMessage', {
|
||||
defaultMessage: 'An error occurred fetching jobs. Refresh and try again.',
|
||||
})
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function handleNewSelection({ selectionFromTable }) {
|
||||
setNewSelection(selectionFromTable);
|
||||
}
|
||||
|
||||
function applySelection() {
|
||||
closeFlyout();
|
||||
const allNewSelection = [];
|
||||
const groupSelection = [];
|
||||
|
||||
newSelection.forEach((id) => {
|
||||
if (maps.groupsMap[id] !== undefined) {
|
||||
allNewSelection.push(...maps.groupsMap[id]);
|
||||
// if it's a group - push group obj to set in global state
|
||||
groupSelection.push({ groupId: id, jobIds: maps.groupsMap[id] });
|
||||
} else {
|
||||
allNewSelection.push(id);
|
||||
}
|
||||
});
|
||||
// create a Set to remove duplicate values
|
||||
const allNewSelectionUnique = Array.from(new Set(allNewSelection));
|
||||
|
||||
setSelectedIds(newSelection);
|
||||
setNewSelection([]);
|
||||
applyTimeRangeFromSelection(allNewSelectionUnique);
|
||||
jobSelectService.next({ selection: allNewSelectionUnique });
|
||||
|
||||
setGlobalState(globalState, { selectedIds: allNewSelectionUnique, selectedGroups: groupSelection });
|
||||
}
|
||||
|
||||
function applyTimeRangeFromSelection(selection) {
|
||||
if (applyTimeRange && jobs.length > 0) {
|
||||
const times = [];
|
||||
jobs.forEach(job => {
|
||||
if (selection.includes(job.job_id)) {
|
||||
if (job.timeRange.from !== undefined) {
|
||||
times.push(job.timeRange.from);
|
||||
}
|
||||
if (job.timeRange.to !== undefined) {
|
||||
times.push(job.timeRange.to);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (times.length) {
|
||||
const min = Math.min(...times);
|
||||
const max = Math.max(...times);
|
||||
timefilter.setTime({
|
||||
from: moment(min).toISOString(),
|
||||
to: moment(max).toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTimerangeSwitch() {
|
||||
setApplyTimeRange(!applyTimeRange);
|
||||
}
|
||||
|
||||
function removeId(id) {
|
||||
setNewSelection(newSelection.filter((item) => item !== id));
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
setNewSelection([]);
|
||||
}
|
||||
|
||||
function renderIdBadges() {
|
||||
const badges = [];
|
||||
const currentGroups = [];
|
||||
// Create group badges. Skip job ids here.
|
||||
for (let i = 0; i < selectedIds.length; i++) {
|
||||
const currentId = selectedIds[i];
|
||||
if (maps.groupsMap[currentId] !== undefined) {
|
||||
currentGroups.push(currentId);
|
||||
|
||||
badges.push((
|
||||
<EuiFlexItem grow={false} key={currentId}>
|
||||
{getBadge({ id: currentId, isGroup: true, numJobs: maps.groupsMap[currentId].length })}
|
||||
</EuiFlexItem>
|
||||
));
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Create jobId badges for jobs with no groups or with groups not selected
|
||||
for (let i = 0; i < selectedIds.length; i++) {
|
||||
const currentId = selectedIds[i];
|
||||
if (maps.groupsMap[currentId] === undefined) {
|
||||
const jobGroups = maps.jobsMap[currentId] || [];
|
||||
|
||||
if (jobGroups.some(g => currentGroups.includes(g)) === false) {
|
||||
badges.push((
|
||||
<EuiFlexItem grow={false} key={currentId}>
|
||||
{getBadge({ id: currentId })}
|
||||
</EuiFlexItem>
|
||||
));
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (showAllBarBadges || badges.length <= BADGE_LIMIT) {
|
||||
if (badges.length > BADGE_LIMIT) {
|
||||
badges.push(
|
||||
<EuiLink
|
||||
key="more-badges-bar-link"
|
||||
onClick={() => setShowAllBarBadges(!showAllBarBadges)}
|
||||
>
|
||||
<EuiText grow={false} size="xs">
|
||||
{i18n.translate('xpack.ml.jobSelector.hideBarBadges', {
|
||||
defaultMessage: 'Hide'
|
||||
})}
|
||||
</EuiText>
|
||||
</EuiLink>);
|
||||
}
|
||||
|
||||
return badges;
|
||||
} else {
|
||||
const overFlow = (badges.length - BADGE_LIMIT);
|
||||
|
||||
badges.splice(BADGE_LIMIT);
|
||||
badges.push(
|
||||
<EuiLink
|
||||
key="more-badges-bar-link"
|
||||
onClick={() => setShowAllBarBadges(!showAllBarBadges)}
|
||||
>
|
||||
<EuiText grow={false} size="xs">
|
||||
{i18n.translate('xpack.ml.jobSelector.showBarBadges', {
|
||||
defaultMessage: `And {overFlow} more`,
|
||||
values: { overFlow },
|
||||
})}
|
||||
</EuiText>
|
||||
</EuiLink>);
|
||||
|
||||
return badges;
|
||||
}
|
||||
}
|
||||
|
||||
function renderNewSelectionIdBadges() {
|
||||
const badges = [];
|
||||
|
||||
for (let i = 0; i < newSelection.length; i++) {
|
||||
if (i >= BADGE_LIMIT && showAllBadges === false) {
|
||||
break;
|
||||
}
|
||||
|
||||
badges.push(
|
||||
<EuiFlexItem grow={false} key={newSelection[i]}>
|
||||
{getBadge({
|
||||
id: newSelection[i],
|
||||
icon: true,
|
||||
removeId,
|
||||
isGroup: (maps.groupsMap[newSelection[i]] !== undefined)
|
||||
})}
|
||||
</EuiFlexItem>
|
||||
);
|
||||
}
|
||||
|
||||
if (showAllBadges === false && newSelection.length > BADGE_LIMIT) {
|
||||
badges.push(
|
||||
<EuiLink
|
||||
key="more-badges-link"
|
||||
onClick={() => setShowAllBadges(!showAllBadges)}
|
||||
>
|
||||
<EuiText grow={false} size="xs">
|
||||
{i18n.translate('xpack.ml.jobSelector.showFlyoutBadges', {
|
||||
defaultMessage: `And {overFlow} more`,
|
||||
values: { overFlow: newSelection.length - BADGE_LIMIT },
|
||||
})}
|
||||
</EuiText>
|
||||
</EuiLink>);
|
||||
} else if (showAllBadges === true && newSelection.length > BADGE_LIMIT) {
|
||||
badges.push(
|
||||
<EuiLink
|
||||
key="hide-badges-link"
|
||||
onClick={() => setShowAllBadges(!showAllBadges)}
|
||||
>
|
||||
<EuiText grow={false} size="xs">
|
||||
{i18n.translate('xpack.ml.jobSelector.hideFlyoutBadges', {
|
||||
defaultMessage: 'Hide'
|
||||
})}
|
||||
</EuiText>
|
||||
</EuiLink>);
|
||||
}
|
||||
|
||||
return badges;
|
||||
}
|
||||
|
||||
function renderJobSelectionBar() {
|
||||
return (
|
||||
<EuiFlexGroup responsive={false} gutterSize="xs" alignItems="center">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
onClick={handleJobSelectionClick}
|
||||
>
|
||||
{i18n.translate('xpack.ml.jobSelector.jobSelectionButton', {
|
||||
defaultMessage: 'Job Selection'
|
||||
})}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup wrap responsive={false} gutterSize="xs" alignItems="center">
|
||||
{renderIdBadges()}
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function renderFlyout() {
|
||||
if (isFlyoutVisible) {
|
||||
return (
|
||||
<EuiFlyout
|
||||
ref={flyoutEl}
|
||||
onClose={closeFlyout}
|
||||
aria-labelledby="Job Selection"
|
||||
size="l"
|
||||
>
|
||||
<EuiFlyoutHeader hasBorder>
|
||||
<EuiTitle size="m">
|
||||
<h2 id="flyoutTitle">
|
||||
{i18n.translate('xpack.ml.jobSelector.flyoutTitle', {
|
||||
defaultMessage: 'Job Selection'
|
||||
})}
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlyoutHeader>
|
||||
<EuiFlyoutBody className="mlJobSelectorFlyoutBody">
|
||||
<EuiFlexGroup direction="column" responsive={false}>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup wrap responsive={false} gutterSize="xs" alignItems="center">
|
||||
{renderNewSelectionIdBadges()}
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup direction="row" justifyContent="spaceBetween" responsive={false}>
|
||||
<EuiFlexItem grow={false}>
|
||||
{!singleSelection && newSelection.length > 0 &&
|
||||
<EuiButtonEmpty
|
||||
onClick={clearSelection}
|
||||
size="xs"
|
||||
>
|
||||
{i18n.translate('xpack.ml.jobSelector.clearAllFlyoutButton', {
|
||||
defaultMessage: 'Clear All'
|
||||
})}
|
||||
</EuiButtonEmpty>}
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiSwitch
|
||||
label={i18n.translate('xpack.ml.jobSelector.applyTimerangeSwitchLabel', {
|
||||
defaultMessage: 'Apply timerange'
|
||||
})}
|
||||
checked={applyTimeRange}
|
||||
onChange={toggleTimerangeSwitch}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<JobSelectorTable
|
||||
jobs={jobs}
|
||||
ganttBarWidth={ganttBarWidth}
|
||||
groupsList={groups}
|
||||
onSelection={handleNewSelection}
|
||||
selectedIds={newSelection}
|
||||
singleSelection={singleSelection}
|
||||
timeseriesOnly={timeseriesOnly}
|
||||
/>
|
||||
</EuiFlyoutBody>
|
||||
<EuiFlyoutFooter>
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
onClick={applySelection}
|
||||
fill
|
||||
isDisabled={newSelection.length === 0}
|
||||
>
|
||||
{i18n.translate('xpack.ml.jobSelector.applyFlyoutButton', {
|
||||
defaultMessage: 'Apply'
|
||||
})}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
iconType="cross"
|
||||
onClick={closeFlyout}
|
||||
>
|
||||
{i18n.translate('xpack.ml.jobSelector.closeFlyoutButton', {
|
||||
defaultMessage: 'Close'
|
||||
})}
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlyoutFooter>
|
||||
</EuiFlyout>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mlJobSelectorBar">
|
||||
{selectedIds.length > 0 && renderJobSelectionBar()}
|
||||
{renderFlyout()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
JobSelector.propTypes = {
|
||||
globalState: PropTypes.object,
|
||||
jobSelectService: PropTypes.object,
|
||||
selectedJobIds: PropTypes.array,
|
||||
singleSelection: PropTypes.string,
|
||||
timeseriesOnly: PropTypes.string
|
||||
};
|
|
@ -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.
|
||||
*/
|
||||
|
||||
/*
|
||||
* AngularJS directive wrapper for rendering Job Selector React component.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { JobSelector } from './job_selector';
|
||||
import { getSelectedJobIds } from './job_select_service_utils';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { uiModules } from 'ui/modules';
|
||||
const module = uiModules.get('apps/ml');
|
||||
|
||||
|
||||
module
|
||||
.directive('mlJobSelectorReactWrapper', function (globalState, config, mlJobSelectService) {
|
||||
function link(scope, element, attrs) {
|
||||
const { jobIds, selectedGroups } = getSelectedJobIds(globalState);
|
||||
const oldSelectedJobIds = mlJobSelectService.getValue().selection;
|
||||
|
||||
if (jobIds && !(_.isEqual(oldSelectedJobIds, jobIds))) {
|
||||
mlJobSelectService.next({ selection: jobIds, groups: selectedGroups });
|
||||
}
|
||||
|
||||
const props = {
|
||||
config,
|
||||
globalState,
|
||||
jobSelectService: mlJobSelectService,
|
||||
selectedJobIds: jobIds,
|
||||
selectedGroups,
|
||||
timeseriesOnly: attrs.timeseriesonly,
|
||||
singleSelection: attrs.singleselection
|
||||
};
|
||||
|
||||
ReactDOM.render(React.createElement(JobSelector, props),
|
||||
element[0]
|
||||
);
|
||||
|
||||
element.on('$destroy', () => {
|
||||
ReactDOM.unmountComponentAtNode(element[0]);
|
||||
scope.$destroy();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
scope: false,
|
||||
link,
|
||||
};
|
||||
})
|
||||
.service('mlJobSelectService', function (globalState) {
|
||||
const { jobIds, selectedGroups } = getSelectedJobIds(globalState);
|
||||
return new BehaviorSubject({ selection: jobIds, groups: selectedGroups, resetSelection: false });
|
||||
});
|
|
@ -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 { JobSelectorTable } from './job_selector_table';
|
|
@ -0,0 +1,249 @@
|
|||
/*
|
||||
* 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, useState, useEffect } from 'react';
|
||||
import { PropTypes } from 'prop-types';
|
||||
import { CustomSelectionTable } from '../custom_selection_table';
|
||||
import { getBadge } from '../job_selector';
|
||||
import { TimeRangeBar } from '../timerange_bar/';
|
||||
|
||||
import {
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiLoadingSpinner,
|
||||
EuiTabbedContent,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import {
|
||||
LEFT_ALIGNMENT,
|
||||
CENTER_ALIGNMENT,
|
||||
SortableProperties,
|
||||
} from '@elastic/eui/lib/services';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
|
||||
const JOB_FILTER_FIELDS = ['job_id', 'groups'];
|
||||
const GROUP_FILTER_FIELDS = ['id'];
|
||||
|
||||
export function JobSelectorTable({
|
||||
ganttBarWidth,
|
||||
groupsList,
|
||||
jobs,
|
||||
onSelection,
|
||||
selectedIds,
|
||||
singleSelection,
|
||||
timeseriesOnly
|
||||
}) {
|
||||
const [sortableProperties, setSortableProperties] = useState();
|
||||
const [currentTab, setCurrentTab] = useState('Jobs');
|
||||
|
||||
useEffect(() => {
|
||||
let sortablePropertyItems = [];
|
||||
let defaultSortProperty = 'job_id';
|
||||
|
||||
if (currentTab === 'Jobs' || singleSelection) {
|
||||
sortablePropertyItems = [
|
||||
{
|
||||
name: 'job_id',
|
||||
getValue: item => item.job_id.toLowerCase(),
|
||||
isAscending: true,
|
||||
},
|
||||
{
|
||||
name: 'groups',
|
||||
getValue: item => (item.groups ? item.groups[0].toLowerCase() : ''),
|
||||
isAscending: true,
|
||||
}
|
||||
];
|
||||
} else if (currentTab === 'Groups') {
|
||||
defaultSortProperty = 'id';
|
||||
sortablePropertyItems = [
|
||||
{
|
||||
name: 'id',
|
||||
getValue: item => item.id.toLowerCase(),
|
||||
isAscending: true,
|
||||
}
|
||||
];
|
||||
}
|
||||
const sortableProps = new SortableProperties(sortablePropertyItems, defaultSortProperty);
|
||||
|
||||
setSortableProperties(sortableProps);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [jobs, currentTab]);
|
||||
|
||||
const tabs = [{
|
||||
id: 'Jobs',
|
||||
name: i18n.translate('xpack.ml.jobSelector.jobsTab', {
|
||||
defaultMessage: 'Jobs',
|
||||
}),
|
||||
content: renderJobsTable(),
|
||||
},
|
||||
{
|
||||
id: 'Groups',
|
||||
name: i18n.translate('xpack.ml.jobSelector.groupsTab', {
|
||||
defaultMessage: 'Groups',
|
||||
}),
|
||||
content: renderGroupsTable()
|
||||
}];
|
||||
|
||||
function getGroupOptions() {
|
||||
return groupsList.map(g => ({
|
||||
value: g.id,
|
||||
view: (
|
||||
<Fragment>
|
||||
<EuiFlexGroup gutterSize="s">
|
||||
<EuiFlexItem key={g.id} grow={false}>
|
||||
{getBadge({ id: g.id, isGroup: true })}
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
{i18n.translate('xpack.ml.jobSelector.filterBar.jobGroupTitle', {
|
||||
defaultMessage: `({jobsCount, plural, one {# job} other {# jobs}})`,
|
||||
values: { jobsCount: g.jobIds.length },
|
||||
})}
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</Fragment>
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
function renderJobsTable() {
|
||||
const columns = [
|
||||
{
|
||||
id: 'checkbox',
|
||||
isCheckbox: true,
|
||||
textOnly: false,
|
||||
width: '24px',
|
||||
},
|
||||
{
|
||||
label: 'job ID',
|
||||
id: 'job_id',
|
||||
isSortable: true,
|
||||
alignment: LEFT_ALIGNMENT
|
||||
},
|
||||
{
|
||||
id: 'groups',
|
||||
label: 'groups',
|
||||
isSortable: true,
|
||||
alignment: LEFT_ALIGNMENT,
|
||||
render: ({ groups = [] }) => (
|
||||
groups.map((group) => getBadge({ id: group, isGroup: true }))
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'time range',
|
||||
id: 'timerange',
|
||||
alignment: LEFT_ALIGNMENT,
|
||||
render: ({ timeRange = {}, isRunning }) => (
|
||||
<TimeRangeBar timerange={timeRange} isRunning={isRunning} ganttBarWidth={ganttBarWidth} />
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const filters = [
|
||||
{
|
||||
type: 'field_value_selection',
|
||||
field: 'groups',
|
||||
name: i18n.translate('xpack.ml.jobSelector.filterBar.groupLabel', {
|
||||
defaultMessage: 'Group',
|
||||
}),
|
||||
loadingMessage: 'Loading...',
|
||||
noOptionsMessage: 'No groups found.',
|
||||
multiSelect: 'or',
|
||||
cache: 10000,
|
||||
options: getGroupOptions()
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<CustomSelectionTable
|
||||
columns={columns}
|
||||
filters={filters}
|
||||
filterDefaultFields={!singleSelection ? JOB_FILTER_FIELDS : undefined}
|
||||
items={jobs}
|
||||
onTableChange={(selectionFromTable) => onSelection({ selectionFromTable })}
|
||||
selectedIds={selectedIds}
|
||||
singleSelection={singleSelection}
|
||||
sortableProperties={sortableProperties}
|
||||
timeseriesOnly={timeseriesOnly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderGroupsTable() {
|
||||
const groupColumns = [
|
||||
{
|
||||
id: 'checkbox',
|
||||
isCheckbox: true,
|
||||
textOnly: false,
|
||||
width: '24px',
|
||||
},
|
||||
{
|
||||
label: 'group ID',
|
||||
id: 'id',
|
||||
isSortable: true,
|
||||
alignment: LEFT_ALIGNMENT,
|
||||
render: ({ id }) => getBadge({ id, isGroup: true })
|
||||
},
|
||||
{
|
||||
id: 'jobs in group',
|
||||
label: 'jobs in group',
|
||||
isSortable: false,
|
||||
alignment: CENTER_ALIGNMENT,
|
||||
render: ({ jobIds = [] }) => jobIds.length
|
||||
},
|
||||
{
|
||||
label: 'time range',
|
||||
id: 'timerange',
|
||||
alignment: LEFT_ALIGNMENT,
|
||||
render: ({ timeRange = {} }) => (
|
||||
<TimeRangeBar timerange={timeRange} ganttBarWidth={ganttBarWidth}/>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<CustomSelectionTable
|
||||
columns={groupColumns}
|
||||
filterDefaultFields={!singleSelection ? GROUP_FILTER_FIELDS : undefined}
|
||||
items={groupsList}
|
||||
onTableChange={(selectionFromTable) => onSelection({ selectionFromTable })}
|
||||
selectedIds={selectedIds}
|
||||
sortableProperties={sortableProperties}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderTabs() {
|
||||
return (
|
||||
<EuiTabbedContent
|
||||
size="s"
|
||||
tabs={tabs}
|
||||
initialSelectedTab={tabs[0]}
|
||||
onTabClick={(tab) => { setCurrentTab(tab.id); }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{jobs.length === 0 && <EuiLoadingSpinner size="l" />}
|
||||
{jobs.length !== 0 && singleSelection === 'true' && renderJobsTable()}
|
||||
{jobs.length !== 0 && singleSelection === undefined && renderTabs()}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
JobSelectorTable.propTypes = {
|
||||
ganttBarWidth: PropTypes.number.isRequired,
|
||||
groupsList: PropTypes.array,
|
||||
jobs: PropTypes.array,
|
||||
onSelection: PropTypes.func.isRequired,
|
||||
selectedIds: PropTypes.array.isRequired,
|
||||
singleSelection: PropTypes.string,
|
||||
timeseriesOnly: PropTypes.string
|
||||
};
|
|
@ -0,0 +1,163 @@
|
|||
/*
|
||||
* 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 { cleanup, fireEvent, render } from 'react-testing-library';
|
||||
import { JobSelectorTable } from './job_selector_table';
|
||||
|
||||
|
||||
jest.mock('../../../services/job_service', () => ({
|
||||
mlJobService: {
|
||||
getJob: jest.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
const props = {
|
||||
ganttBarWidth: 299,
|
||||
groupsList: [
|
||||
{
|
||||
id: 'logs',
|
||||
jobIds: ['bytes-by-geo-dest', 'machine-ram-by-source'],
|
||||
timeRange: {
|
||||
fromPx: 15.1,
|
||||
label: 'Apr 20th 2019, 20: 39 to Jun 20th 2019, 17: 45',
|
||||
widthPx: 283.89
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'ecommerce',
|
||||
jobIds: ['price-by-day'],
|
||||
timeRange: {
|
||||
fromPx: 1,
|
||||
label: 'Apr 17th 2019, 20:04 to May 18th 2019, 19:45',
|
||||
widthPx: 144.5
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'flights',
|
||||
jobIds: ['price-by-dest-city'],
|
||||
timeRange: {
|
||||
fromPx: 19.6,
|
||||
label: 'Apr 21st 2019, 20:00 to Jun 2nd 2019, 19:50',
|
||||
widthPx: 195.8
|
||||
}
|
||||
}
|
||||
],
|
||||
jobs: [
|
||||
{
|
||||
groups: ['logs'],
|
||||
id: 'bytes-by-geo-dest',
|
||||
isRunning: false,
|
||||
isSingleMetricViewerJob: true,
|
||||
job_id: 'bytes-by-geo-dest',
|
||||
timeRange: {
|
||||
fromPx: 12.3,
|
||||
label: 'Apr 20th 2019, 20:39 to Jun 20th 2019, 17:45',
|
||||
widthPx: 228.6
|
||||
}
|
||||
},
|
||||
{
|
||||
groups: ['logs'],
|
||||
id: 'machine-ram-by-source',
|
||||
isRunning: false,
|
||||
isSingleMetricViewerJob: true,
|
||||
job_id: 'machine-ram-by-source',
|
||||
timeRange: {
|
||||
fromPx: 10,
|
||||
label: 'Apr 20th 2019, 20:39 to Jun 20th 2019, 17:45',
|
||||
widthPx: 182.9
|
||||
}
|
||||
},
|
||||
{
|
||||
groups: ['ecommerce'],
|
||||
id: 'price-by-day',
|
||||
isRunning: false,
|
||||
isSingleMetricViewerJob: true,
|
||||
job_id: 'price-by-day',
|
||||
timeRange: {
|
||||
fromPx: 1,
|
||||
label: 'Apr 17th 2019, 20:04 to May 18th 2019, 19:45',
|
||||
widthPx: 93.1
|
||||
}
|
||||
}
|
||||
],
|
||||
onSelection: jest.fn(),
|
||||
selectedIds: ['price-by-day'],
|
||||
};
|
||||
|
||||
describe('JobSelectorTable', () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('Single Selection', () => {
|
||||
|
||||
test('Does not render tabs', () => {
|
||||
const singleSelectionProps = { ...props, singleSelection: 'true' };
|
||||
const { queryByRole } = render(<JobSelectorTable {...singleSelectionProps} />);
|
||||
const tabs = queryByRole('tab');
|
||||
expect(tabs).toBeNull();
|
||||
});
|
||||
|
||||
test('incoming selectedId is selected in the table', () => {
|
||||
const singleSelectionProps = { ...props, singleSelection: 'true' };
|
||||
const { getByTestId } = render(<JobSelectorTable {...singleSelectionProps} />);
|
||||
const radioButton = getByTestId('price-by-day-radio-button');
|
||||
expect(radioButton.firstChild.checked).toEqual(true);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('Not Single Selection', () => {
|
||||
|
||||
test('renders tabs when not singleSelection', () => {
|
||||
const { getByRole } = render(<JobSelectorTable {...props} />);
|
||||
const tabs = getByRole('tab');
|
||||
expect(tabs).toBeDefined();
|
||||
});
|
||||
|
||||
test('toggles content when tabs clicked', () => {
|
||||
// Default is Jobs tab so select Groups tab
|
||||
const { getByText } = render(<JobSelectorTable {...props} />);
|
||||
const groupsTab = getByText('Groups');
|
||||
fireEvent.click(groupsTab);
|
||||
const groupsTableHeader = getByText('jobs in group');
|
||||
expect(groupsTableHeader).toBeDefined();
|
||||
// switch back to Jobs tab
|
||||
const jobsTab = getByText('Jobs');
|
||||
fireEvent.click(jobsTab);
|
||||
const jobsTableHeader = getByText('job ID');
|
||||
expect(jobsTableHeader).toBeDefined();
|
||||
});
|
||||
|
||||
test('incoming selectedIds are checked in the table', () => {
|
||||
const { getByTestId } = render(<JobSelectorTable {...props} />);
|
||||
const checkbox = getByTestId('price-by-day-checkbox');
|
||||
expect(checkbox.checked).toEqual(true);
|
||||
});
|
||||
|
||||
test('incoming selectedIds are checked in the table when multiple ids', () => {
|
||||
const multipleSelectedIdsProps = { ...props, selectedIds: ['price-by-day', 'bytes-by-geo-dest'] };
|
||||
const { getByTestId } = render(<JobSelectorTable {...multipleSelectedIdsProps} />);
|
||||
const priceByDayCheckbox = getByTestId('price-by-day-checkbox');
|
||||
const bytesByGeoCheckbox = getByTestId('bytes-by-geo-dest-checkbox');
|
||||
const unselectedCheckbox = getByTestId('machine-ram-by-source-checkbox');
|
||||
expect(priceByDayCheckbox.checked).toEqual(true);
|
||||
expect(bytesByGeoCheckbox.checked).toEqual(true);
|
||||
expect(unselectedCheckbox.checked).toEqual(false);
|
||||
});
|
||||
|
||||
test('displays group filter dropdown button', () => {
|
||||
const { getByText } = render(<JobSelectorTable {...props} />);
|
||||
const groupDropdownButton = getByText('Group');
|
||||
expect(groupDropdownButton).toBeDefined();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
|
@ -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 { TimeRangeBar } from './timerange_bar';
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* 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 { PropTypes } from 'prop-types';
|
||||
import { EuiToolTip } from '@elastic/eui';
|
||||
|
||||
export function TimeRangeBar({
|
||||
isRunning,
|
||||
timerange,
|
||||
ganttBarWidth
|
||||
}) {
|
||||
const style = {
|
||||
width: timerange.widthPx,
|
||||
marginLeft: timerange.fromPx
|
||||
};
|
||||
|
||||
const className =
|
||||
`mlJobSelector__ganttBar${isRunning ? ' mlJobSelector__ganttBarRunning' : ''}`;
|
||||
|
||||
return (
|
||||
<EuiToolTip
|
||||
position="top"
|
||||
content={timerange.label}
|
||||
>
|
||||
<Fragment>
|
||||
<div className="mlJobSelector__ganttBarBackEdge">
|
||||
<div className="mlJobSelector__ganttBarDashed" style={{ width: `${ganttBarWidth}px` }}/>
|
||||
</div>
|
||||
<div style={style} className={className}/>
|
||||
</Fragment>
|
||||
</EuiToolTip>
|
||||
);
|
||||
}
|
||||
|
||||
TimeRangeBar.propTypes = {
|
||||
ganttBarWidth: PropTypes.number,
|
||||
isRunning: PropTypes.bool,
|
||||
timerange: PropTypes.shape({
|
||||
widthPx: PropTypes.number,
|
||||
label: PropTypes.string,
|
||||
fromPx: PropTypes.number,
|
||||
})
|
||||
};
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* 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 { mount } from 'enzyme';
|
||||
import React from 'react';
|
||||
import { TimeRangeBar } from './timerange_bar';
|
||||
|
||||
describe('TimeRangeBar', () => {
|
||||
|
||||
const timeRange = {
|
||||
fromPx: 1,
|
||||
label: 'Oct 27th 2018, 20:00 to Nov 11th 2018, 08:31',
|
||||
widthPx: 40.38226874737488
|
||||
};
|
||||
|
||||
test('Renders gantt bar when isRunning is false', () => {
|
||||
const wrapper = mount(
|
||||
<TimeRangeBar timerange={timeRange} />
|
||||
);
|
||||
const ganttBar = wrapper.find('.mlJobSelector__ganttBar');
|
||||
|
||||
expect(
|
||||
ganttBar.containsMatchingElement(
|
||||
<div className="mlJobSelector__ganttBar" />
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Renders running animation bar when isRunning is true', () => {
|
||||
const wrapper = mount(
|
||||
<TimeRangeBar timerange={timeRange} isRunning={true} />
|
||||
);
|
||||
const runningBar = wrapper.find('.mlJobSelector__ganttBarRunning');
|
||||
|
||||
expect(runningBar.length).toEqual(1);
|
||||
});
|
||||
|
||||
});
|
|
@ -1,9 +1,8 @@
|
|||
<ml-nav-menu name="explorer"></ml-nav-menu>
|
||||
<ml-chart-tooltip></ml-chart-tooltip>
|
||||
<div class="ml-explorer" ng-controller="MlExplorerController">
|
||||
<navbar ng-show="jobs.length > 0 && chrome.getVisible()">
|
||||
<job-select-button></job-select-button>
|
||||
</navbar>
|
||||
|
||||
<ml-job-selector-react-wrapper />
|
||||
|
||||
<ml-explorer-react-wrapper />
|
||||
</div>
|
||||
|
|
|
@ -144,7 +144,6 @@ export const Explorer = injectI18n(injectObservablesAsProps(
|
|||
static propTypes = {
|
||||
appStateHandler: PropTypes.func.isRequired,
|
||||
dateFormatTz: PropTypes.string.isRequired,
|
||||
mlJobSelectService: PropTypes.object.isRequired,
|
||||
MlTimeBuckets: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
|
|
|
@ -17,7 +17,6 @@ import moment from 'moment-timezone';
|
|||
import '../components/annotations/annotations_table';
|
||||
import '../components/anomalies_table';
|
||||
import '../components/controls';
|
||||
import '../components/job_select_list';
|
||||
|
||||
import template from './explorer.html';
|
||||
|
||||
|
@ -30,7 +29,6 @@ import { checkFullLicense } from '../license/check_license';
|
|||
import { checkGetJobsPrivilege } from '../privilege/check_privilege';
|
||||
import { getIndexPatterns, loadIndexPatterns } from '../util/index_utils';
|
||||
import { IntervalHelperProvider } from 'plugins/ml/util/ml_time_buckets';
|
||||
import { JobSelectServiceProvider } from '../components/job_select_list/job_select_service';
|
||||
import { explorer$ } from './explorer_dashboard_service';
|
||||
import { mlFieldFormatService } from 'plugins/ml/services/field_format_service';
|
||||
import { mlJobService } from '../services/job_service';
|
||||
|
@ -47,6 +45,7 @@ uiRoutes
|
|||
CheckLicense: checkFullLicense,
|
||||
privileges: checkGetJobsPrivilege,
|
||||
indexPatterns: loadIndexPatterns,
|
||||
jobs: mlJobService.loadJobsWrapper
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -62,6 +61,7 @@ module.controller('MlExplorerController', function (
|
|||
Private,
|
||||
config,
|
||||
) {
|
||||
|
||||
// Even if they are not used directly anymore in this controller but via imports
|
||||
// in React components, because of the use of AppState and its dependency on angularjs
|
||||
// these services still need to be required here to properly initialize.
|
||||
|
@ -70,6 +70,8 @@ module.controller('MlExplorerController', function (
|
|||
$injector.get('mlSelectLimitService');
|
||||
$injector.get('mlSelectSeverityService');
|
||||
|
||||
const mlJobSelectService = $injector.get('mlJobSelectService');
|
||||
|
||||
// $scope should only contain what's actually still necessary for the angular part.
|
||||
// For the moment that's the job selector and the (hidden) filter bar.
|
||||
$scope.jobs = [];
|
||||
|
@ -80,7 +82,6 @@ module.controller('MlExplorerController', function (
|
|||
const tzConfig = config.get('dateFormat:tz');
|
||||
$scope.dateFormatTz = (tzConfig !== 'Browser') ? tzConfig : moment.tz.guess();
|
||||
|
||||
$scope.mlJobSelectService = Private(JobSelectServiceProvider);
|
||||
$scope.MlTimeBuckets = Private(IntervalHelperProvider);
|
||||
|
||||
let resizeTimeout = null;
|
||||
|
@ -143,55 +144,50 @@ module.controller('MlExplorerController', function (
|
|||
// <ml-explorer-react-wrapper /> and <Explorer /> have been initialized.
|
||||
function loadJobsListener({ action }) {
|
||||
if (action === EXPLORER_ACTION.LOAD_JOBS) {
|
||||
mlJobService.loadJobs()
|
||||
.then((resp) => {
|
||||
if (resp.jobs.length > 0) {
|
||||
// Select any jobs set in the global state (i.e. passed in the URL).
|
||||
const selectedJobIds = $scope.mlJobSelectService.getSelectedJobIds(true);
|
||||
let selectedCells;
|
||||
let filterData = {};
|
||||
// Jobs load via route resolver
|
||||
if (mlJobService.jobs.length > 0) {
|
||||
// Select any jobs set in the global state (i.e. passed in the URL).
|
||||
const selectedJobIds = mlJobSelectService.getValue().selection;
|
||||
let selectedCells;
|
||||
let filterData = {};
|
||||
|
||||
// keep swimlane selection, restore selectedCells from AppState
|
||||
if ($scope.appState.mlExplorerSwimlane.selectedType !== undefined) {
|
||||
selectedCells = {
|
||||
type: $scope.appState.mlExplorerSwimlane.selectedType,
|
||||
lanes: $scope.appState.mlExplorerSwimlane.selectedLanes,
|
||||
times: $scope.appState.mlExplorerSwimlane.selectedTimes,
|
||||
showTopFieldValues: $scope.appState.mlExplorerSwimlane.showTopFieldValues,
|
||||
viewByFieldName: $scope.appState.mlExplorerSwimlane.viewByFieldName,
|
||||
};
|
||||
}
|
||||
// keep swimlane selection, restore selectedCells from AppState
|
||||
if ($scope.appState.mlExplorerSwimlane.selectedType !== undefined) {
|
||||
selectedCells = {
|
||||
type: $scope.appState.mlExplorerSwimlane.selectedType,
|
||||
lanes: $scope.appState.mlExplorerSwimlane.selectedLanes,
|
||||
times: $scope.appState.mlExplorerSwimlane.selectedTimes,
|
||||
showTopFieldValues: $scope.appState.mlExplorerSwimlane.showTopFieldValues,
|
||||
viewByFieldName: $scope.appState.mlExplorerSwimlane.viewByFieldName,
|
||||
};
|
||||
}
|
||||
|
||||
// keep influencers filter selection, restore from AppState
|
||||
if ($scope.appState.mlExplorerFilter.influencersFilterQuery !== undefined) {
|
||||
filterData = {
|
||||
influencersFilterQuery: $scope.appState.mlExplorerFilter.influencersFilterQuery,
|
||||
filterActive: $scope.appState.mlExplorerFilter.filterActive,
|
||||
filteredFields: $scope.appState.mlExplorerFilter.filteredFields,
|
||||
queryString: $scope.appState.mlExplorerFilter.queryString,
|
||||
};
|
||||
}
|
||||
// keep influencers filter selection, restore from AppState
|
||||
if ($scope.appState.mlExplorerFilter.influencersFilterQuery !== undefined) {
|
||||
filterData = {
|
||||
influencersFilterQuery: $scope.appState.mlExplorerFilter.influencersFilterQuery,
|
||||
filterActive: $scope.appState.mlExplorerFilter.filterActive,
|
||||
filteredFields: $scope.appState.mlExplorerFilter.filteredFields,
|
||||
queryString: $scope.appState.mlExplorerFilter.queryString,
|
||||
};
|
||||
}
|
||||
|
||||
jobSelectionUpdate(EXPLORER_ACTION.INITIALIZE, {
|
||||
filterData,
|
||||
fullJobs: resp.jobs,
|
||||
selectedCells,
|
||||
selectedJobIds,
|
||||
swimlaneViewByFieldName: $scope.appState.mlExplorerSwimlane.viewByFieldName,
|
||||
});
|
||||
} else {
|
||||
explorer$.next({
|
||||
action: EXPLORER_ACTION.RELOAD,
|
||||
payload: {
|
||||
loading: false,
|
||||
noJobsFound: true,
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((resp) => {
|
||||
console.log('Explorer - error getting job info from elasticsearch:', resp);
|
||||
jobSelectionUpdate(EXPLORER_ACTION.INITIALIZE, {
|
||||
filterData,
|
||||
fullJobs: mlJobService.jobs,
|
||||
selectedCells,
|
||||
selectedJobIds,
|
||||
swimlaneViewByFieldName: $scope.appState.mlExplorerSwimlane.viewByFieldName,
|
||||
});
|
||||
} else {
|
||||
explorer$.next({
|
||||
action: EXPLORER_ACTION.RELOAD,
|
||||
payload: {
|
||||
loading: false,
|
||||
noJobsFound: true,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -199,9 +195,12 @@ module.controller('MlExplorerController', function (
|
|||
|
||||
// Listen for changes to job selection.
|
||||
$scope.jobSelectionUpdateInProgress = false;
|
||||
$scope.mlJobSelectService.listenJobSelectionChange($scope, (event, selectedJobIds) => {
|
||||
$scope.jobSelectionUpdateInProgress = true;
|
||||
jobSelectionUpdate(EXPLORER_ACTION.JOB_SELECTION_CHANGE, { fullJobs: mlJobService.jobs, selectedJobIds });
|
||||
|
||||
mlJobSelectService.subscribe(({ selection }) => {
|
||||
if (selection !== undefined) {
|
||||
$scope.jobSelectionUpdateInProgress = true;
|
||||
jobSelectionUpdate(EXPLORER_ACTION.JOB_SELECTION_CHANGE, { fullJobs: mlJobService.jobs, selectedJobIds: selection });
|
||||
}
|
||||
});
|
||||
|
||||
// Refresh all the data when the time range is altered.
|
||||
|
|
|
@ -11,4 +11,4 @@ import 'plugins/ml/explorer/explorer_dashboard_service';
|
|||
import 'plugins/ml/explorer/explorer_react_wrapper_directive';
|
||||
import 'plugins/ml/explorer/explorer_charts';
|
||||
import 'plugins/ml/explorer/select_limit';
|
||||
import 'plugins/ml/components/job_select_list';
|
||||
import 'plugins/ml/components/job_selector';
|
||||
|
|
|
@ -40,8 +40,7 @@
|
|||
@import 'components/form_label/index';
|
||||
@import 'components/influencers_list/index';
|
||||
@import 'components/items_grid/index';
|
||||
@import 'components/job_group_select/index'; // SASSTODO: This file does some dangerous overwrites
|
||||
@import 'components/job_select_list/index'; // SASSTODO: This file does EXTREMELY DANGEROUS overwrites
|
||||
@import 'components/job_selector/index'; // TODO: remove above two once react conversion of job selector is done
|
||||
@import 'components/json_tooltip/index'; // SASSTODO: This file overwrites EUI directly
|
||||
@import 'components/loading_indicator/index'; // SASSTODO: This component should be replaced with EuiLoadingSpinner
|
||||
@import 'components/messagebar/index';
|
||||
|
|
|
@ -5,29 +5,11 @@
|
|||
*/
|
||||
|
||||
|
||||
import { stringHash } from '../../../../../common/util/string_utils';
|
||||
import { tabColor } from '../../../../../common/util/group_color_utils';
|
||||
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
// This should import the colors directly from EUI's palette service rather than be hard coded
|
||||
const COLORS = [
|
||||
'#00B3A4', // euiColorVis0
|
||||
'#3185FC', // euiColorVis1
|
||||
'#DB1374', // euiColorVis2
|
||||
'#490092', // euiColorVis3
|
||||
// '#FEB6DB', // euiColorVis4 light pink, too hard to read with white text
|
||||
'#E6C220', // euiColorVis5
|
||||
'#BFA180', // euiColorVis6
|
||||
'#F98510', // euiColorVis7
|
||||
'#461A0A', // euiColorVis8
|
||||
'#920000', // euiColorVis9
|
||||
|
||||
'#666666', // euiColorDarkShade
|
||||
'#0079A5', // euiColorPrimary
|
||||
];
|
||||
|
||||
const colorMap = {};
|
||||
|
||||
export function JobGroup({ name }) {
|
||||
return (
|
||||
|
@ -42,16 +24,3 @@ export function JobGroup({ name }) {
|
|||
JobGroup.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
// to ensure the same color is always used for a group name
|
||||
// the color choice is based on a hash of the group name
|
||||
function tabColor(name) {
|
||||
if (colorMap[name] === undefined) {
|
||||
const n = stringHash(name);
|
||||
const color = COLORS[(n % COLORS.length)];
|
||||
colorMap[name] = color;
|
||||
return color;
|
||||
} else {
|
||||
return colorMap[name];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -154,6 +154,18 @@ class JobService {
|
|||
});
|
||||
}
|
||||
|
||||
loadJobsWrapper = () => {
|
||||
return this.loadJobs()
|
||||
.then(function (resp) {
|
||||
return resp;
|
||||
})
|
||||
.catch(function (error) {
|
||||
console.log('Error loading jobs in route resolve.', error);
|
||||
// Always resolve to ensure tab still works.
|
||||
Promise.resolve([]);
|
||||
});
|
||||
}
|
||||
|
||||
refreshJob(jobId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
ml.getJobs({ jobId })
|
||||
|
|
|
@ -22,6 +22,16 @@ export const jobs = {
|
|||
});
|
||||
},
|
||||
|
||||
jobsWithTimerange(dateFormatTz) {
|
||||
return http({
|
||||
url: `${basePath}/jobs/jobs_with_timerange`,
|
||||
method: 'POST',
|
||||
data: {
|
||||
dateFormatTz
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
jobs(jobIds) {
|
||||
return http({
|
||||
url: `${basePath}/jobs/jobs`,
|
||||
|
|
|
@ -8,5 +8,5 @@ import './components/forecasting_modal';
|
|||
import './components/timeseries_chart/timeseries_chart_directive';
|
||||
import './timeseriesexplorer_controller.js';
|
||||
import './timeseries_search_service.js';
|
||||
import 'plugins/ml/components/job_select_list';
|
||||
import 'plugins/ml/components/job_selector';
|
||||
import 'plugins/ml/components/chart_tooltip';
|
||||
|
|
|
@ -1,12 +1,8 @@
|
|||
<ml-nav-menu name="timeseriesexplorer"></ml-nav-menu>
|
||||
<ml-chart-tooltip></ml-chart-tooltip>
|
||||
<div class="ml-time-series-explorer" ng-controller="MlTimeSeriesExplorerController">
|
||||
<navbar ng-show="jobs.length > 0 && chrome.getVisible()">
|
||||
<job-select-button
|
||||
timeseriesonly="true"
|
||||
single-selection="true">
|
||||
</job-select-button>
|
||||
</navbar>
|
||||
|
||||
<ml-job-selector-react-wrapper timeseriesonly="true" singleselection="true" />
|
||||
|
||||
<div class="no-results-container" ng-if="jobs.length === 0 && loading === false">
|
||||
<div class="no-results">
|
||||
|
|
|
@ -49,7 +49,6 @@ import { getMlNodeCount } from 'plugins/ml/ml_nodes_check/check_ml_nodes';
|
|||
import { ml } from 'plugins/ml/services/ml_api_service';
|
||||
import { mlJobService } from 'plugins/ml/services/job_service';
|
||||
import { mlFieldFormatService } from 'plugins/ml/services/field_format_service';
|
||||
import { JobSelectServiceProvider } from 'plugins/ml/components/job_select_list/job_select_service';
|
||||
import { mlForecastService } from 'plugins/ml/services/forecast_service';
|
||||
import { mlTimeSeriesSearchService } from 'plugins/ml/timeseriesexplorer/timeseries_search_service';
|
||||
import {
|
||||
|
@ -59,6 +58,7 @@ import {
|
|||
import { annotationsRefresh$ } from '../services/annotations_service';
|
||||
import { interval$ } from '../components/controls/select_interval/select_interval';
|
||||
import { severity$ } from '../components/controls/select_severity/select_severity';
|
||||
import { setGlobalState, getSelectedJobIds } from '../components/job_selector/job_select_service_utils';
|
||||
|
||||
|
||||
import chrome from 'ui/chrome';
|
||||
|
@ -73,6 +73,7 @@ uiRoutes
|
|||
privileges: checkGetJobsPrivilege,
|
||||
indexPatterns: loadIndexPatterns,
|
||||
mlNodeCount: getMlNodeCount,
|
||||
jobs: mlJobService.loadJobsWrapper
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -90,6 +91,8 @@ module.controller('MlTimeSeriesExplorerController', function (
|
|||
|
||||
$injector.get('mlSelectIntervalService');
|
||||
$injector.get('mlSelectSeverityService');
|
||||
const globalState = $injector.get('globalState');
|
||||
const mlJobSelectService = $injector.get('mlJobSelectService');
|
||||
|
||||
$scope.timeFieldName = 'timestamp';
|
||||
timefilter.enableTimeRangeSelector();
|
||||
|
@ -98,7 +101,6 @@ module.controller('MlTimeSeriesExplorerController', function (
|
|||
const CHARTS_POINT_TARGET = 500;
|
||||
const MAX_SCHEDULED_EVENTS = 10; // Max number of scheduled events displayed per bucket.
|
||||
const TimeBuckets = Private(IntervalHelperProvider);
|
||||
const mlJobSelectService = Private(JobSelectServiceProvider);
|
||||
|
||||
$scope.jobPickerSelections = [];
|
||||
$scope.selectedJob;
|
||||
|
@ -136,91 +138,90 @@ module.controller('MlTimeSeriesExplorerController', function (
|
|||
|
||||
$scope.jobs = [];
|
||||
|
||||
// Load the job info needed by the visualization, then do the first load.
|
||||
mlJobService.loadJobs()
|
||||
.then((resp) => {
|
||||
// Get the job info needed by the visualization, then do the first load.
|
||||
if (mlJobService.jobs.length > 0) {
|
||||
$scope.jobs = createTimeSeriesJobData(mlJobService.jobs);
|
||||
const timeSeriesJobIds = $scope.jobs.map(j => j.id);
|
||||
|
||||
if (resp.jobs.length > 0) {
|
||||
$scope.jobs = createTimeSeriesJobData(resp.jobs);
|
||||
const timeSeriesJobIds = $scope.jobs.map(j => j.id);
|
||||
// Select any jobs set in the global state (i.e. passed in the URL).
|
||||
let { jobIds: selectedJobIds } = getSelectedJobIds(globalState);
|
||||
|
||||
// Select any jobs set in the global state (i.e. passed in the URL).
|
||||
let selectedJobIds = mlJobSelectService.getSelectedJobIds(true);
|
||||
|
||||
// Check if any of the jobs set in the URL are not time series jobs
|
||||
// (e.g. if switching to this view straight from the Anomaly Explorer).
|
||||
const invalidIds = _.difference(selectedJobIds, timeSeriesJobIds);
|
||||
selectedJobIds = _.without(selectedJobIds, ...invalidIds);
|
||||
if (invalidIds.length > 0) {
|
||||
let warningText = i18n('xpack.ml.timeSeriesExplorer.canNotViewRequestedJobsWarningMessage', {
|
||||
defaultMessage: `You can't view requested {invalidIdsCount, plural, one {job} other {jobs}} {invalidIds} in this dashboard`,
|
||||
values: {
|
||||
invalidIdsCount: invalidIds.length,
|
||||
invalidIds
|
||||
}
|
||||
});
|
||||
if (selectedJobIds.length === 0 && timeSeriesJobIds.length > 0) {
|
||||
warningText += i18n('xpack.ml.timeSeriesExplorer.autoSelectingFirstJobText', {
|
||||
defaultMessage: ', auto selecting first job'
|
||||
});
|
||||
}
|
||||
toastNotifications.addWarning(warningText);
|
||||
// Check if any of the jobs set in the URL are not time series jobs
|
||||
// (e.g. if switching to this view straight from the Anomaly Explorer).
|
||||
const invalidIds = _.difference(selectedJobIds, timeSeriesJobIds);
|
||||
selectedJobIds = _.without(selectedJobIds, ...invalidIds);
|
||||
if (invalidIds.length > 0) {
|
||||
let warningText = i18n('xpack.ml.timeSeriesExplorer.canNotViewRequestedJobsWarningMessage', {
|
||||
defaultMessage: `You can't view requested {invalidIdsCount, plural, one {job} other {jobs}} {invalidIds} in this dashboard`,
|
||||
values: {
|
||||
invalidIdsCount: invalidIds.length,
|
||||
invalidIds
|
||||
}
|
||||
});
|
||||
if (selectedJobIds.length === 0 && timeSeriesJobIds.length > 0) {
|
||||
warningText += i18n('xpack.ml.timeSeriesExplorer.autoSelectingFirstJobText', {
|
||||
defaultMessage: ', auto selecting first job'
|
||||
});
|
||||
}
|
||||
toastNotifications.addWarning(warningText);
|
||||
}
|
||||
|
||||
if (selectedJobIds.length > 1 || mlJobSelectService.groupIds.length) {
|
||||
// if more than one job or a group has been loaded from the URL
|
||||
if (selectedJobIds.length > 1) {
|
||||
// if more than one job, select the first job from the selection.
|
||||
toastNotifications.addWarning(
|
||||
i18n('xpack.ml.timeSeriesExplorer.youCanViewOneJobAtTimeWarningMessage', {
|
||||
defaultMessage: 'You can only view one job at a time in this dashboard'
|
||||
})
|
||||
);
|
||||
mlJobSelectService.setJobIds([selectedJobIds[0]]);
|
||||
} else {
|
||||
// if a group has been loaded
|
||||
if (selectedJobIds.length > 0) {
|
||||
// if the group contains valid jobs, select the first
|
||||
toastNotifications.addWarning(
|
||||
i18n('xpack.ml.timeSeriesExplorer.youCanViewOneJobAtTimeWarningMessage', {
|
||||
defaultMessage: 'You can only view one job at a time in this dashboard'
|
||||
})
|
||||
);
|
||||
mlJobSelectService.setJobIds([selectedJobIds[0]]);
|
||||
} else if ($scope.jobs.length > 0) {
|
||||
// if there are no valid jobs in the group but there are valid jobs
|
||||
// in the list of all jobs, select the first
|
||||
mlJobSelectService.setJobIds([$scope.jobs[0].id]);
|
||||
} else {
|
||||
// if there are no valid jobs left.
|
||||
$scope.loading = false;
|
||||
}
|
||||
}
|
||||
} else if (invalidIds.length > 0 && selectedJobIds.length > 0) {
|
||||
// if some ids have been filtered out because they were invalid.
|
||||
// refresh the URL with the first valid id
|
||||
mlJobSelectService.setJobIds([selectedJobIds[0]]);
|
||||
} else if (selectedJobIds.length > 0) {
|
||||
// normal behavior. a job ID has been loaded from the URL
|
||||
loadForJobId(selectedJobIds[0]);
|
||||
} else {
|
||||
if (selectedJobIds.length === 0 && $scope.jobs.length > 0) {
|
||||
// no jobs were loaded from the URL, so add the first job
|
||||
// from the full jobs list.
|
||||
mlJobSelectService.setJobIds([$scope.jobs[0].id]);
|
||||
} else {
|
||||
// Jobs exist, but no time series jobs.
|
||||
$scope.loading = false;
|
||||
}
|
||||
}
|
||||
if (selectedJobIds.length > 1) {
|
||||
// if more than one job or a group has been loaded from the URL
|
||||
if (selectedJobIds.length > 1) {
|
||||
// if more than one job, select the first job from the selection.
|
||||
toastNotifications.addWarning(
|
||||
i18n('xpack.ml.timeSeriesExplorer.youCanViewOneJobAtTimeWarningMessage', {
|
||||
defaultMessage: 'You can only view one job at a time in this dashboard'
|
||||
})
|
||||
);
|
||||
setGlobalState(globalState, [selectedJobIds[0]]);
|
||||
mlJobSelectService.next({ selection: [selectedJobIds[0]], resetSelection: true });
|
||||
} else {
|
||||
// if a group has been loaded
|
||||
if (selectedJobIds.length > 0) {
|
||||
// if the group contains valid jobs, select the first
|
||||
toastNotifications.addWarning(
|
||||
i18n('xpack.ml.timeSeriesExplorer.youCanViewOneJobAtTimeWarningMessage', {
|
||||
defaultMessage: 'You can only view one job at a time in this dashboard'
|
||||
})
|
||||
);
|
||||
setGlobalState(globalState, [selectedJobIds[0]]);
|
||||
mlJobSelectService.next({ selection: [selectedJobIds[0]], resetSelection: true });
|
||||
} else if ($scope.jobs.length > 0) {
|
||||
// if there are no valid jobs in the group but there are valid jobs
|
||||
// in the list of all jobs, select the first
|
||||
setGlobalState(globalState, [$scope.jobs[0].id]);
|
||||
mlJobSelectService.next({ selection: [$scope.jobs[0].id], resetSelection: true });
|
||||
} else {
|
||||
// if there are no valid jobs left.
|
||||
$scope.loading = false;
|
||||
}
|
||||
}
|
||||
} else if (invalidIds.length > 0 && selectedJobIds.length > 0) {
|
||||
// if some ids have been filtered out because they were invalid.
|
||||
// refresh the URL with the first valid id
|
||||
setGlobalState(globalState, [selectedJobIds[0]]);
|
||||
mlJobSelectService.next({ selection: [selectedJobIds[0]], resetSelection: true });
|
||||
} else if (selectedJobIds.length > 0) {
|
||||
// normal behavior. a job ID has been loaded from the URL
|
||||
loadForJobId(selectedJobIds[0]);
|
||||
} else {
|
||||
if (selectedJobIds.length === 0 && $scope.jobs.length > 0) {
|
||||
// no jobs were loaded from the URL, so add the first job
|
||||
// from the full jobs list.
|
||||
setGlobalState(globalState, [$scope.jobs[0].id]);
|
||||
mlJobSelectService.next({ selection: [$scope.jobs[0].id], resetSelection: true });
|
||||
} else {
|
||||
// Jobs exist, but no time series jobs.
|
||||
$scope.loading = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$scope.loading = false;
|
||||
}
|
||||
|
||||
$scope.$applyAsync();
|
||||
}).catch((resp) => {
|
||||
console.log('Time series explorer - error getting job info from elasticsearch:', resp);
|
||||
});
|
||||
$scope.$applyAsync();
|
||||
};
|
||||
|
||||
$scope.refresh = function () {
|
||||
|
@ -688,28 +689,28 @@ module.controller('MlTimeSeriesExplorerController', function (
|
|||
const intervalSub = interval$.subscribe(tableControlsListener);
|
||||
const severitySub = severity$.subscribe(tableControlsListener);
|
||||
const annotationsRefreshSub = annotationsRefresh$.subscribe($scope.refresh);
|
||||
|
||||
$scope.$on('$destroy', () => {
|
||||
refreshWatcher.cancel();
|
||||
intervalSub.unsubscribe();
|
||||
severitySub.unsubscribe();
|
||||
annotationsRefreshSub.unsubscribe();
|
||||
});
|
||||
|
||||
// Listen for changes to job selection.
|
||||
mlJobSelectService.listenJobSelectionChange($scope, (event, selections) => {
|
||||
const jobSelectServiceSub = mlJobSelectService.subscribe(({ selection }) => {
|
||||
// Clear the detectorIndex, entities and forecast info.
|
||||
if (selections.length > 0) {
|
||||
if (selection.length > 0 && $scope.appState !== undefined) {
|
||||
delete $scope.appState.mlTimeSeriesExplorer.detectorIndex;
|
||||
delete $scope.appState.mlTimeSeriesExplorer.entities;
|
||||
delete $scope.appState.mlTimeSeriesExplorer.forecastId;
|
||||
$scope.appState.save();
|
||||
|
||||
$scope.showForecastCheckbox = false;
|
||||
loadForJobId(selections[0]);
|
||||
loadForJobId(selection[0]);
|
||||
}
|
||||
});
|
||||
|
||||
$scope.$on('$destroy', () => {
|
||||
refreshWatcher.cancel();
|
||||
intervalSub.unsubscribe();
|
||||
severitySub.unsubscribe();
|
||||
annotationsRefreshSub.unsubscribe();
|
||||
jobSelectServiceSub.unsubscribe();
|
||||
});
|
||||
|
||||
$scope.$on('contextChartSelected', function (event, selection) {
|
||||
// Save state of zoom (adds to URL) if it is different to the default.
|
||||
if (($scope.contextChartData === undefined || $scope.contextChartData.length === 0) &&
|
||||
|
|
|
@ -140,6 +140,35 @@ export function jobsProvider(callWithRequest) {
|
|||
return jobs;
|
||||
}
|
||||
|
||||
async function jobsWithTimerange() {
|
||||
const fullJobsList = await createFullJobsList();
|
||||
const jobsMap = {};
|
||||
|
||||
const jobs = fullJobsList.map((job) => {
|
||||
jobsMap[job.job_id] = job.groups || [];
|
||||
const hasDatafeed = (typeof job.datafeed_config === 'object' && Object.keys(job.datafeed_config).length > 0);
|
||||
const timeRange = {};
|
||||
|
||||
if (job.data_counts !== undefined) {
|
||||
timeRange.to = job.data_counts.latest_record_timestamp;
|
||||
timeRange.from = job.data_counts.earliest_record_timestamp;
|
||||
}
|
||||
|
||||
const tempJob = {
|
||||
id: job.job_id,
|
||||
job_id: job.job_id,
|
||||
groups: (Array.isArray(job.groups) ? job.groups.sort() : []),
|
||||
isRunning: (hasDatafeed && job.datafeed_config.state === 'started'),
|
||||
isSingleMetricViewerJob: isTimeSeriesViewJob(job),
|
||||
timeRange
|
||||
};
|
||||
|
||||
return tempJob;
|
||||
});
|
||||
|
||||
return { jobs, jobsMap };
|
||||
}
|
||||
|
||||
async function createFullJobsList(jobIds = []) {
|
||||
const [ JOBS, JOB_STATS, DATAFEEDS, DATAFEED_STATS, CALENDARS ] = [0, 1, 2, 3, 4];
|
||||
|
||||
|
@ -298,6 +327,7 @@ export function jobsProvider(callWithRequest) {
|
|||
deleteJobs,
|
||||
closeJobs,
|
||||
jobsSummary,
|
||||
jobsWithTimerange,
|
||||
createFullJobsList,
|
||||
deletingJobTasks,
|
||||
};
|
||||
|
|
|
@ -90,6 +90,23 @@ export function jobServiceRoutes(server, commonRouteConfig) {
|
|||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: 'POST',
|
||||
path: '/api/ml/jobs/jobs_with_timerange',
|
||||
handler(request) {
|
||||
const callWithRequest = callWithRequestFactory(server, request);
|
||||
const { jobsWithTimerange } = jobServiceProvider(callWithRequest);
|
||||
const { dateFormatTz } = request.payload;
|
||||
return jobsWithTimerange(dateFormatTz)
|
||||
.catch(resp => {
|
||||
wrapError(resp);
|
||||
});
|
||||
},
|
||||
config: {
|
||||
...commonRouteConfig
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: 'POST',
|
||||
path: '/api/ml/jobs/jobs',
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue