diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 40481a1c1..5e0763248 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -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)) - [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)) +- [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 diff --git a/packages/xo-web/src/common/selectors.js b/packages/xo-web/src/common/selectors.js index e116233e9..f417dfaa2 100644 --- a/packages/xo-web/src/common/selectors.js +++ b/packages/xo-web/src/common/selectors.js @@ -1,4 +1,5 @@ import add from 'lodash/add' +import defined from '@xen-orchestra/defined' import { check as checkPermissions } from 'xo-acl-resolver' import { createSelector as create } from 'reselect' import { @@ -6,6 +7,7 @@ import { filter, find, forEach, + forOwn, groupBy, identity, isArrayLike, @@ -588,3 +590,60 @@ export const createGetHostState = getHost => (powerState, enabled, operations) => 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 + } +) diff --git a/packages/xo-web/src/xo-app/menu/index.js b/packages/xo-web/src/xo-app/menu/index.js index e894fdef8..ee53356b4 100644 --- a/packages/xo-web/src/xo-app/menu/index.js +++ b/packages/xo-web/src/xo-app/menu/index.js @@ -26,6 +26,7 @@ import { createGetObjectsOfType, createSelector, getIsPoolAdmin, + getResolvedPendingTasks, getStatus, getUser, getXoaState, @@ -44,18 +45,19 @@ const returnTrue = () => true @connectStore( () => { const getHosts = createGetObjectsOfType('host') - return { - hosts: getHosts, - isAdmin, - isPoolAdmin: getIsPoolAdmin, - nHosts: getHosts.count(), - nTasks: createGetObjectsOfType('task').count([task => task.status === 'pending']), - pools: createGetObjectsOfType('pool'), - srs: createGetObjectsOfType('SR'), - status: getStatus, - user: getUser, - xoaState: getXoaState, - } + return (state, props) => ({ + hosts: getHosts(state, props), + isAdmin: isAdmin(state, props), + isPoolAdmin: getIsPoolAdmin(state, props), + nHosts: getHosts.count()(state, props), + // true: useResourceSet to bypass permissions + nResolvedTasks: getResolvedPendingTasks(state, props, true).length, + pools: createGetObjectsOfType('pool')(state, props), + srs: createGetObjectsOfType('SR')(state, props), + status: getStatus(state, props), + user: getUser(state, props), + xoaState: getXoaState(state, props), + }) }, { withRef: true, @@ -207,7 +209,7 @@ export default class Menu extends Component { } 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 noResourceSets = this._getNoResourceSets() const noNotifications = this._getNoNotifications() @@ -470,11 +472,11 @@ export default class Menu extends Component { ], }, isAdmin && { to: '/about', icon: 'menu-about', label: 'aboutPage' }, - !noOperatablePools && { + { to: '/tasks', icon: 'task', label: 'taskMenu', - pill: nTasks, + pill: nResolvedTasks, }, isAdmin && { to: '/xosan', icon: 'menu-xosan', label: 'xosan' }, !noOperatablePools && { diff --git a/packages/xo-web/src/xo-app/tasks/index.js b/packages/xo-web/src/xo-app/tasks/index.js index b6491da61..0088bf618 100644 --- a/packages/xo-web/src/xo-app/tasks/index.js +++ b/packages/xo-web/src/xo-app/tasks/index.js @@ -1,19 +1,25 @@ import _, { messages } from 'intl' import Collapse from 'collapse' import Component from 'base-component' -import defined from '@xen-orchestra/defined' import Icon from 'icon' import Link from 'link' import React from 'react' import renderXoItem, { Pool } from 'render-xo-item' import SortedTable from 'sorted-table' +import { addSubscriptions, connectStore, resolveIds } from 'utils' import { FormattedDate, FormattedRelative, injectIntl } from 'react-intl' import { SelectPool } from 'select-objects' -import { connectStore, resolveIds } from 'utils' import { Col, Container, Row } from 'grid' -import { differenceBy, flatMap, flatten, forOwn, groupBy, isEmpty, keys, map, some, toArray } from 'lodash' -import { createFilter, createGetObject, createGetObjectsOfType, createSelector } from 'selectors' -import { cancelTask, cancelTasks, destroyTask, destroyTasks } from 'xo' +import { differenceBy, flatMap, groupBy, isEmpty, keys, map, some } from 'lodash' +import { + createFilter, + createGetObject, + createGetObjectsOfType, + createSelector, + getResolvedPendingTasks, + isAdmin, +} from 'selectors' +import { cancelTask, cancelTasks, destroyTask, destroyTasks, subscribePermissions } from 'xo' import Page from '../page' @@ -182,66 +188,25 @@ const GROUPED_ACTIONS = [ }, ] +@addSubscriptions({ + permissions: subscribePermissions, +}) @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) - - const getLinkedObjectsByTaskRefOrId = createSelector( - createGetObjectsOfType('pool').filter([predicate]), - createGetObjectsOfType('host').filter([predicate]), - createGetObjectsOfType('SR').filter([predicate]), - createGetObjectsOfType('VDI').filter([predicate]), - createGetObjectsOfType('VM').filter([predicate]), - 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 + return (state, props) => { + // true: useResourceSet to bypass permissions + const resolvedPendingTasksByPool = getResolvedPendingTasks(state, props, true) + return { + isAdmin: isAdmin(state, props), + nResolvedTasks: resolvedPendingTasksByPool.length, + pools: getPools(state, props, true), + resolvedPendingTasksByPool, } - ) - - 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 @@ -251,11 +216,7 @@ export default class Tasks extends Component { } componentWillReceiveProps(props) { - const finishedTasks = differenceBy( - flatten(toArray(this.props.pendingTasksByPool)), - flatten(toArray(props.pendingTasksByPool)), - 'id' - ) + const finishedTasks = differenceBy(this.props.resolvedPendingTasksByPool, props.resolvedPendingTasksByPool, 'id') if (!isEmpty(finishedTasks)) { this.setState({ finishedTasks: finishedTasks @@ -267,11 +228,11 @@ export default class Tasks extends Component { _getTasks = createSelector( createSelector(() => this.state.pools, resolveIds), - () => this.props.pendingTasksByPool, - (poolIds, pendingTasksByPool) => + () => this.props.resolvedPendingTasksByPool, + (poolIds, resolvedPendingTasksByPool) => isEmpty(poolIds) - ? flatten(toArray(pendingTasksByPool)) - : flatMap(poolIds, poolId => pendingTasksByPool[poolId] || []) + ? resolvedPendingTasksByPool + : flatMap(poolIds, poolId => resolvedPendingTasksByPool[poolId] || []) ) _getFinishedTasks = createFilter( @@ -288,11 +249,11 @@ export default class Tasks extends Component { render() { const { props } = this - const { intl, nTasks, pools } = props + const { intl, nResolvedTasks, pools } = props const { formatMessage } = intl return ( - + diff --git a/packages/xo-web/src/xo-app/vm/tab-general.js b/packages/xo-web/src/xo-app/vm/tab-general.js index 5d160faa3..f3fb35601 100644 --- a/packages/xo-web/src/xo-app/vm/tab-general.js +++ b/packages/xo-web/src/xo-app/vm/tab-general.js @@ -1,5 +1,6 @@ import _ from 'intl' import Copiable from 'copiable' +import decorate from 'apply-decorators' import defined, { get } from '@xen-orchestra/defined' import Icon from 'icon' import isEmpty from 'lodash/isEmpty' @@ -14,14 +15,15 @@ import { FormattedRelative, FormattedDate } from 'react-intl' import { Container, Row, Col } from 'grid' import { Number, Size } from 'editable' import { - createCollectionWrapper, createFinder, createGetObjectsOfType, createGetVmLastShutdownTime, createSelector, + getResolvedPendingTasks, } from 'selectors' import { connectStore, formatSizeShort, getVirtualizationModeLabel, osFamily } from 'utils' import { CpuSparkLines, MemorySparkLines, NetworkSparkLines, XvdSparkLines } from 'xo-sparklines' +import { injectState, provideState } from 'reaclette' const GuestToolsDetection = ({ vm }) => { if (vm.power_state !== 'Running' || vm.pvDriversDetected === undefined) { @@ -78,153 +80,165 @@ const GuestToolsDetection = ({ vm }) => { ) } -export default connectStore(() => { - const getVgpus = createGetObjectsOfType('vgpu') - .pick((_, { vm }) => vm.$VGPUs) - .sort() +const GeneralTab = decorate([ + connectStore(() => { + const getVgpus = createGetObjectsOfType('vgpu') + .pick((_, { vm }) => vm.$VGPUs) + .sort() - const getAttachedVgpu = createFinder(getVgpus, vgpu => vgpu.currentlyAttached) + const getAttachedVgpu = createFinder(getVgpus, vgpu => vgpu.currentlyAttached) - const getVgpuTypes = createGetObjectsOfType('vgpuType').pick( - createSelector(getVgpus, vgpus => map(vgpus, 'vgpuType')) - ) + const getVgpuTypes = createGetObjectsOfType('vgpuType').pick( + createSelector(getVgpus, vgpus => map(vgpus, 'vgpuType')) + ) - return { - lastShutdownTime: createGetVmLastShutdownTime(), - tasks: createGetObjectsOfType('task') - .pick(createSelector((_, { vm }) => vm.current_operations, createCollectionWrapper(Object.keys))) - .filter({ status: 'pending' }) - .sort(), - vgpu: getAttachedVgpu, - vgpuTypes: getVgpuTypes, - } -})(({ lastShutdownTime, statsOverview, tasks, vgpu, vgpuTypes, vm, vmTotalDiskSpace }) => { - const { - CPUs: cpus, - id, - installTime, - mainIpAddress, - memory, - os_version: osVersion, - power_state: powerState, - startTime, - tags, - VIFs: vifs, - } = vm - return ( - - {/* TODO: use CSS style */} -
- - -

- editVm(vm, { CPUs: vcpus })} /> - x -

- {statsOverview && } - - -

- editVm(vm, { memory })} /> -   - - - -

- {statsOverview && } - - - -

- {vifs.length}x -

-
- {statsOverview && } - - - -

- {formatSizeShort(vmTotalDiskSpace)} -

-
- {statsOverview && } - -
- {/* TODO: use CSS style */} -
- - - {installTime !== null && ( -
- {_('created', { - date: , - })} -
- )} - {powerState === 'Running' || powerState === 'Paused' ? ( -
-

- {_('started', { - ago: , - })} -

-
- ) : ( -

- {lastShutdownTime - ? _('vmHaltedSince', { - ago: , - }) - : _('vmNotRunning')} -

- )} - - -

{getVirtualizationModeLabel(vm)}

- {vgpu !== undefined &&

{renderXoItem(vgpuTypes[vgpu.vgpuType])}

} - - - - {mainIpAddress !== undefined ? ( - {mainIpAddress} - ) : ( -

{_('noIpv4Record')}

- )} -
- - - - -

- -

-
-
- -
- - {/* TODO: use CSS style */} -
- - -

- removeTag(id, tag)} onAdd={tag => addTag(id, tag)} /> -

- -
- {isEmpty(tasks) ? null : ( + return (state, props) => ({ + lastShutdownTime: createGetVmLastShutdownTime()(state, props), + // true: useResourceSet to bypass permissions + resolvedPendingTasks: getResolvedPendingTasks(state, props, true), + vgpu: getAttachedVgpu(state, props), + vgpuTypes: getVgpuTypes(state, props), + }) + }), + provideState({ + computed: { + vmResolvedPendingTasks: (_, { resolvedPendingTasks, vm }) => { + const vmTaskIds = Object.keys(vm.current_operations) + return resolvedPendingTasks.filter(task => vmTaskIds.includes(task.id)) + }, + }, + }), + injectState, + ({ state: { vmResolvedPendingTasks }, lastShutdownTime, statsOverview, vgpu, vgpuTypes, vm, vmTotalDiskSpace }) => { + const { + CPUs: cpus, + id, + installTime, + mainIpAddress, + memory, + os_version: osVersion, + power_state: powerState, + startTime, + tags, + VIFs: vifs, + } = vm + return ( + + {/* TODO: use CSS style */} +
- -

{_('vmCurrentStatus')}

- {map(tasks, task => ( -

- {task.name_label} - {task.progress > 0 && : {Math.round(task.progress * 100)}%} -

- ))} + +

+ editVm(vm, { CPUs: vcpus })} /> + x +

+ {statsOverview && } + + +

+ editVm(vm, { memory })} /> +   + + + +

+ {statsOverview && } + + + +

+ {vifs.length}x +

+
+ {statsOverview && } + + + +

+ {formatSizeShort(vmTotalDiskSpace)} +

+
+ {statsOverview && }
- )} -
- ) -}) + {/* TODO: use CSS style */} +
+ + + {installTime !== null && ( +
+ {_('created', { + date: , + })} +
+ )} + {powerState === 'Running' || powerState === 'Paused' ? ( +
+

+ {_('started', { + ago: , + })} +

+
+ ) : ( +

+ {lastShutdownTime + ? _('vmHaltedSince', { + ago: , + }) + : _('vmNotRunning')} +

+ )} + + +

{getVirtualizationModeLabel(vm)}

+ {vgpu !== undefined &&

{renderXoItem(vgpuTypes[vgpu.vgpuType])}

} + + + + {mainIpAddress !== undefined ? ( + {mainIpAddress} + ) : ( +

{_('noIpv4Record')}

+ )} +
+ + + + +

+ +

+
+
+ +
+ + {/* TODO: use CSS style */} +
+ + +

+ removeTag(id, tag)} onAdd={tag => addTag(id, tag)} /> +

+ +
+ {isEmpty(vmResolvedPendingTasks) ? null : ( + + +

{_('vmCurrentStatus')}

+ {map(vmResolvedPendingTasks, task => ( +

+ {task.name_label} + {task.progress > 0 && : {Math.round(task.progress * 100)}%} +

+ ))} + +
+ )} +
+ ) + }, +]) + +export default GeneralTab