feat(xo-web/tasks): show tasks for Self Service users (#6217)

See zammad#5436
This commit is contained in:
Rajaa.BARHTAOUI 2022-06-28 18:35:58 +02:00 committed by GitHub
parent c7df11cc6f
commit dae37c6a50
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 269 additions and 232 deletions

View File

@ -10,6 +10,7 @@
- [Backup] Merge delta backups without copying data when using VHD directories on NFS/SMB/local remote(https://github.com/vatesfr/xen-orchestra/pull/6271)) - [Backup] Merge delta backups without copying data when using VHD directories on NFS/SMB/local remote(https://github.com/vatesfr/xen-orchestra/pull/6271))
- [Proxies] Ability to copy the proxy access URL (PR [#6287](https://github.com/vatesfr/xen-orchestra/pull/6287)) - [Proxies] Ability to copy the proxy access URL (PR [#6287](https://github.com/vatesfr/xen-orchestra/pull/6287))
- [User] User tokens management through XO interface (PR [#6276](https://github.com/vatesfr/xen-orchestra/pull/6276)) - [User] User tokens management through XO interface (PR [#6276](https://github.com/vatesfr/xen-orchestra/pull/6276))
- [Tasks, VM/General] Self Service users: show tasks related to their pools, hosts, SRs, networks and VMs (PR [#6217](https://github.com/vatesfr/xen-orchestra/pull/6217))
### Bug fixes ### Bug fixes

View File

@ -1,4 +1,5 @@
import add from 'lodash/add' import add from 'lodash/add'
import defined from '@xen-orchestra/defined'
import { check as checkPermissions } from 'xo-acl-resolver' import { check as checkPermissions } from 'xo-acl-resolver'
import { createSelector as create } from 'reselect' import { createSelector as create } from 'reselect'
import { import {
@ -6,6 +7,7 @@ import {
filter, filter,
find, find,
forEach, forEach,
forOwn,
groupBy, groupBy,
identity, identity,
isArrayLike, isArrayLike,
@ -588,3 +590,60 @@ export const createGetHostState = getHost =>
(powerState, enabled, operations) => (powerState, enabled, operations) =>
powerState !== 'Running' ? powerState : !isEmpty(operations) ? 'Busy' : !enabled ? 'Disabled' : 'Running' powerState !== 'Running' ? powerState : !isEmpty(operations) ? 'Busy' : !enabled ? 'Disabled' : 'Running'
) )
const taskPredicate = obj => !isEmpty(obj.current_operations)
const getLinkedObjectsByTaskRefOrId = create(
createGetObjectsOfType('pool').filter([taskPredicate]),
createGetObjectsOfType('host').filter([taskPredicate]),
createGetObjectsOfType('SR').filter([taskPredicate]),
createGetObjectsOfType('VDI').filter([taskPredicate]),
createGetObjectsOfType('VM').filter([taskPredicate]),
createGetObjectsOfType('network').filter([taskPredicate]),
getCheckPermissions,
(pools, hosts, srs, vdis, vms, networks, check) => {
const linkedObjectsByTaskRefOrId = {}
const resolveLinkedObjects = obj => {
if (!check(obj.id, 'view')) {
return
}
Object.keys(obj.current_operations).forEach(task => {
if (linkedObjectsByTaskRefOrId[task] === undefined) {
linkedObjectsByTaskRefOrId[task] = []
}
linkedObjectsByTaskRefOrId[task].push(obj)
})
}
forOwn(pools, resolveLinkedObjects)
forOwn(hosts, resolveLinkedObjects)
forOwn(srs, resolveLinkedObjects)
forOwn(vdis, resolveLinkedObjects)
forOwn(vms, resolveLinkedObjects)
forOwn(networks, resolveLinkedObjects)
return linkedObjectsByTaskRefOrId
}
)
export const getResolvedPendingTasks = create(
createGetObjectsOfType('task').filter([task => task.status === 'pending']),
getLinkedObjectsByTaskRefOrId,
(tasks, linkedObjectsByTaskRefOrId) => {
const resolvedTasks = []
forEach(tasks, task => {
const objects = [
...defined(linkedObjectsByTaskRefOrId[task.xapiRef], []),
// for VMs, the current_operations prop is
// { taskId → operation } map instead of { taskRef → operation } map
...defined(linkedObjectsByTaskRefOrId[task.id], []),
]
objects.length > 0 &&
resolvedTasks.push({
...task,
objects,
})
})
return resolvedTasks
}
)

View File

@ -26,6 +26,7 @@ import {
createGetObjectsOfType, createGetObjectsOfType,
createSelector, createSelector,
getIsPoolAdmin, getIsPoolAdmin,
getResolvedPendingTasks,
getStatus, getStatus,
getUser, getUser,
getXoaState, getXoaState,
@ -44,18 +45,19 @@ const returnTrue = () => true
@connectStore( @connectStore(
() => { () => {
const getHosts = createGetObjectsOfType('host') const getHosts = createGetObjectsOfType('host')
return { return (state, props) => ({
hosts: getHosts, hosts: getHosts(state, props),
isAdmin, isAdmin: isAdmin(state, props),
isPoolAdmin: getIsPoolAdmin, isPoolAdmin: getIsPoolAdmin(state, props),
nHosts: getHosts.count(), nHosts: getHosts.count()(state, props),
nTasks: createGetObjectsOfType('task').count([task => task.status === 'pending']), // true: useResourceSet to bypass permissions
pools: createGetObjectsOfType('pool'), nResolvedTasks: getResolvedPendingTasks(state, props, true).length,
srs: createGetObjectsOfType('SR'), pools: createGetObjectsOfType('pool')(state, props),
status: getStatus, srs: createGetObjectsOfType('SR')(state, props),
user: getUser, status: getStatus(state, props),
xoaState: getXoaState, user: getUser(state, props),
} xoaState: getXoaState(state, props),
})
}, },
{ {
withRef: true, withRef: true,
@ -207,7 +209,7 @@ export default class Menu extends Component {
} }
render() { render() {
const { isAdmin, isPoolAdmin, nTasks, state, status, user, pools, nHosts, srs, xoaState } = this.props const { isAdmin, isPoolAdmin, nResolvedTasks, state, status, user, pools, nHosts, srs, xoaState } = this.props
const noOperatablePools = this._getNoOperatablePools() const noOperatablePools = this._getNoOperatablePools()
const noResourceSets = this._getNoResourceSets() const noResourceSets = this._getNoResourceSets()
const noNotifications = this._getNoNotifications() const noNotifications = this._getNoNotifications()
@ -470,11 +472,11 @@ export default class Menu extends Component {
], ],
}, },
isAdmin && { to: '/about', icon: 'menu-about', label: 'aboutPage' }, isAdmin && { to: '/about', icon: 'menu-about', label: 'aboutPage' },
!noOperatablePools && { {
to: '/tasks', to: '/tasks',
icon: 'task', icon: 'task',
label: 'taskMenu', label: 'taskMenu',
pill: nTasks, pill: nResolvedTasks,
}, },
isAdmin && { to: '/xosan', icon: 'menu-xosan', label: 'xosan' }, isAdmin && { to: '/xosan', icon: 'menu-xosan', label: 'xosan' },
!noOperatablePools && { !noOperatablePools && {

View File

@ -1,19 +1,25 @@
import _, { messages } from 'intl' import _, { messages } from 'intl'
import Collapse from 'collapse' import Collapse from 'collapse'
import Component from 'base-component' import Component from 'base-component'
import defined from '@xen-orchestra/defined'
import Icon from 'icon' import Icon from 'icon'
import Link from 'link' import Link from 'link'
import React from 'react' import React from 'react'
import renderXoItem, { Pool } from 'render-xo-item' import renderXoItem, { Pool } from 'render-xo-item'
import SortedTable from 'sorted-table' import SortedTable from 'sorted-table'
import { addSubscriptions, connectStore, resolveIds } from 'utils'
import { FormattedDate, FormattedRelative, injectIntl } from 'react-intl' import { FormattedDate, FormattedRelative, injectIntl } from 'react-intl'
import { SelectPool } from 'select-objects' import { SelectPool } from 'select-objects'
import { connectStore, resolveIds } from 'utils'
import { Col, Container, Row } from 'grid' import { Col, Container, Row } from 'grid'
import { differenceBy, flatMap, flatten, forOwn, groupBy, isEmpty, keys, map, some, toArray } from 'lodash' import { differenceBy, flatMap, groupBy, isEmpty, keys, map, some } from 'lodash'
import { createFilter, createGetObject, createGetObjectsOfType, createSelector } from 'selectors' import {
import { cancelTask, cancelTasks, destroyTask, destroyTasks } from 'xo' createFilter,
createGetObject,
createGetObjectsOfType,
createSelector,
getResolvedPendingTasks,
isAdmin,
} from 'selectors'
import { cancelTask, cancelTasks, destroyTask, destroyTasks, subscribePermissions } from 'xo'
import Page from '../page' import Page from '../page'
@ -182,66 +188,25 @@ const GROUPED_ACTIONS = [
}, },
] ]
@addSubscriptions({
permissions: subscribePermissions,
})
@connectStore(() => { @connectStore(() => {
const getPendingTasks = createGetObjectsOfType('task').filter([task => task.status === 'pending']) const getResolvedPendingTasksByPool = createSelector(getResolvedPendingTasks, resolvedPendingTasks =>
groupBy(resolvedPendingTasks, '$pool')
)
const getNPendingTasks = getPendingTasks.count() const getPools = createGetObjectsOfType('pool').pick(createSelector(getResolvedPendingTasksByPool, keys))
const predicate = obj => !isEmpty(obj.current_operations) return (state, props) => {
// true: useResourceSet to bypass permissions
const getLinkedObjectsByTaskRefOrId = createSelector( const resolvedPendingTasksByPool = getResolvedPendingTasks(state, props, true)
createGetObjectsOfType('pool').filter([predicate]), return {
createGetObjectsOfType('host').filter([predicate]), isAdmin: isAdmin(state, props),
createGetObjectsOfType('SR').filter([predicate]), nResolvedTasks: resolvedPendingTasksByPool.length,
createGetObjectsOfType('VDI').filter([predicate]), pools: getPools(state, props, true),
createGetObjectsOfType('VM').filter([predicate]), resolvedPendingTasksByPool,
createGetObjectsOfType('network').filter([predicate]),
(pools, hosts, srs, vdis, vms, networks) => {
const linkedObjectsByTaskRefOrId = {}
const resolveLinkedObjects = obj => {
Object.keys(obj.current_operations).forEach(task => {
if (linkedObjectsByTaskRefOrId[task] === undefined) {
linkedObjectsByTaskRefOrId[task] = []
}
linkedObjectsByTaskRefOrId[task].push(obj)
})
}
forOwn(pools, resolveLinkedObjects)
forOwn(hosts, resolveLinkedObjects)
forOwn(srs, resolveLinkedObjects)
forOwn(vdis, resolveLinkedObjects)
forOwn(vms, resolveLinkedObjects)
forOwn(networks, resolveLinkedObjects)
return linkedObjectsByTaskRefOrId
} }
)
const getPendingTasksByPool = createSelector(
getPendingTasks,
getLinkedObjectsByTaskRefOrId,
(tasks, linkedObjectsByTaskRefOrId) =>
groupBy(
map(tasks, task => ({
...task,
objects: [
...defined(linkedObjectsByTaskRefOrId[task.xapiRef], []),
// for VMs, the current_operations prop is
// { taskId → operation } map instead of { taskRef → operation } map
...defined(linkedObjectsByTaskRefOrId[task.id], []),
],
})),
'$pool'
)
)
const getPools = createGetObjectsOfType('pool').pick(createSelector(getPendingTasksByPool, keys))
return {
nTasks: getNPendingTasks,
pendingTasksByPool: getPendingTasksByPool,
pools: getPools,
} }
}) })
@injectIntl @injectIntl
@ -251,11 +216,7 @@ export default class Tasks extends Component {
} }
componentWillReceiveProps(props) { componentWillReceiveProps(props) {
const finishedTasks = differenceBy( const finishedTasks = differenceBy(this.props.resolvedPendingTasksByPool, props.resolvedPendingTasksByPool, 'id')
flatten(toArray(this.props.pendingTasksByPool)),
flatten(toArray(props.pendingTasksByPool)),
'id'
)
if (!isEmpty(finishedTasks)) { if (!isEmpty(finishedTasks)) {
this.setState({ this.setState({
finishedTasks: finishedTasks finishedTasks: finishedTasks
@ -267,11 +228,11 @@ export default class Tasks extends Component {
_getTasks = createSelector( _getTasks = createSelector(
createSelector(() => this.state.pools, resolveIds), createSelector(() => this.state.pools, resolveIds),
() => this.props.pendingTasksByPool, () => this.props.resolvedPendingTasksByPool,
(poolIds, pendingTasksByPool) => (poolIds, resolvedPendingTasksByPool) =>
isEmpty(poolIds) isEmpty(poolIds)
? flatten(toArray(pendingTasksByPool)) ? resolvedPendingTasksByPool
: flatMap(poolIds, poolId => pendingTasksByPool[poolId] || []) : flatMap(poolIds, poolId => resolvedPendingTasksByPool[poolId] || [])
) )
_getFinishedTasks = createFilter( _getFinishedTasks = createFilter(
@ -288,11 +249,11 @@ export default class Tasks extends Component {
render() { render() {
const { props } = this const { props } = this
const { intl, nTasks, pools } = props const { intl, nResolvedTasks, pools } = props
const { formatMessage } = intl const { formatMessage } = intl
return ( return (
<Page header={HEADER} title={`(${nTasks}) ${formatMessage(messages.taskPage)}`}> <Page header={HEADER} title={`(${nResolvedTasks}) ${formatMessage(messages.taskPage)}`}>
<Container> <Container>
<Row className='mb-1'> <Row className='mb-1'>
<Col mediumSize={7}> <Col mediumSize={7}>

View File

@ -1,5 +1,6 @@
import _ from 'intl' import _ from 'intl'
import Copiable from 'copiable' import Copiable from 'copiable'
import decorate from 'apply-decorators'
import defined, { get } from '@xen-orchestra/defined' import defined, { get } from '@xen-orchestra/defined'
import Icon from 'icon' import Icon from 'icon'
import isEmpty from 'lodash/isEmpty' import isEmpty from 'lodash/isEmpty'
@ -14,14 +15,15 @@ import { FormattedRelative, FormattedDate } from 'react-intl'
import { Container, Row, Col } from 'grid' import { Container, Row, Col } from 'grid'
import { Number, Size } from 'editable' import { Number, Size } from 'editable'
import { import {
createCollectionWrapper,
createFinder, createFinder,
createGetObjectsOfType, createGetObjectsOfType,
createGetVmLastShutdownTime, createGetVmLastShutdownTime,
createSelector, createSelector,
getResolvedPendingTasks,
} from 'selectors' } from 'selectors'
import { connectStore, formatSizeShort, getVirtualizationModeLabel, osFamily } from 'utils' import { connectStore, formatSizeShort, getVirtualizationModeLabel, osFamily } from 'utils'
import { CpuSparkLines, MemorySparkLines, NetworkSparkLines, XvdSparkLines } from 'xo-sparklines' import { CpuSparkLines, MemorySparkLines, NetworkSparkLines, XvdSparkLines } from 'xo-sparklines'
import { injectState, provideState } from 'reaclette'
const GuestToolsDetection = ({ vm }) => { const GuestToolsDetection = ({ vm }) => {
if (vm.power_state !== 'Running' || vm.pvDriversDetected === undefined) { if (vm.power_state !== 'Running' || vm.pvDriversDetected === undefined) {
@ -78,153 +80,165 @@ const GuestToolsDetection = ({ vm }) => {
) )
} }
export default connectStore(() => { const GeneralTab = decorate([
const getVgpus = createGetObjectsOfType('vgpu') connectStore(() => {
.pick((_, { vm }) => vm.$VGPUs) const getVgpus = createGetObjectsOfType('vgpu')
.sort() .pick((_, { vm }) => vm.$VGPUs)
.sort()
const getAttachedVgpu = createFinder(getVgpus, vgpu => vgpu.currentlyAttached) const getAttachedVgpu = createFinder(getVgpus, vgpu => vgpu.currentlyAttached)
const getVgpuTypes = createGetObjectsOfType('vgpuType').pick( const getVgpuTypes = createGetObjectsOfType('vgpuType').pick(
createSelector(getVgpus, vgpus => map(vgpus, 'vgpuType')) createSelector(getVgpus, vgpus => map(vgpus, 'vgpuType'))
) )
return { return (state, props) => ({
lastShutdownTime: createGetVmLastShutdownTime(), lastShutdownTime: createGetVmLastShutdownTime()(state, props),
tasks: createGetObjectsOfType('task') // true: useResourceSet to bypass permissions
.pick(createSelector((_, { vm }) => vm.current_operations, createCollectionWrapper(Object.keys))) resolvedPendingTasks: getResolvedPendingTasks(state, props, true),
.filter({ status: 'pending' }) vgpu: getAttachedVgpu(state, props),
.sort(), vgpuTypes: getVgpuTypes(state, props),
vgpu: getAttachedVgpu, })
vgpuTypes: getVgpuTypes, }),
} provideState({
})(({ lastShutdownTime, statsOverview, tasks, vgpu, vgpuTypes, vm, vmTotalDiskSpace }) => { computed: {
const { vmResolvedPendingTasks: (_, { resolvedPendingTasks, vm }) => {
CPUs: cpus, const vmTaskIds = Object.keys(vm.current_operations)
id, return resolvedPendingTasks.filter(task => vmTaskIds.includes(task.id))
installTime, },
mainIpAddress, },
memory, }),
os_version: osVersion, injectState,
power_state: powerState, ({ state: { vmResolvedPendingTasks }, lastShutdownTime, statsOverview, vgpu, vgpuTypes, vm, vmTotalDiskSpace }) => {
startTime, const {
tags, CPUs: cpus,
VIFs: vifs, id,
} = vm installTime,
return ( mainIpAddress,
<Container> memory,
{/* TODO: use CSS style */} os_version: osVersion,
<br /> power_state: powerState,
<Row className='text-xs-center'> startTime,
<Col mediumSize={3}> tags,
<h2> VIFs: vifs,
<Number value={cpus.number} onChange={vcpus => editVm(vm, { CPUs: vcpus })} /> } = vm
x <Icon icon='cpu' size='lg' /> return (
</h2> <Container>
<BlockLink to={`/vms/${id}/stats`}>{statsOverview && <CpuSparkLines data={statsOverview} />}</BlockLink> {/* TODO: use CSS style */}
</Col> <br />
<Col mediumSize={3}>
<h2 className='form-inline'>
<Size value={defined(memory.dynamic[1], null)} onChange={memory => editVm(vm, { memory })} />
&nbsp;
<span>
<Icon icon='memory' size='lg' />
</span>
</h2>
<BlockLink to={`/vms/${id}/stats`}>{statsOverview && <MemorySparkLines data={statsOverview} />}</BlockLink>
</Col>
<Col mediumSize={3}>
<BlockLink to={`/vms/${id}/network`}>
<h2>
{vifs.length}x <Icon icon='network' size='lg' />
</h2>
</BlockLink>
<BlockLink to={`/vms/${id}/stats`}>{statsOverview && <NetworkSparkLines data={statsOverview} />}</BlockLink>
</Col>
<Col mediumSize={3}>
<BlockLink to={`/vms/${id}/disks`}>
<h2>
{formatSizeShort(vmTotalDiskSpace)} <Icon icon='disk' size='lg' />
</h2>
</BlockLink>
<BlockLink to={`/vms/${id}/stats`}>{statsOverview && <XvdSparkLines data={statsOverview} />}</BlockLink>
</Col>
</Row>
{/* TODO: use CSS style */}
<br />
<Row className='text-xs-center'>
<Col mediumSize={3}>
{installTime !== null && (
<div className='text-xs-center'>
{_('created', {
date: <FormattedDate day='2-digit' month='long' value={installTime * 1000} year='numeric' />,
})}
</div>
)}
{powerState === 'Running' || powerState === 'Paused' ? (
<div>
<p className='text-xs-center'>
{_('started', {
ago: <FormattedRelative value={startTime * 1000} />,
})}
</p>
</div>
) : (
<p className='text-xs-center'>
{lastShutdownTime
? _('vmHaltedSince', {
ago: <FormattedRelative value={lastShutdownTime * 1000} />,
})
: _('vmNotRunning')}
</p>
)}
</Col>
<Col mediumSize={3}>
<p>{getVirtualizationModeLabel(vm)}</p>
{vgpu !== undefined && <p>{renderXoItem(vgpuTypes[vgpu.vgpuType])}</p>}
</Col>
<Col mediumSize={3}>
<BlockLink to={`/vms/${id}/network`}>
{mainIpAddress !== undefined ? (
<Copiable tagName='p'>{mainIpAddress}</Copiable>
) : (
<p>{_('noIpv4Record')}</p>
)}
</BlockLink>
</Col>
<Col mediumSize={3}>
<BlockLink to={`/vms/${id}/advanced`}>
<Tooltip content={osVersion ? osVersion.name : _('unknownOsName')}>
<h1>
<Icon className='text-info' icon={osVersion && osVersion.distro && osFamily(osVersion.distro)} />
</h1>
</Tooltip>
</BlockLink>
</Col>
</Row>
<GuestToolsDetection vm={vm} />
{/* TODO: use CSS style */}
<br />
<Row>
<Col>
<h2 className='text-xs-center'>
<HomeTags type='VM' labels={tags} onDelete={tag => removeTag(id, tag)} onAdd={tag => addTag(id, tag)} />
</h2>
</Col>
</Row>
{isEmpty(tasks) ? null : (
<Row className='text-xs-center'> <Row className='text-xs-center'>
<Col> <Col mediumSize={3}>
<h4>{_('vmCurrentStatus')}</h4> <h2>
{map(tasks, task => ( <Number value={cpus.number} onChange={vcpus => editVm(vm, { CPUs: vcpus })} />
<p> x <Icon icon='cpu' size='lg' />
<strong>{task.name_label}</strong> </h2>
{task.progress > 0 && <span>: {Math.round(task.progress * 100)}%</span>} <BlockLink to={`/vms/${id}/stats`}>{statsOverview && <CpuSparkLines data={statsOverview} />}</BlockLink>
</p> </Col>
))} <Col mediumSize={3}>
<h2 className='form-inline'>
<Size value={defined(memory.dynamic[1], null)} onChange={memory => editVm(vm, { memory })} />
&nbsp;
<span>
<Icon icon='memory' size='lg' />
</span>
</h2>
<BlockLink to={`/vms/${id}/stats`}>{statsOverview && <MemorySparkLines data={statsOverview} />}</BlockLink>
</Col>
<Col mediumSize={3}>
<BlockLink to={`/vms/${id}/network`}>
<h2>
{vifs.length}x <Icon icon='network' size='lg' />
</h2>
</BlockLink>
<BlockLink to={`/vms/${id}/stats`}>{statsOverview && <NetworkSparkLines data={statsOverview} />}</BlockLink>
</Col>
<Col mediumSize={3}>
<BlockLink to={`/vms/${id}/disks`}>
<h2>
{formatSizeShort(vmTotalDiskSpace)} <Icon icon='disk' size='lg' />
</h2>
</BlockLink>
<BlockLink to={`/vms/${id}/stats`}>{statsOverview && <XvdSparkLines data={statsOverview} />}</BlockLink>
</Col> </Col>
</Row> </Row>
)} {/* TODO: use CSS style */}
</Container> <br />
) <Row className='text-xs-center'>
}) <Col mediumSize={3}>
{installTime !== null && (
<div className='text-xs-center'>
{_('created', {
date: <FormattedDate day='2-digit' month='long' value={installTime * 1000} year='numeric' />,
})}
</div>
)}
{powerState === 'Running' || powerState === 'Paused' ? (
<div>
<p className='text-xs-center'>
{_('started', {
ago: <FormattedRelative value={startTime * 1000} />,
})}
</p>
</div>
) : (
<p className='text-xs-center'>
{lastShutdownTime
? _('vmHaltedSince', {
ago: <FormattedRelative value={lastShutdownTime * 1000} />,
})
: _('vmNotRunning')}
</p>
)}
</Col>
<Col mediumSize={3}>
<p>{getVirtualizationModeLabel(vm)}</p>
{vgpu !== undefined && <p>{renderXoItem(vgpuTypes[vgpu.vgpuType])}</p>}
</Col>
<Col mediumSize={3}>
<BlockLink to={`/vms/${id}/network`}>
{mainIpAddress !== undefined ? (
<Copiable tagName='p'>{mainIpAddress}</Copiable>
) : (
<p>{_('noIpv4Record')}</p>
)}
</BlockLink>
</Col>
<Col mediumSize={3}>
<BlockLink to={`/vms/${id}/advanced`}>
<Tooltip content={osVersion ? osVersion.name : _('unknownOsName')}>
<h1>
<Icon className='text-info' icon={osVersion && osVersion.distro && osFamily(osVersion.distro)} />
</h1>
</Tooltip>
</BlockLink>
</Col>
</Row>
<GuestToolsDetection vm={vm} />
{/* TODO: use CSS style */}
<br />
<Row>
<Col>
<h2 className='text-xs-center'>
<HomeTags type='VM' labels={tags} onDelete={tag => removeTag(id, tag)} onAdd={tag => addTag(id, tag)} />
</h2>
</Col>
</Row>
{isEmpty(vmResolvedPendingTasks) ? null : (
<Row className='text-xs-center'>
<Col>
<h4>{_('vmCurrentStatus')}</h4>
{map(vmResolvedPendingTasks, task => (
<p>
<strong>{task.name_label}</strong>
{task.progress > 0 && <span>: {Math.round(task.progress * 100)}%</span>}
</p>
))}
</Col>
</Row>
)}
</Container>
)
},
])
export default GeneralTab