parent
2aed2fd534
commit
45fe70f0fa
@ -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
|
||||||
|
|
||||||
|
@ -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',
|
||||||
|
88
packages/xo-web/src/common/search-bar.js
Normal file
88
packages/xo-web/src/common/search-bar.js
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -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,
|
||||||
|
@ -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} />
|
||||||
|
Loading…
Reference in New Issue
Block a user