feat(xo-web/backup/health): show detached backups (#5062)

See #4716
This commit is contained in:
Rajaa.BARHTAOUI 2020-06-26 14:45:01 +02:00 committed by GitHub
parent fe722c8b31
commit 6d1048e5c5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 299 additions and 79 deletions

View File

@ -17,6 +17,7 @@
- [SR/Disks] Add tooltip for disabled migration (PR [#4884](https://github.com/vatesfr/xen-orchestra/pull/4884))
- [SR/Advanced, SR selector] Show thin/thick provisioning [#2208](https://github.com/vatesfr/xen-orchestra/issues/2208) (PR [#5081](https://github.com/vatesfr/xen-orchestra/pull/5081))
- [Licenses] Ability to move a license from another XOA to the current XOA (PR [#5110](https://github.com/vatesfr/xen-orchestra/pull/5110))
- [Backup/health] Show VM backups with missing jobs, schedules and VMs [#4716](https://github.com/vatesfr/xen-orchestra/issues/4716) (PR [#5062](https://github.com/vatesfr/xen-orchestra/pull/5062))
### Bug fixes

View File

@ -4030,7 +4030,7 @@ export default {
vmsToBackup: 'VMs per il backup',
// Original text: 'Refresh backup list'
restoreResfreshList: "Aggiorna l'elenco di backup",
refreshBackupList: "Aggiorna l'elenco di backup",
// Original text: 'Legacy restore'
restoreLegacy: 'Ripristino legacy',

View File

@ -3430,7 +3430,7 @@ export default {
vmsToBackup: "Yedeklenecek VM'ler",
// Original text: "Refresh backup list"
restoreResfreshList: 'Yedek listesini yenile',
refreshBackupList: 'Yedek listesini yenile',
// Original text: "Restore"
restoreVmBackups: 'Geri yükle',

View File

@ -1324,6 +1324,16 @@ const messages = {
metricsLoading: 'Loading…',
// ----- Health -----
deleteBackups: 'Delete backup{nBackups, plural, one {} other {s}}',
deleteBackupsMessage:
'Are you sure you want to delete {nBackups, number} backup{nBackups, plural, one {} other {s}}?',
detachedBackups: 'Detached backups',
missingJob: 'Missing job',
missingVm: 'Missing VM',
missingVmInJob: 'This VM does not belong to this job',
missingSchedule: 'Missing schedule',
noDetachedBackups: 'No backups',
reason: 'Reason',
orphanedVdis: 'Orphaned snapshot VDIs',
orphanedVms: 'Orphaned VMs snapshot',
noOrphanedObject: 'No orphans',
@ -1542,7 +1552,7 @@ const messages = {
importBackupTitle: 'Import VM',
importBackupMessage: 'Starting your backup import',
vmsToBackup: 'VMs to backup',
restoreResfreshList: 'Refresh backup list',
refreshBackupList: 'Refresh backup list',
restoreLegacy: 'Legacy restore',
restoreFileLegacy: 'Legacy file restore',
restoreVmBackups: 'Restore',

View File

@ -14,6 +14,7 @@ import { createGetObject, createSelector } from './selectors'
import { FormattedDate } from 'react-intl'
import {
isSrWritable,
subscribeBackupNgJobs,
subscribeProxies,
subscribeRemotes,
subscribeUsers,
@ -21,11 +22,13 @@ import {
// ===================================================================
const unknowItem = (uuid, type) => (
const unknowItem = (uuid, type, placeholder) => (
<Tooltip content={_('copyUuid', { uuid })}>
<CopyToClipboard text={uuid}>
<span className='text-muted' style={{ cursor: 'pointer' }}>
{_('errorUnknownItem', { type })}
{placeholder === undefined
? _('errorUnknownItem', { type })
: placeholder}
</span>
</CopyToClipboard>
</Tooltip>
@ -142,9 +145,9 @@ export const Vm = decorate([
),
}
}),
({ id, vm, container, link, newTab }) => {
({ id, vm, container, link, newTab, name }) => {
if (vm === undefined) {
return unknowItem(id, 'VM')
return unknowItem(id, 'VM', name)
}
return (
@ -159,6 +162,7 @@ export const Vm = decorate([
Vm.propTypes = {
id: PropTypes.string.isRequired,
link: PropTypes.bool,
name: PropTypes.string,
newTab: PropTypes.bool,
}
@ -425,6 +429,41 @@ Proxy.defaultProps = {
// ===================================================================
export const BackupJob = decorate([
addSubscriptions(({ id }) => ({
job: cb =>
subscribeBackupNgJobs(jobs => cb(jobs.find(job => job.id === id))),
})),
({ id, job, link, newTab }) => {
if (job === undefined) {
return unknowItem(id, 'job')
}
return (
<LinkWrapper
link={link}
newTab={newTab}
to={`/backup/overview?s=id:${id}`}
>
{job.name}
</LinkWrapper>
)
},
])
BackupJob.propTypes = {
id: PropTypes.string.isRequired,
link: PropTypes.bool,
newTab: PropTypes.bool,
}
BackupJob.defaultProps = {
link: false,
newTab: false,
}
// ===================================================================
export const Vgpu = connectStore(() => ({
vgpuType: createGetObject((_, props) => props.vgpu.vgpuType),
}))(({ vgpu, vgpuType }) => (

View File

@ -2307,6 +2307,8 @@ export const getResourceSet = id =>
// Remote ------------------------------------------------------------
export const getRemotes = () => _call('remote.getAll')
export const getRemote = remote =>
_call('remote.get', resolveIds({ id: remote }))::tap(null, err =>
error(_('getRemote'), err.message || String(err))

View File

@ -230,7 +230,7 @@ export default class Restore extends Component {
handler={this._refreshBackupList}
icon='refresh'
>
{_('restoreResfreshList')}
{_('refreshBackupList')}
</ActionButton>
</div>
<em>

View File

@ -1,17 +1,87 @@
import _ from 'intl'
import Component from 'base-component'
import ActionButton from 'action-button'
import decorate from 'apply-decorators'
import defined, { get } from '@xen-orchestra/defined'
import Icon from 'icon'
import Link from 'link'
import NoObjects from 'no-objects'
import React from 'react'
import renderXoItem from 'render-xo-item'
import renderXoItem, { BackupJob, Vm } from 'render-xo-item'
import SortedTable from 'sorted-table'
import { addSubscriptions, connectStore } from 'utils'
import { Card, CardHeader, CardBlock } from 'card'
import { Container, Row, Col } from 'grid'
import { confirm } from 'modal'
import { createPredicate } from 'value-matcher'
import { createGetLoneSnapshots, createGetObjectsOfType } from 'selectors'
import { deleteSnapshot, deleteSnapshots, subscribeSchedules } from 'xo'
import { FormattedRelative, FormattedTime } from 'react-intl'
import { forEach, keyBy, omit, toArray } from 'lodash'
import { FormattedDate, FormattedRelative, FormattedTime } from 'react-intl'
import { injectState, provideState } from 'reaclette'
import {
deleteBackups,
deleteSnapshot,
deleteSnapshots,
getRemotes,
listVmBackups,
subscribeBackupNgJobs,
subscribeSchedules,
} from 'xo'
const DETACHED_BACKUP_COLUMNS = [
{
name: _('date'),
itemRenderer: backup => (
<FormattedDate
value={new Date(backup.timestamp)}
month='long'
day='numeric'
year='numeric'
hour='2-digit'
minute='2-digit'
second='2-digit'
/>
),
sortCriteria: 'timestamp',
sortOrder: 'desc',
},
{
name: _('vm'),
itemRenderer: ({ vm, vmId }) => <Vm id={vmId} link name={vm.name_label} />,
sortCriteria: ({ vm, vmId }, { vms }) => defined(vms[vmId], vm).name_label,
},
{
name: _('job'),
itemRenderer: ({ jobId }) => <BackupJob id={jobId} link />,
sortCriteria: ({ jobId }, { jobs }) => get(() => jobs[jobId].name),
},
{
name: _('jobModes'),
valuePath: 'mode',
},
{
name: _('reason'),
itemRenderer: ({ reason }) => _(reason),
sortCriteria: 'reason',
},
]
const DETACHED_BACKUP_ACTIONS = [
{
handler: (backups, { fetchBackupList }) => {
const nBackups = backups.length
return confirm({
title: _('deleteBackups', { nBackups }),
body: _('deleteBackupsMessage', { nBackups }),
icon: 'delete',
})
.then(() => deleteBackups(backups), noop)
.then(fetchBackupList)
},
icon: 'delete',
label: backups => _('deleteBackups', { nBackups: backups.length }),
level: 'danger',
},
]
const SNAPSHOT_COLUMNS = [
{
@ -66,69 +136,167 @@ const ACTIONS = [
},
]
@addSubscriptions({
// used by createGetLoneSnapshots
schedules: subscribeSchedules,
})
@connectStore({
loneSnapshots: createGetLoneSnapshots,
legacySnapshots: createGetObjectsOfType('VM-snapshot').filter([
(() => {
const RE = /^(?:XO_DELTA_EXPORT:|XO_DELTA_BASE_VM_SNAPSHOT_|rollingSnapshot_)/
return (
{ name_label } // eslint-disable-line camelcase
) => RE.test(name_label)
})(),
]),
vms: createGetObjectsOfType('VM'),
})
export default class Health extends Component {
render() {
return (
<Container>
<Row className='lone-snapshots'>
<Col>
<Card>
<CardHeader>
<Icon icon='vm' /> {_('vmSnapshotsRelatedToNonExistentBackups')}
</CardHeader>
<CardBlock>
<NoObjects
actions={ACTIONS}
collection={this.props.loneSnapshots}
columns={SNAPSHOT_COLUMNS}
component={SortedTable}
data-vms={this.props.vms}
emptyMessage={_('noSnapshots')}
shortcutsTarget='.lone-snapshots'
stateUrlParam='s_vm_snapshots'
/>
</CardBlock>
</Card>
</Col>
</Row>
<Row className='legacy-snapshots'>
<Col>
<Card>
<CardHeader>
<Icon icon='vm' /> {_('legacySnapshots')}
</CardHeader>
<CardBlock>
<NoObjects
actions={ACTIONS}
collection={this.props.legacySnapshots}
columns={SNAPSHOT_COLUMNS}
component={SortedTable}
data-vms={this.props.vms}
emptyMessage={_('noSnapshots')}
shortcutsTarget='.legacy-snapshots'
stateUrlParam='s_legacy_vm_snapshots'
/>
</CardBlock>
</Card>
</Col>
</Row>
</Container>
)
}
}
const Health = decorate([
addSubscriptions({
// used by createGetLoneSnapshots
schedules: cb =>
subscribeSchedules(schedules => {
cb(keyBy(schedules, 'id'))
}),
jobs: cb =>
subscribeBackupNgJobs(jobs => {
cb(keyBy(jobs, 'id'))
}),
}),
connectStore({
loneSnapshots: createGetLoneSnapshots,
legacySnapshots: createGetObjectsOfType('VM-snapshot').filter([
(() => {
const RE = /^(?:XO_DELTA_EXPORT:|XO_DELTA_BASE_VM_SNAPSHOT_|rollingSnapshot_)/
return (
{ name_label } // eslint-disable-line camelcase
) => RE.test(name_label)
})(),
]),
vms: createGetObjectsOfType('VM'),
}),
provideState({
initialState: () => ({
backupsByRemote: undefined,
}),
effects: {
initialize({ fetchBackupList }) {
return fetchBackupList()
},
async fetchBackupList() {
this.state.backupsByRemote = await listVmBackups(
toArray(await getRemotes())
)
},
},
computed: {
detachedBackups: ({ backupsByRemote }, { jobs, vms, schedules }) => {
if (jobs === undefined || schedules === undefined) {
return []
}
const detachedBackups = []
let job
forEach(backupsByRemote, backupsByVm => {
forEach(backupsByVm, (vmBackups, vmId) => {
const vm = vms[vmId]
vmBackups.forEach(backup => {
const reason =
vm === undefined
? 'missingVm'
: (job = jobs[backup.jobId]) === undefined
? 'missingJob'
: schedules[backup.scheduleId] === undefined
? 'missingSchedule'
: !createPredicate(omit(job.vms, 'power_state'))(vm)
? 'missingVmInJob'
: undefined
if (reason !== undefined) {
detachedBackups.push({
...backup,
vmId,
reason,
})
}
})
})
})
return detachedBackups
},
},
}),
injectState,
({
effects: { fetchBackupList },
jobs,
legacySnapshots,
loneSnapshots,
state: { detachedBackups },
vms,
}) => (
<Container>
<Row className='detached-backups'>
<Col>
<Card>
<CardHeader>
<Icon icon='backup' /> {_('detachedBackups')}
</CardHeader>
<CardBlock>
<div className='mb-1'>
<ActionButton
btnStyle='primary'
handler={fetchBackupList}
icon='refresh'
>
{_('refreshBackupList')}
</ActionButton>
</div>
<NoObjects
actions={DETACHED_BACKUP_ACTIONS}
collection={detachedBackups}
columns={DETACHED_BACKUP_COLUMNS}
component={SortedTable}
data-fetchBackupList={fetchBackupList}
data-jobs={jobs}
data-vms={vms}
emptyMessage={_('noDetachedBackups')}
shortcutsTarget='.detached-backups'
stateUrlParam='s_detached_backups'
/>
</CardBlock>
</Card>
</Col>
</Row>
<Row className='lone-snapshots'>
<Col>
<Card>
<CardHeader>
<Icon icon='vm' /> {_('vmSnapshotsRelatedToNonExistentBackups')}
</CardHeader>
<CardBlock>
<NoObjects
actions={ACTIONS}
collection={loneSnapshots}
columns={SNAPSHOT_COLUMNS}
component={SortedTable}
data-vms={vms}
emptyMessage={_('noSnapshots')}
shortcutsTarget='.lone-snapshots'
stateUrlParam='s_vm_snapshots'
/>
</CardBlock>
</Card>
</Col>
</Row>
<Row className='legacy-snapshots'>
<Col>
<Card>
<CardHeader>
<Icon icon='vm' /> {_('legacySnapshots')}
</CardHeader>
<CardBlock>
<NoObjects
actions={ACTIONS}
collection={legacySnapshots}
columns={SNAPSHOT_COLUMNS}
component={SortedTable}
data-vms={vms}
emptyMessage={_('noSnapshots')}
shortcutsTarget='.legacy-snapshots'
stateUrlParam='s_legacy_vm_snapshots'
/>
</CardBlock>
</Card>
</Col>
</Row>
</Container>
),
])
export default Health

View File

@ -280,7 +280,7 @@ export default class Restore extends Component {
handler={this._refreshBackupList}
icon='refresh'
>
{_('restoreResfreshList')}
{_('refreshBackupList')}
</ActionButton>{' '}
<ButtonLink to='backup/restore/metadata'>
<Icon icon='database' /> {_('metadata')}