feat(xo-web/logs/backup-ng): advanced filter (#5208)

See #4406
This commit is contained in:
badrAZ 2020-09-25 16:58:33 +02:00 committed by GitHub
parent 2aed2fd534
commit 45fe70f0fa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 162 additions and 123 deletions

View File

@ -12,6 +12,7 @@
- [Dashboard/Health] Show VMs that have too many snapshots [#5238](https://github.com/vatesfr/xen-orchestra/pull/5238) - [Dashboard/Health] Show VMs that have too many snapshots [#5238](https://github.com/vatesfr/xen-orchestra/pull/5238)
- [Groups] Ability to delete multiple groups at once (PR [#5264](https://github.com/vatesfr/xen-orchestra/pull/5264)) - [Groups] Ability to delete multiple groups at once (PR [#5264](https://github.com/vatesfr/xen-orchestra/pull/5264))
- [Backup/logs] Log's tasks pagination [#4406](https://github.com/vatesfr/xen-orchestra/issues/4406) (PR [#5209](https://github.com/vatesfr/xen-orchestra/pull/5209)) - [Backup/logs] Log's tasks pagination [#4406](https://github.com/vatesfr/xen-orchestra/issues/4406) (PR [#5209](https://github.com/vatesfr/xen-orchestra/pull/5209))
- [Backup logs] Ability to filter by VM/pool name, SRs names and remotes names [#4406](https://github.com/vatesfr/xen-orchestra/issues/4406) (PR [#5208](https://github.com/vatesfr/xen-orchestra/pull/5208))
### Bug fixes ### Bug fixes

View File

@ -83,6 +83,8 @@ const messages = {
advancedSettings: 'Advanced settings', advancedSettings: 'Advanced settings',
txChecksumming: 'TX checksumming', txChecksumming: 'TX checksumming',
unknownSize: 'Unknown size', unknownSize: 'Unknown size',
remotesNames: 'Remotes names',
srsNames: 'SRs names',
// ----- Modals ----- // ----- Modals -----
alertOk: 'OK', alertOk: 'OK',

View File

@ -0,0 +1,88 @@
import _ from 'intl'
import classNames from 'classnames'
import DropdownMenu from 'react-bootstrap-4/lib/DropdownMenu' // https://phabricator.babeljs.io/T6662 so Dropdown.Menu won't work like https://react-bootstrap.github.io/components.html#btn-dropdowns-custom
import DropdownToggle from 'react-bootstrap-4/lib/DropdownToggle' // https://phabricator.babeljs.io/T6662 so Dropdown.Toggle won't work https://react-bootstrap.github.io/components.html#btn-dropdowns-custom
import PropTypes from 'prop-types'
import React, { Component } from 'react'
import { Dropdown, MenuItem } from 'react-bootstrap-4/lib'
import { Input as DebouncedInput } from 'debounce-input-decorator'
import { isEmpty, map } from 'lodash'
import Button from './button'
import Icon from './icon'
import Tooltip from './tooltip'
export default class SearchBar extends Component {
static propTypes = {
filters: PropTypes.object,
onChange: PropTypes.func.isRequired,
value: PropTypes.string.isRequired,
}
_cleanFilter = () => this._setFilter('')
_setFilter = filterValue => {
const filter = this.refs.filter.getWrappedInstance()
filter.value = filterValue
filter.focus()
this.props.onChange(filterValue)
}
_onChange = event => {
this.props.onChange(event.target.value)
}
focus() {
this.refs.filter.getWrappedInstance().focus()
}
render() {
const { props } = this
return (
<div className={classNames('input-group', props.className)}>
{isEmpty(props.filters) ? (
<span className='input-group-addon'>
<Icon icon='search' />
</span>
) : (
<span className='input-group-btn'>
<Dropdown id='filter'>
<DropdownToggle bsStyle='info'>
<Icon icon='search' />
</DropdownToggle>
<DropdownMenu>
{map(props.filters, (filter, label) => (
<MenuItem key={label} onClick={() => this._setFilter(filter)}>
{_(label)}
</MenuItem>
))}
</DropdownMenu>
</Dropdown>
</span>
)}
<DebouncedInput
className='form-control'
onChange={this._onChange}
ref='filter'
value={props.value}
/>
<Tooltip content={_('filterSyntaxLinkTooltip')}>
<a
className='input-group-addon'
href='https://xen-orchestra.com/docs/manage_infrastructure.html#live-filter-search'
rel='noopener noreferrer'
target='_blank'
>
<Icon icon='info' />
</a>
</Tooltip>
<span className='input-group-btn'>
<Button onClick={this._cleanFilter}>
<Icon icon='clear-search' />
</Button>
</span>
</div>
)
}
}

View File

@ -2,15 +2,11 @@ import * as CM from 'complex-matcher'
import _ from 'intl' import _ from 'intl'
import classNames from 'classnames' import classNames from 'classnames'
import defined from '@xen-orchestra/defined' import defined from '@xen-orchestra/defined'
import DropdownMenu from 'react-bootstrap-4/lib/DropdownMenu' // https://phabricator.babeljs.io/T6662 so Dropdown.Menu won't work like https://react-bootstrap.github.io/components.html#btn-dropdowns-custom
import DropdownToggle from 'react-bootstrap-4/lib/DropdownToggle' // https://phabricator.babeljs.io/T6662 so Dropdown.Toggle won't work https://react-bootstrap.github.io/components.html#btn-dropdowns-custom
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import React from 'react' import React from 'react'
import Shortcuts from 'shortcuts' import Shortcuts from 'shortcuts'
import { Input as DebouncedInput } from 'debounce-input-decorator'
import { Portal } from 'react-overlays' import { Portal } from 'react-overlays'
import { Set } from 'immutable' import { Set } from 'immutable'
import { Dropdown, MenuItem } from 'react-bootstrap-4/lib'
import { injectState, provideState } from 'reaclette' import { injectState, provideState } from 'reaclette'
import { withRouter } from 'react-router' import { withRouter } from 'react-router'
import { import {
@ -32,7 +28,7 @@ import decorate from '../apply-decorators'
import Icon from '../icon' import Icon from '../icon'
import Pagination from '../pagination' import Pagination from '../pagination'
import SingleLineRow from '../single-line-row' import SingleLineRow from '../single-line-row'
import Tooltip from '../tooltip' import TableFilter from '../search-bar'
import { BlockLink } from '../link' import { BlockLink } from '../link'
import { Container, Col } from '../grid' import { Container, Col } from '../grid'
import { import {
@ -48,83 +44,6 @@ import styles from './index.css'
// =================================================================== // ===================================================================
class TableFilter extends Component {
static propTypes = {
filters: PropTypes.object,
onChange: PropTypes.func.isRequired,
value: PropTypes.string.isRequired,
}
_cleanFilter = () => this._setFilter('')
_setFilter = filterValue => {
const filter = this.refs.filter.getWrappedInstance()
filter.value = filterValue
filter.focus()
this.props.onChange(filterValue)
}
_onChange = event => {
this.props.onChange(event.target.value)
}
focus() {
this.refs.filter.getWrappedInstance().focus()
}
render() {
const { props } = this
return (
<div className='input-group'>
{isEmpty(props.filters) ? (
<span className='input-group-addon'>
<Icon icon='search' />
</span>
) : (
<span className='input-group-btn'>
<Dropdown id='filter'>
<DropdownToggle bsStyle='info'>
<Icon icon='search' />
</DropdownToggle>
<DropdownMenu>
{map(props.filters, (filter, label) => (
<MenuItem key={label} onClick={() => this._setFilter(filter)}>
{_(label)}
</MenuItem>
))}
</DropdownMenu>
</Dropdown>
</span>
)}
<DebouncedInput
className='form-control'
onChange={this._onChange}
ref='filter'
value={props.value}
/>
<Tooltip content={_('filterSyntaxLinkTooltip')}>
<a
className='input-group-addon'
href='https://xen-orchestra.com/docs/manage_infrastructure.html#live-filter-search'
rel='noopener noreferrer'
target='_blank'
>
<Icon icon='info' />
</a>
</Tooltip>
<span className='input-group-btn'>
<Button onClick={this._cleanFilter}>
<Icon icon='clear-search' />
</Button>
</span>
</div>
)
}
}
// ===================================================================
class ColumnHead extends Component { class ColumnHead extends Component {
static propTypes = { static propTypes = {
columnId: PropTypes.number.isRequired, columnId: PropTypes.number.isRequired,

View File

@ -1,4 +1,5 @@
import _, { FormattedDuration } from 'intl' import _, { FormattedDuration } from 'intl'
import * as CM from 'complex-matcher'
import ActionButton from 'action-button' import ActionButton from 'action-button'
import ButtonGroup from 'button-group' import ButtonGroup from 'button-group'
import decorate from 'apply-decorators' import decorate from 'apply-decorators'
@ -6,10 +7,12 @@ import defined, { get } from '@xen-orchestra/defined'
import Icon from 'icon' import Icon from 'icon'
import Pagination from 'pagination' import Pagination from 'pagination'
import React from 'react' import React from 'react'
import SearchBar from 'search-bar'
import Select from 'form/select' import Select from 'form/select'
import Tooltip from 'tooltip' import Tooltip from 'tooltip'
import { addSubscriptions, formatSize, formatSpeed } from 'utils' import { addSubscriptions, connectStore, formatSize, formatSpeed } from 'utils'
import { countBy, cloneDeep, filter, keyBy, map } from 'lodash' import { countBy, cloneDeep, filter, keyBy, map } from 'lodash'
import { createGetObjectsOfType } from 'selectors'
import { FormattedDate } from 'react-intl' import { FormattedDate } from 'react-intl'
import { injectState, provideState } from 'reaclette' import { injectState, provideState } from 'reaclette'
import { runBackupNgJob, subscribeBackupNgLogs, subscribeRemotes } from 'xo' import { runBackupNgJob, subscribeBackupNgLogs, subscribeRemotes } from 'xo'
@ -309,6 +312,8 @@ const TaskLi = ({ className, task, ...props }) => {
) )
} }
const SEARCH_BAR_FILTERS = { name: 'name:' }
const ITEMS_PER_PAGE = 5 const ITEMS_PER_PAGE = 5
export default decorate([ export default decorate([
addSubscriptions(({ id }) => ({ addSubscriptions(({ id }) => ({
@ -321,19 +326,28 @@ export default decorate([
cb(logs[id]) cb(logs[id])
}), }),
})), })),
connectStore({
pools: createGetObjectsOfType('pool'),
vms: createGetObjectsOfType('VM'),
}),
provideState({ provideState({
initialState: () => ({ initialState: () => ({
filter: undefined, _status: undefined,
filter: '',
page: 1, page: 1,
}), }),
effects: { effects: {
onPageChange(_, page) { onPageChange(_, page) {
this.state.page = page this.state.page = page
}, },
setFilter(_, filter) { onFilterChange(_, filter) {
this.state.filter = filter this.state.filter = filter
this.state.page = 1 this.state.page = 1
}, },
onStatusChange(_, status) {
this.state._status = status
this.state.page = 1
},
restartVmJob: (_, params) => async ( restartVmJob: (_, params) => async (
_, _,
{ log: { scheduleId, jobId } } { log: { scheduleId, jobId } }
@ -347,7 +361,7 @@ export default decorate([
}, },
}, },
computed: { computed: {
log: (_, { log }) => { log: (_, { log, pools, vms }) => {
if (log === undefined) { if (log === undefined) {
return {} return {}
} }
@ -356,33 +370,41 @@ export default decorate([
return log return log
} }
let newLog const newLog = cloneDeep(log)
log.tasks.forEach((task, key) => { newLog.tasks.forEach(task => {
if (task.tasks === undefined || get(() => task.data.type) !== 'VM') { const type = get(() => task.data.type)
if (type !== 'VM' && type !== 'xo' && type !== 'pool') {
return return
} }
const subTaskWithIsFull = task.tasks.find( task.name =
({ data = {} }) => data.isFull !== undefined type === 'VM'
) ? get(() => vms[task.data.id].name_label)
if (subTaskWithIsFull !== undefined) { : type === 'pool'
if (newLog === undefined) { ? get(() => pools[task.data.id].name_label)
newLog = cloneDeep(log) : 'xo'
}
newLog.tasks[key].isFull = subTaskWithIsFull.data.isFull if (task.tasks !== undefined) {
const subTaskWithIsFull = task.tasks.find(
({ data = {} }) => data.isFull !== undefined
)
task.isFull = get(() => subTaskWithIsFull.data.isFull)
} }
}) })
return defined(newLog, log) return newLog
}, },
tasksFilteredByStatus: ({ preFilteredTasksLogs: ({ log, filter }) => {
defaultFilter, try {
filter: value = defaultFilter, return log.tasks.filter(CM.parse(filter).createPredicate())
log, } catch (_) {
}) => return []
value === 'all' }
? log.tasks },
: filter(log.tasks, ({ status }) => status === value), tasksFilteredByStatus: ({ preFilteredTasksLogs, status }) =>
status === 'all'
? preFilteredTasksLogs
: filter(preFilteredTasksLogs, task => task.status === status),
displayedTasks: ({ tasksFilteredByStatus, page }) => { displayedTasks: ({ tasksFilteredByStatus, page }) => {
const start = (page - 1) * ITEMS_PER_PAGE const start = (page - 1) * ITEMS_PER_PAGE
return tasksFilteredByStatus.slice(start, start + ITEMS_PER_PAGE) return tasksFilteredByStatus.slice(start, start + ITEMS_PER_PAGE)
@ -392,9 +414,9 @@ export default decorate([
{_(label)} ({countByStatus[value] || 0}) {_(label)} ({countByStatus[value] || 0})
</span> </span>
), ),
countByStatus: ({ log }) => ({ countByStatus: ({ preFilteredTasksLogs }) => ({
all: get(() => log.tasks.length), all: get(() => preFilteredTasksLogs.length),
...countBy(log.tasks, 'status'), ...countBy(preFilteredTasksLogs, 'status'),
}), }),
options: ({ countByStatus }) => [ options: ({ countByStatus }) => [
{ label: 'allTasks', value: 'all' }, { label: 'allTasks', value: 'all' },
@ -424,21 +446,22 @@ export default decorate([
value: 'success', value: 'success',
}, },
], ],
defaultFilter: ({ countByStatus }) => { status: ({ _status, countByStatus }) =>
if (countByStatus.pending > 0) { defined(_status, () => {
return 'pending' if (countByStatus.pending > 0) {
} return 'pending'
}
if (countByStatus.failure > 0) { if (countByStatus.failure > 0) {
return 'failure' return 'failure'
} }
if (countByStatus.interrupted > 0) { if (countByStatus.interrupted > 0) {
return 'interrupted' return 'interrupted'
} }
return 'all' return 'all'
}, }),
nPages: ({ tasksFilteredByStatus }) => nPages: ({ tasksFilteredByStatus }) =>
Math.ceil(tasksFilteredByStatus.length / ITEMS_PER_PAGE), Math.ceil(tasksFilteredByStatus.length / ITEMS_PER_PAGE),
}, },
@ -453,14 +476,20 @@ export default decorate([
</div> </div>
) : ( ) : (
<div> <div>
<SearchBar
className='mb-1'
filters={SEARCH_BAR_FILTERS}
onChange={effects.onFilterChange}
value={state.filter}
/>
<Select <Select
labelKey='label' labelKey='label'
onChange={effects.setFilter} onChange={effects.onStatusChange}
optionRenderer={state.optionRenderer} optionRenderer={state.optionRenderer}
options={state.options} options={state.options}
required required
simpleValue simpleValue
value={state.filter || state.defaultFilter} value={state.status}
valueKey='value' valueKey='value'
/> />
<Warnings warnings={warnings} /> <Warnings warnings={warnings} />