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)
- [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] 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

View File

@ -83,6 +83,8 @@ const messages = {
advancedSettings: 'Advanced settings',
txChecksumming: 'TX checksumming',
unknownSize: 'Unknown size',
remotesNames: 'Remotes names',
srsNames: 'SRs names',
// ----- Modals -----
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 classNames from 'classnames'
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 React from 'react'
import Shortcuts from 'shortcuts'
import { Input as DebouncedInput } from 'debounce-input-decorator'
import { Portal } from 'react-overlays'
import { Set } from 'immutable'
import { Dropdown, MenuItem } from 'react-bootstrap-4/lib'
import { injectState, provideState } from 'reaclette'
import { withRouter } from 'react-router'
import {
@ -32,7 +28,7 @@ import decorate from '../apply-decorators'
import Icon from '../icon'
import Pagination from '../pagination'
import SingleLineRow from '../single-line-row'
import Tooltip from '../tooltip'
import TableFilter from '../search-bar'
import { BlockLink } from '../link'
import { Container, Col } from '../grid'
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 {
static propTypes = {
columnId: PropTypes.number.isRequired,

View File

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