From 5d5c431ab7d843a1cbc7a4a04aa26141adf62e10 Mon Sep 17 00:00:00 2001 From: CJ Cenizal Date: Thu, 23 Aug 2018 15:50:22 -0700 Subject: [PATCH] Add start, stop, and delete actions to rollup jobs table and detail panel (#22235) --- .../rollup/common/constants/crud_app.js | 2 +- x-pack/plugins/rollup/public/crud_app/app.js | 2 +- .../plugins/rollup/public/crud_app/index.js | 15 +- .../crud_app/sections/components/index.js | 7 + .../confirm_delete_modal.js | 114 +++++++++ .../confirm_delete_modal/index.js | 7 + .../components/job_action_menu/index.js | 7 + .../job_action_menu.container.js | 30 +++ .../job_action_menu/job_action_menu.js | 233 ++++++++++++++++++ .../detail_panel/detail_panel.container.js | 4 +- .../job_list/detail_panel/detail_panel.js | 84 +++++-- .../crud_app/sections/job_list/job_list.js | 4 +- .../job_list/job_table/job_table.container.js | 10 +- .../sections/job_list/job_table/job_table.js | 220 ++++++++++++++--- .../rollup/public/crud_app/services/api.js | 15 ++ .../crud_app/services/flatten_panel_tree.js | 20 ++ .../rollup/public/crud_app/services/index.js | 7 +- .../crud_app/store/actions/delete_jobs.js | 30 +++ .../public/crud_app/store/actions/index.js | 15 ++ .../crud_app/store/actions/start_jobs.js | 22 ++ .../crud_app/store/actions/stop_jobs.js | 22 ++ .../crud_app/store/reducers/detail_panel.js | 4 +- .../public/crud_app/store/reducers/jobs.js | 1 - .../public/crud_app/store/selectors/index.js | 10 +- .../server/client/elasticsearch_rollup.js | 42 ++++ .../plugins/rollup/server/routes/api/jobs.js | 63 ++++- 26 files changed, 910 insertions(+), 80 deletions(-) create mode 100644 x-pack/plugins/rollup/public/crud_app/sections/components/index.js create mode 100644 x-pack/plugins/rollup/public/crud_app/sections/components/job_action_menu/confirm_delete_modal/confirm_delete_modal.js create mode 100644 x-pack/plugins/rollup/public/crud_app/sections/components/job_action_menu/confirm_delete_modal/index.js create mode 100644 x-pack/plugins/rollup/public/crud_app/sections/components/job_action_menu/index.js create mode 100644 x-pack/plugins/rollup/public/crud_app/sections/components/job_action_menu/job_action_menu.container.js create mode 100644 x-pack/plugins/rollup/public/crud_app/sections/components/job_action_menu/job_action_menu.js create mode 100644 x-pack/plugins/rollup/public/crud_app/services/flatten_panel_tree.js create mode 100644 x-pack/plugins/rollup/public/crud_app/store/actions/delete_jobs.js create mode 100644 x-pack/plugins/rollup/public/crud_app/store/actions/start_jobs.js create mode 100644 x-pack/plugins/rollup/public/crud_app/store/actions/stop_jobs.js diff --git a/x-pack/plugins/rollup/common/constants/crud_app.js b/x-pack/plugins/rollup/common/constants/crud_app.js index 1813373776f46..5298043f2b4c8 100644 --- a/x-pack/plugins/rollup/common/constants/crud_app.js +++ b/x-pack/plugins/rollup/common/constants/crud_app.js @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export const CRUD_APP_BASE_PATH = '/management/elasticsearch/index_rollup_jobs/'; +export const CRUD_APP_BASE_PATH = '/management/elasticsearch/rollup_jobs/'; diff --git a/x-pack/plugins/rollup/public/crud_app/app.js b/x-pack/plugins/rollup/public/crud_app/app.js index e6d107643dcd5..b0bc3775fea0c 100644 --- a/x-pack/plugins/rollup/public/crud_app/app.js +++ b/x-pack/plugins/rollup/public/crud_app/app.js @@ -12,7 +12,7 @@ import { JobList } from './sections'; export const App = () => (
- +
); diff --git a/x-pack/plugins/rollup/public/crud_app/index.js b/x-pack/plugins/rollup/public/crud_app/index.js index 8a93515524f1e..3134914206ac7 100644 --- a/x-pack/plugins/rollup/public/crud_app/index.js +++ b/x-pack/plugins/rollup/public/crud_app/index.js @@ -8,6 +8,7 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { Provider } from 'react-redux'; import { HashRouter } from 'react-router-dom'; +import { I18nProvider } from '@kbn/i18n/react'; import { management } from 'ui/management'; import routes from 'ui/routes'; @@ -23,7 +24,7 @@ esSection.register('rollup_jobs', { visible: true, display: 'Rollup Jobs', order: 2, - url: `#${CRUD_APP_BASE_PATH}home` + url: `#${CRUD_APP_BASE_PATH}` }); export const manageAngularLifecycle = ($scope, $route, elem) => { @@ -44,11 +45,13 @@ export const manageAngularLifecycle = ($scope, $route, elem) => { const renderReact = async (elem) => { render( - - - - - , + + + + + + + , elem ); }; diff --git a/x-pack/plugins/rollup/public/crud_app/sections/components/index.js b/x-pack/plugins/rollup/public/crud_app/sections/components/index.js new file mode 100644 index 0000000000000..e972bdeae89f6 --- /dev/null +++ b/x-pack/plugins/rollup/public/crud_app/sections/components/index.js @@ -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 { JobActionMenu } from './job_action_menu'; diff --git a/x-pack/plugins/rollup/public/crud_app/sections/components/job_action_menu/confirm_delete_modal/confirm_delete_modal.js b/x-pack/plugins/rollup/public/crud_app/sections/components/job_action_menu/confirm_delete_modal/confirm_delete_modal.js new file mode 100644 index 0000000000000..4ea38ba838492 --- /dev/null +++ b/x-pack/plugins/rollup/public/crud_app/sections/components/job_action_menu/confirm_delete_modal/confirm_delete_modal.js @@ -0,0 +1,114 @@ +/* + * 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, { Component, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import { injectI18n, FormattedMessage } from '@kbn/i18n/react'; + +import { + EuiConfirmModal, + EuiOverlayMask, +} from '@elastic/eui'; + +class ConfirmDeleteModalUi extends Component { + static propTypes = { + isSingleSelection: PropTypes.bool.isRequired, + entity: PropTypes.string.isRequired, + jobs: PropTypes.array.isRequired, + onCancel: PropTypes.func.isRequired, + onConfirm: PropTypes.func.isRequired, + } + + renderJobs() { + const { jobs } = this.props; + const jobItems = jobs.map(({ id, status }) => { + const statusText = status === 'started' ? ' (started)' : null; + return
  • {id}{statusText}
  • ; + }); + + return ; + } + + render() { + const { + isSingleSelection, + entity, + jobs, + onCancel, + onConfirm, + intl, + } = this.props; + + let title; + let content; + + if (isSingleSelection) { + const { id, status } = jobs[0]; + title = intl.formatMessage({ + id: 'xpack.rollupJobs.jobActionMenu.deleteJob.confirmModal.modalTitleSingle', + defaultMessage: 'Delete rollup job \'{id}\'?', + }, { id }); + + if (status === 'started') { + content = ( +

    + +

    + ); + } + } else { + title = intl.formatMessage({ + id: 'xpack.rollupJobs.jobActionMenu.deleteJob.confirmModal.modalTitleMultiple', + defaultMessage: 'Delete {count} rollup jobs?', + }, { count: jobs.length }); + + content = ( + +

    + + {' '} + {entity}: +

    + {this.renderJobs()} +
    + ); + } + + return ( + + + {content} + + + ); + } +} + +export const ConfirmDeleteModal = injectI18n(ConfirmDeleteModalUi); diff --git a/x-pack/plugins/rollup/public/crud_app/sections/components/job_action_menu/confirm_delete_modal/index.js b/x-pack/plugins/rollup/public/crud_app/sections/components/job_action_menu/confirm_delete_modal/index.js new file mode 100644 index 0000000000000..651455d00e9f2 --- /dev/null +++ b/x-pack/plugins/rollup/public/crud_app/sections/components/job_action_menu/confirm_delete_modal/index.js @@ -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 { ConfirmDeleteModal } from './confirm_delete_modal'; diff --git a/x-pack/plugins/rollup/public/crud_app/sections/components/job_action_menu/index.js b/x-pack/plugins/rollup/public/crud_app/sections/components/job_action_menu/index.js new file mode 100644 index 0000000000000..4d273b9af27c3 --- /dev/null +++ b/x-pack/plugins/rollup/public/crud_app/sections/components/job_action_menu/index.js @@ -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 { JobActionMenu } from './job_action_menu.container'; diff --git a/x-pack/plugins/rollup/public/crud_app/sections/components/job_action_menu/job_action_menu.container.js b/x-pack/plugins/rollup/public/crud_app/sections/components/job_action_menu/job_action_menu.container.js new file mode 100644 index 0000000000000..95a48eabd3c70 --- /dev/null +++ b/x-pack/plugins/rollup/public/crud_app/sections/components/job_action_menu/job_action_menu.container.js @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { connect } from 'react-redux'; +import { JobActionMenu as JobActionMenuComponent } from './job_action_menu'; +import { + startJobs, + stopJobs, + deleteJobs, +} from '../../../store/actions'; + +const mapDispatchToProps = (dispatch, { jobs }) => { + const jobIds = jobs.map(job => job.id); + return { + startJobs: () => { + dispatch(startJobs(jobIds)); + }, + stopJobs: () => { + dispatch(stopJobs(jobIds)); + }, + deleteJobs: () => { + dispatch(deleteJobs(jobIds)); + }, + }; +}; + +export const JobActionMenu = connect(undefined, mapDispatchToProps)(JobActionMenuComponent); diff --git a/x-pack/plugins/rollup/public/crud_app/sections/components/job_action_menu/job_action_menu.js b/x-pack/plugins/rollup/public/crud_app/sections/components/job_action_menu/job_action_menu.js new file mode 100644 index 0000000000000..b56a0c7160214 --- /dev/null +++ b/x-pack/plugins/rollup/public/crud_app/sections/components/job_action_menu/job_action_menu.js @@ -0,0 +1,233 @@ +/* + * 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, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { injectI18n } from '@kbn/i18n/react'; + +import { + EuiButton, + EuiContextMenu, + EuiIcon, + EuiPopover, +} from '@elastic/eui'; + +import { ConfirmDeleteModal } from './confirm_delete_modal'; +import { flattenPanelTree } from '../../../services'; + +class JobActionMenuUi extends Component { + static propTypes = { + startJobs: PropTypes.func.isRequired, + stopJobs: PropTypes.func.isRequired, + deleteJobs: PropTypes.func.isRequired, + iconSide: PropTypes.string, + anchorPosition: PropTypes.string, + label: PropTypes.node, + iconType: PropTypes.string, + jobs: PropTypes.array, + } + + static defaultProps = { + iconSide: 'right', + anchorPosition: 'rightUp', + iconType: 'arrowDown', + jobs: [], + } + + constructor(props) { + super(props); + + this.state = { + isPopoverOpen: false, + showDeleteConfirmation: false + }; + } + + panels() { + const { + startJobs, + stopJobs, + intl, + } = this.props; + + const isSingleSelection = this.isSingleSelection(); + const entity = this.getEntity(isSingleSelection); + + const items = []; + + if (this.canStartJobs()) { + items.push({ + name: intl.formatMessage({ + id: 'xpack.rollupJobs.jobActionMenu.startJobLabel', + defaultMessage: 'Start {entity}', + }, { entity }), + icon: , + onClick: () => { + this.closePopover(); + startJobs(); + }, + }); + } + + if (this.canStopJobs()) { + items.push({ + name: intl.formatMessage({ + id: 'xpack.rollupJobs.jobActionMenu.stopJobLabel', + defaultMessage: 'Stop {entity}', + }, { entity }), + icon: , + onClick: () => { + this.closePopover(); + stopJobs(); + }, + }); + } + + items.push({ + name: intl.formatMessage({ + id: 'xpack.rollupJobs.jobActionMenu.deleteJobLabel', + defaultMessage: 'Delete {entity}', + }, { entity }), + icon: , + onClick: () => { + this.closePopover(); + this.openDeleteConfirmationModal(); + }, + }); + + const upperCasedEntity = `${entity[0].toUpperCase()}${entity.slice(1)}`; + const panelTree = { + id: 0, + title: intl.formatMessage({ + id: 'xpack.rollupJobs.jobActionMenu.panelTitle', + defaultMessage: '{upperCasedEntity} options', + }, { upperCasedEntity }), + items, + }; + + return flattenPanelTree(panelTree); + } + + onButtonClick = () => { + this.setState(prevState => ({ + isPopoverOpen: !prevState.isPopoverOpen + })); + }; + + closePopover = () => { + this.setState({ + isPopoverOpen: false + }); + }; + + closeDeleteConfirmationModal = () => { + this.setState({ showDeleteConfirmation: false }); + }; + + openDeleteConfirmationModal = () => { + this.setState({ showDeleteConfirmation: true }); + }; + + canStartJobs() { + const { jobs } = this.props; + return jobs.some(job => job.status === 'stopped'); + } + + canStopJobs() { + const { jobs } = this.props; + return jobs.some(job => job.status === 'started'); + } + + confirmDeleteModal = () => { + const { showDeleteConfirmation } = this.state; + + if (!showDeleteConfirmation) { + return null; + } + + const { + deleteJobs, + jobs, + } = this.props; + + const onConfirmDelete = () => { + this.closePopover(); + deleteJobs(); + }; + + const isSingleSelection = this.isSingleSelection(); + const entity = this.getEntity(isSingleSelection); + + return ( + + ); + }; + + isSingleSelection = () => { + return this.props.jobs.length === 1; + }; + + getEntity = isSingleSelection => { + return isSingleSelection ? 'job' : 'jobs'; + }; + + render() { + const { intl } = this.props; + const jobCount = this.props.jobs.length; + + const { + iconSide, + anchorPosition, + iconType, + label = intl.formatMessage({ + id: 'xpack.rollupJobs.jobActionMenu.buttonLabel', + defaultMessage: 'Manage {jobCount, plural, one {job} other {jobs}}', + }, { jobCount }), + } = this.props; + + const panels = this.panels(); + const isSingleSelection = this.isSingleSelection(); + const entity = this.getEntity(isSingleSelection); + + const button = ( + + {label} + + ); + + return ( +
    + {this.confirmDeleteModal()} + + + +
    + ); + } +} + +export const JobActionMenu = injectI18n(JobActionMenuUi); diff --git a/x-pack/plugins/rollup/public/crud_app/sections/job_list/detail_panel/detail_panel.container.js b/x-pack/plugins/rollup/public/crud_app/sections/job_list/detail_panel/detail_panel.container.js index 16ed5984594ff..7c7db24b8b0dd 100644 --- a/x-pack/plugins/rollup/public/crud_app/sections/job_list/detail_panel/detail_panel.container.js +++ b/x-pack/plugins/rollup/public/crud_app/sections/job_list/detail_panel/detail_panel.container.js @@ -30,8 +30,8 @@ const mapDispatchToProps = (dispatch) => { closeDetailPanel: () => { dispatch(closeDetailPanel()); }, - openDetailPanel: ({ panelType, job }) => { - dispatch(openDetailPanel({ panelType, job })); + openDetailPanel: ({ panelType, jobId }) => { + dispatch(openDetailPanel({ panelType, jobId })); }, }; }; diff --git a/x-pack/plugins/rollup/public/crud_app/sections/job_list/detail_panel/detail_panel.js b/x-pack/plugins/rollup/public/crud_app/sections/job_list/detail_panel/detail_panel.js index 5234efcbde867..37ccaf6f50086 100644 --- a/x-pack/plugins/rollup/public/crud_app/sections/job_list/detail_panel/detail_panel.js +++ b/x-pack/plugins/rollup/public/crud_app/sections/job_list/detail_panel/detail_panel.js @@ -5,11 +5,16 @@ */ import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { injectI18n } from '@kbn/i18n/react'; import { EuiFlyout, EuiFlyoutBody, EuiFlyoutHeader, + EuiFlyoutFooter, + EuiFlexItem, + EuiFlexGroup, EuiTitle, EuiTab, EuiTabs, @@ -24,11 +29,20 @@ import { TabHistogram, } from './tabs'; +import { JobActionMenu } from '../../components'; + const tabs = ['Summary', 'Terms', 'Histogram', 'Metrics', 'JSON']; -export class DetailPanel extends Component { +export class DetailPanelUi extends Component { + static propTypes = { + job: PropTypes.object, + panelType: PropTypes.oneOf(tabs), + closeDetailPanel: PropTypes.func.isRequired, + openDetailPanel: PropTypes.func.isRequired, + } + static defaultProps = { - job: {}, + panelType: tabs[0], } constructor(props) { @@ -54,7 +68,7 @@ export class DetailPanel extends Component { const isSelected = tab === panelType; return ( openDetailPanel({ panelType: tab, job })} + onClick={() => openDetailPanel({ panelType: tab, jobId: job.id })} isSelected={isSelected} data-test-subj={`detailPanelTab${isSelected ? 'Selected' : ''}`} key={index} @@ -69,31 +83,34 @@ export class DetailPanel extends Component { const { panelType, closeDetailPanel, - job: { - id, - indexPattern, - rollupIndex, - rollupCron, - rollupInterval, - rollupDelay, - dateHistogramTimeZone, - dateHistogramField, - metrics, - terms, - histogram, - status, - documentsProcessed, - pagesProcessed, - rollupsIndexed, - triggerCount, - json, - }, + job, + intl, } = this.props; - if (!panelType) { + if (!job) { return null; } + const { + id, + indexPattern, + rollupIndex, + rollupCron, + rollupInterval, + rollupDelay, + dateHistogramTimeZone, + dateHistogramField, + metrics, + terms, + histogram, + status, + documentsProcessed, + pagesProcessed, + rollupsIndexed, + triggerCount, + json, + } = job; + const tabToContentMap = { Summary: ( @@ -151,7 +167,27 @@ export class DetailPanel extends Component { {content} + + + + + + + + ); } } + +export const DetailPanel = injectI18n(DetailPanelUi); diff --git a/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.js b/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.js index 8fffe520a9721..df38ceb0f2728 100644 --- a/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.js +++ b/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_list.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { PureComponent, Fragment } from 'react'; +import React, { Component, Fragment } from 'react'; import PropTypes from 'prop-types'; import { @@ -17,7 +17,7 @@ import { const REFRESH_RATE_MS = 30000; -export class JobList extends PureComponent { +export class JobList extends Component { static propTypes = { loadJobs: PropTypes.func, } diff --git a/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_table/job_table.container.js b/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_table/job_table.container.js index 3f3d4b4f236a6..87a37198c7350 100644 --- a/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_table/job_table.container.js +++ b/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_table/job_table.container.js @@ -15,6 +15,7 @@ import { } from '../../../store/selectors'; import { + closeDetailPanel, filterChanged, openDetailPanel, pageChanged, @@ -36,6 +37,9 @@ const mapStateToProps = (state) => { const mapDispatchToProps = (dispatch) => { return { + closeDetailPanel: () => { + dispatch(closeDetailPanel()); + }, filterChanged: (filter) => { dispatch(filterChanged({ filter })); }, @@ -48,13 +52,13 @@ const mapDispatchToProps = (dispatch) => { sortChanged: (sortField, isSortAscending) => { dispatch(sortChanged({ sortField, isSortAscending })); }, - openDetailPanel: (job) => { - dispatch(openDetailPanel({ job })); + openDetailPanel: (jobId) => { + dispatch(openDetailPanel({ jobId: jobId })); }, }; }; export const JobTable = connect( mapStateToProps, - mapDispatchToProps + mapDispatchToProps, )(JobTableComponent); diff --git a/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_table/job_table.js b/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_table/job_table.js index 0318fe7a805e7..0ab197f240c5f 100644 --- a/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_table/job_table.js +++ b/x-pack/plugins/rollup/public/crud_app/sections/job_list/job_table/job_table.js @@ -5,33 +5,46 @@ */ import React, { Component } from 'react'; +import { i18n } from '@kbn/i18n'; +import { injectI18n } from '@kbn/i18n/react'; import PropTypes from 'prop-types'; import { + EuiCheckbox, EuiFieldSearch, + EuiFlexGroup, + EuiFlexItem, + EuiLink, EuiPage, + EuiPageBody, + EuiPageContent, EuiSpacer, EuiTable, EuiTableBody, EuiTableHeader, EuiTableHeaderCell, + EuiTableHeaderCellCheckbox, EuiTablePagination, EuiTableRow, EuiTableRowCell, + EuiTableRowCellCheckbox, EuiTitle, - EuiPageBody, - EuiPageContent, EuiToolTip, - EuiLink, } from '@elastic/eui'; +import { JobActionMenu } from '../../components'; + import { JobStatus } from '../job_status'; const COLUMNS = [{ - name: 'ID', + name: i18n.translate('xpack.rollupJobs.jobTable.headers.nameHeader', { + defaultMessage: 'ID', + }), fieldName: 'id', }, { - name: 'Status', + name: i18n.translate('xpack.rollupJobs.jobTable.headers.statusHeader', { + defaultMessage: 'Status', + }), fieldName: 'status', render: ({ status, rollupCron }) => { return ( @@ -41,22 +54,32 @@ const COLUMNS = [{ ); }, }, { - name: 'Index pattern', + name: i18n.translate('xpack.rollupJobs.jobTable.headers.indexPatternHeader', { + defaultMessage: 'Index pattern', + }), truncateText: true, fieldName: 'indexPattern', }, { - name: 'Rollup index', + name: i18n.translate('xpack.rollupJobs.jobTable.headers.rollupIndexHeader', { + defaultMessage: 'Rollup index', + }), truncateText: true, fieldName: 'rollupIndex', }, { - name: 'Delay', + name: i18n.translate('xpack.rollupJobs.jobTable.headers.delayHeader', { + defaultMessage: 'Delay', + }), fieldName: 'rollupDelay', render: ({ rollupDelay }) => rollupDelay || 'None', }, { - name: 'Interval', + name: i18n.translate('xpack.rollupJobs.jobTable.headers.intervalHeader', { + defaultMessage: 'Interval', + }), fieldName: 'rollupInterval', }, { - name: 'Groups', + name: i18n.translate('xpack.rollupJobs.jobTable.headers.groupsHeader', { + defaultMessage: 'Groups', + }), truncateText: true, render: job => { const { histogram, terms } = job; @@ -78,7 +101,9 @@ const COLUMNS = [{ return 'None'; }, }, { - name: 'Metrics', + name: i18n.translate('xpack.rollupJobs.jobTable.headers.metricsHeader', { + defaultMessage: 'Metrics', + }), truncateText: true, render: job => { const { metrics } = job; @@ -86,19 +111,105 @@ const COLUMNS = [{ }, }]; -export class JobTable extends Component { - constructor(props) { - super(props); - } - +export class JobTableUi extends Component { static propTypes = { jobs: PropTypes.array, + closeDetailPanel: PropTypes.func.isRequired, } static defaultProps = { jobs: [], } + static getDerivedStateFromProps(props, state) { + // Deselct any jobs which no longer exist, e.g. they've been deleted. + const { idToSelectedJobMap } = state; + const jobIds = props.jobs.map(job => job.id); + const selectedJobIds = Object.keys(idToSelectedJobMap); + const missingJobIds = selectedJobIds.filter(selectedJobId => { + return !jobIds.includes(selectedJobId); + }); + + if (missingJobIds.length) { + const newMap = { ...idToSelectedJobMap }; + missingJobIds.forEach(missingJobId => delete newMap[missingJobId]); + return { idToSelectedJobMap: newMap }; + } + + return null; + } + + constructor(props) { + super(props); + + this.state = { + idToSelectedJobMap: {}, + }; + } + + toggleAll = () => { + const allSelected = this.areAllItemsSelected(); + + if (allSelected) { + return this.setState({ idToSelectedJobMap: {} }); + } + + const { jobs } = this.props; + const idToSelectedJobMap = {}; + + jobs.forEach(({ id }) => { + idToSelectedJobMap[id] = true; + }); + + this.setState({ idToSelectedJobMap }); + }; + + toggleItem = id => { + this.setState(({ idToSelectedJobMap }) => { + const newMap = { ...idToSelectedJobMap }; + + if (newMap[id]) { + delete newMap[id]; + } else { + newMap[id] = true; + } + + return { idToSelectedJobMap: newMap }; + }); + }; + + resetSelection = () => { + this.setState({ idToSelectedJobMap: {} }); + }; + + deselectItems = (itemIds) => { + this.setState(({ idToSelectedJobMap }) => { + const newMap = { ...idToSelectedJobMap }; + itemIds.forEach(id => delete newMap[id]); + return { idToSelectedJobMap: newMap }; + }); + }; + + areAllItemsSelected = () => { + const { jobs } = this.props; + const indexOfUnselectedItem = jobs.findIndex( + job => !this.isItemSelected(job.id) + ); + return indexOfUnselectedItem === -1; + }; + + isItemSelected = id => { + return !!this.state.idToSelectedJobMap[id]; + }; + + getSelectedJobs() { + const { jobs } = this.props; + const { idToSelectedJobMap } = this.state; + return Object.keys(idToSelectedJobMap).map(jobId => { + return jobs.find(job => job.id === jobId); + }); + } + onSort = column => { const { sortField, isSortAscending, sortChanged } = this.props; @@ -137,7 +248,7 @@ export class JobTable extends Component { { - openDetailPanel(job); + openDetailPanel(job.id); }} > {value} @@ -175,10 +286,24 @@ export class JobTable extends Component { const { jobs } = this.props; return jobs.map(job => { + const { id } = job; + return ( + + { + this.toggleItem(id); + }} + data-test-subj="indexTableRowCheckbox" + /> + + {this.buildRowCells(job)} ); @@ -203,9 +328,15 @@ export class JobTable extends Component { const { filterChanged, filter, - jobs + jobs, + intl, + closeDetailPanel, } = this.props; + const { idToSelectedJobMap } = this.state; + + const atLeastOneItemSelected = Object.keys(idToSelectedJobMap).length > 0; + return ( @@ -216,22 +347,49 @@ export class JobTable extends Component { - { - filterChanged(event.target.value); - }} - data-test-subj="jobTableFilterInput" - placeholder="Search" - aria-label="Search jobs" - /> + + {atLeastOneItemSelected ? ( + + + + ) : null} + + { + filterChanged(event.target.value); + }} + data-test-subj="jobTableFilterInput" + placeholder={ + intl.formatMessage({ + id: 'xpack.rollupJobs.jobTable.searchInputPlaceholder', + defaultMessage: 'Search', + }) + } + aria-label="Search jobs" + /> + + {jobs.length > 0 ? ( + + + {this.buildHeader()} @@ -241,7 +399,7 @@ export class JobTable extends Component { ) : (
    - No job rollup jobs to show + No rollup jobs to show
    )} @@ -254,3 +412,5 @@ export class JobTable extends Component { ); } } + +export const JobTable = injectI18n(JobTableUi); diff --git a/x-pack/plugins/rollup/public/crud_app/services/api.js b/x-pack/plugins/rollup/public/crud_app/services/api.js index 0af3a05a76239..3515d99349fa7 100644 --- a/x-pack/plugins/rollup/public/crud_app/services/api.js +++ b/x-pack/plugins/rollup/public/crud_app/services/api.js @@ -18,3 +18,18 @@ export async function loadJobs() { const { data: { jobs } } = await httpClient.get(`${apiPrefix}/jobs`); return jobs; } + +export async function startJobs(jobIds) { + const body = { jobIds }; + return await httpClient.post(`${apiPrefix}/start`, body); +} + +export async function stopJobs(jobIds) { + const body = { jobIds }; + return await httpClient.post(`${apiPrefix}/stop`, body); +} + +export async function deleteJobs(jobIds) { + const body = { jobIds }; + return await httpClient.post(`${apiPrefix}/delete`, body); +} diff --git a/x-pack/plugins/rollup/public/crud_app/services/flatten_panel_tree.js b/x-pack/plugins/rollup/public/crud_app/services/flatten_panel_tree.js new file mode 100644 index 0000000000000..e060e22965cb3 --- /dev/null +++ b/x-pack/plugins/rollup/public/crud_app/services/flatten_panel_tree.js @@ -0,0 +1,20 @@ +/* + * 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 const flattenPanelTree = (tree, array = []) => { + array.push(tree); + + if (tree.items) { + tree.items.forEach(item => { + if (item.panel) { + flattenPanelTree(item.panel, array); + item.panel = item.panel.id; + } + }); + } + + return array; +}; diff --git a/x-pack/plugins/rollup/public/crud_app/services/index.js b/x-pack/plugins/rollup/public/crud_app/services/index.js index 1c2eb791a9912..9fe4d2255feba 100644 --- a/x-pack/plugins/rollup/public/crud_app/services/index.js +++ b/x-pack/plugins/rollup/public/crud_app/services/index.js @@ -5,9 +5,14 @@ */ export { - loadJobs, setHttpClient, + loadJobs, + startJobs, + stopJobs, + deleteJobs, } from './api'; + export { sortTable } from './sort_table'; export { filterItems } from './filter_items'; export { deserializeJobs } from './jobs'; +export { flattenPanelTree } from './flatten_panel_tree'; diff --git a/x-pack/plugins/rollup/public/crud_app/store/actions/delete_jobs.js b/x-pack/plugins/rollup/public/crud_app/store/actions/delete_jobs.js new file mode 100644 index 0000000000000..49d3f8d1d91a7 --- /dev/null +++ b/x-pack/plugins/rollup/public/crud_app/store/actions/delete_jobs.js @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createAction } from 'redux-actions'; +import { toastNotifications } from 'ui/notify'; +import { deleteJobs as sendDeleteJobsRequest } from '../../services'; +import { getDetailPanelJob } from '../selectors'; +import { loadJobs } from './load_jobs'; +import { closeDetailPanel } from './detail_panel'; + +export const deleteJobsSuccess = createAction('DELETE_JOBS_SUCCESS'); +export const deleteJobs = (jobIds) => async (dispatch, getState) => { + try { + await sendDeleteJobsRequest(jobIds); + } catch (error) { + return toastNotifications.addDanger(error.data.message); + } + + dispatch(deleteJobsSuccess()); + dispatch(loadJobs()); + + // If we've just deleted a job we were looking at, we need to close the panel. + const detailPanelJob = getDetailPanelJob(getState()); + if (detailPanelJob && jobIds.includes(detailPanelJob.id)) { + dispatch(closeDetailPanel()); + } +}; diff --git a/x-pack/plugins/rollup/public/crud_app/store/actions/index.js b/x-pack/plugins/rollup/public/crud_app/store/actions/index.js index 43f3f44acfeff..044c28ba4ceb8 100644 --- a/x-pack/plugins/rollup/public/crud_app/store/actions/index.js +++ b/x-pack/plugins/rollup/public/crud_app/store/actions/index.js @@ -9,6 +9,21 @@ export { loadJobsSuccess, } from './load_jobs'; +export { + startJobs, + startJobsSuccess, +} from './start_jobs'; + +export { + stopJobs, + stopJobsSuccess, +} from './stop_jobs'; + +export { + deleteJobs, + deleteJobsSuccess, +} from './delete_jobs'; + export { applyFilters, filtersApplied, diff --git a/x-pack/plugins/rollup/public/crud_app/store/actions/start_jobs.js b/x-pack/plugins/rollup/public/crud_app/store/actions/start_jobs.js new file mode 100644 index 0000000000000..f83f62f035ce9 --- /dev/null +++ b/x-pack/plugins/rollup/public/crud_app/store/actions/start_jobs.js @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createAction } from 'redux-actions'; +import { toastNotifications } from 'ui/notify'; +import { startJobs as sendStartJobsRequest } from '../../services'; +import { loadJobs } from './load_jobs'; + +export const startJobsSuccess = createAction('START_JOBS_SUCCESS'); +export const startJobs = (jobIds) => async (dispatch) => { + try { + await sendStartJobsRequest(jobIds); + } catch (error) { + return toastNotifications.addDanger(error.data.message); + } + + dispatch(startJobsSuccess()); + dispatch(loadJobs()); +}; diff --git a/x-pack/plugins/rollup/public/crud_app/store/actions/stop_jobs.js b/x-pack/plugins/rollup/public/crud_app/store/actions/stop_jobs.js new file mode 100644 index 0000000000000..3f86b6d482ea6 --- /dev/null +++ b/x-pack/plugins/rollup/public/crud_app/store/actions/stop_jobs.js @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createAction } from 'redux-actions'; +import { toastNotifications } from 'ui/notify'; +import { stopJobs as sendStopJobsRequest } from '../../services'; +import { loadJobs } from './load_jobs'; + +export const stopJobsSuccess = createAction('STOP_JOBS_SUCCESS'); +export const stopJobs = (jobIds) => async (dispatch) => { + try { + await sendStopJobsRequest(jobIds); + } catch (error) { + return toastNotifications.addDanger(error.data.message); + } + + dispatch(stopJobsSuccess()); + dispatch(loadJobs()); +}; diff --git a/x-pack/plugins/rollup/public/crud_app/store/reducers/detail_panel.js b/x-pack/plugins/rollup/public/crud_app/store/reducers/detail_panel.js index e38d218ef0f9a..9cf22a85f0eac 100644 --- a/x-pack/plugins/rollup/public/crud_app/store/reducers/detail_panel.js +++ b/x-pack/plugins/rollup/public/crud_app/store/reducers/detail_panel.js @@ -14,12 +14,12 @@ export const detailPanel = handleActions( [openDetailPanel](state, action) { const { panelType, - job, + jobId, } = action.payload; return { panelType: panelType || state.panelType || 'Summary', - job, + jobId, }; }, [closeDetailPanel]() { diff --git a/x-pack/plugins/rollup/public/crud_app/store/reducers/jobs.js b/x-pack/plugins/rollup/public/crud_app/store/reducers/jobs.js index b57e8c9199fa3..eb5c53521eda7 100644 --- a/x-pack/plugins/rollup/public/crud_app/store/reducers/jobs.js +++ b/x-pack/plugins/rollup/public/crud_app/store/reducers/jobs.js @@ -17,7 +17,6 @@ const byId = handleActions({ jobs.forEach(job => { newState[job.id] = job; }); - return newState; }, }, {}); diff --git a/x-pack/plugins/rollup/public/crud_app/store/selectors/index.js b/x-pack/plugins/rollup/public/crud_app/store/selectors/index.js index cdab30e9c4e9a..6ec726cae826a 100644 --- a/x-pack/plugins/rollup/public/crud_app/store/selectors/index.js +++ b/x-pack/plugins/rollup/public/crud_app/store/selectors/index.js @@ -9,15 +9,15 @@ import { Pager } from '@elastic/eui'; import { createSelector } from 'reselect'; import { filterItems, sortTable } from '../../services'; -export const getDetailPanelType = (state) => state.detailPanel.panelType; -export const isDetailPanelOpen = (state) => !!getDetailPanelType(state); -export const getDetailPanelJob = (state) => state.detailPanel.job; - export const getJobs = (state) => state.jobs.byId; -export const getJobByJobName = (state, name) => getJobs(state)[name]; +export const getJobByJobId = (state, id) => getJobs(state)[id]; export const getFilteredIds = (state) => state.jobs.filteredIds; export const getTableState = (state) => state.tableState; +export const getDetailPanelType = (state) => state.detailPanel.panelType; +export const isDetailPanelOpen = (state) => !!getDetailPanelType(state); +export const getDetailPanelJob = (state) => getJobByJobId(state, state.detailPanel.jobId); + export const getJobStatusByJobName = (state, jobName) => { const jobs = getJobs(state); const { status } = jobs[jobName] || {}; diff --git a/x-pack/plugins/rollup/server/client/elasticsearch_rollup.js b/x-pack/plugins/rollup/server/client/elasticsearch_rollup.js index ca8177c1e50ee..b8ca1f005bc27 100644 --- a/x-pack/plugins/rollup/server/client/elasticsearch_rollup.js +++ b/x-pack/plugins/rollup/server/client/elasticsearch_rollup.js @@ -64,5 +64,47 @@ export const elasticsearchJsPlugin = (Client, config, components) => { ], method: 'GET' }); + + rollup.startJob = ca({ + urls: [ + { + fmt: '/_xpack/rollup/job/<%=id%>/_start', + req: { + id: { + type: 'string' + } + } + }, + ], + method: 'POST' + }); + + rollup.stopJob = ca({ + urls: [ + { + fmt: '/_xpack/rollup/job/<%=id%>/_stop', + req: { + id: { + type: 'string' + } + } + }, + ], + method: 'POST' + }); + + rollup.deleteJob = ca({ + urls: [ + { + fmt: '/_xpack/rollup/job/<%=id%>', + req: { + id: { + type: 'string' + } + } + }, + ], + method: 'DELETE' + }); }; diff --git a/x-pack/plugins/rollup/server/routes/api/jobs.js b/x-pack/plugins/rollup/server/routes/api/jobs.js index 8086e065544fa..40ff4dc164da8 100644 --- a/x-pack/plugins/rollup/server/routes/api/jobs.js +++ b/x-pack/plugins/rollup/server/routes/api/jobs.js @@ -14,9 +14,8 @@ export function registerJobsRoute(server) { path: '/api/rollup/jobs', method: 'GET', handler: async (request, reply) => { - const callWithRequest = callWithRequestFactory(server, request); - try { + const callWithRequest = callWithRequestFactory(server, request); const results = await callWithRequest('rollup.jobs'); reply(results); } catch(err) { @@ -28,4 +27,64 @@ export function registerJobsRoute(server) { } }, }); + + server.route({ + path: '/api/rollup/start', + method: 'POST', + handler: async (request, reply) => { + try { + const { jobIds } = request.payload; + const callWithRequest = callWithRequestFactory(server, request); + const results = await Promise.all(jobIds.map(id => callWithRequest('rollup.startJob', { id }))); + + reply(results); + } catch(err) { + if (isEsError(err)) { + return reply(wrapEsError(err)); + } + + reply(wrapUnknownError(err)); + } + }, + }); + + server.route({ + path: '/api/rollup/stop', + method: 'POST', + handler: async (request, reply) => { + try { + const { jobIds } = request.payload; + const callWithRequest = callWithRequestFactory(server, request); + const results = await Promise.all(jobIds.map(id => callWithRequest('rollup.stopJob', { id }))); + + reply(results); + } catch(err) { + if (isEsError(err)) { + return reply(wrapEsError(err)); + } + + reply(wrapUnknownError(err)); + } + }, + }); + + server.route({ + path: '/api/rollup/delete', + method: 'POST', + handler: async (request, reply) => { + try { + const { jobIds } = request.payload; + const callWithRequest = callWithRequestFactory(server, request); + const results = await Promise.all(jobIds.map(id => callWithRequest('rollup.deleteJob', { id }))); + + reply(results); + } catch(err) { + if (isEsError(err)) { + return reply(wrapEsError(err)); + } + + reply(wrapUnknownError(err)); + } + }, + }); }