[ML] Job Selector conversion to React (#35638)

* 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:
Melissa Alvarez 2019-05-07 11:42:34 -04:00 committed by GitHub
parent f036bcf0f0
commit 1cc0fa37f6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 2020 additions and 187 deletions

View 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];
}
}

View file

@ -0,0 +1 @@
@import 'job_selector';

View file

@ -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;
}

View file

@ -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
};

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 { CustomSelectionTable } from './custom_selection_table';

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 './job_selector_react_wrapper_directive';

View file

@ -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;
}

View 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
};

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.
*/
/*
* 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 });
});

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 { JobSelectorTable } from './job_selector_table';

View file

@ -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
};

View file

@ -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();
});
});
});

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 { TimeRangeBar } from './timerange_bar';

View file

@ -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,
})
};

View file

@ -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);
});
});

View file

@ -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>

View file

@ -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,
};

View file

@ -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.

View file

@ -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';

View file

@ -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';

View file

@ -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];
}
}

View file

@ -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 })

View file

@ -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`,

View file

@ -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';

View file

@ -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">

View file

@ -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) &&

View file

@ -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,
};

View file

@ -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',