feat(xo-web/pool): XCP-ng license binding (#6453)
This commit is contained in:
parent
afd47f5522
commit
1269ddfeae
@ -8,6 +8,7 @@
|
|||||||
> Users must be able to say: “Nice enhancement, I'm eager to test it”
|
> Users must be able to say: “Nice enhancement, I'm eager to test it”
|
||||||
|
|
||||||
- [Delta Backup] Use [NBD](https://en.wikipedia.org/wiki/Network_block_device) to download disks (PR [#6461](https://github.com/vatesfr/xen-orchestra/pull/6461))
|
- [Delta Backup] Use [NBD](https://en.wikipedia.org/wiki/Network_block_device) to download disks (PR [#6461](https://github.com/vatesfr/xen-orchestra/pull/6461))
|
||||||
|
- [License] Possibility to bind XCP-ng license to hosts at pool level (PR [#6453](https://github.com/vatesfr/xen-orchestra/pull/6453))
|
||||||
|
|
||||||
### Bug fixes
|
### Bug fixes
|
||||||
|
|
||||||
|
@ -811,6 +811,9 @@ const messages = {
|
|||||||
noActiveVdi: 'No active VDI',
|
noActiveVdi: 'No active VDI',
|
||||||
|
|
||||||
// ----- Pool general -----
|
// ----- Pool general -----
|
||||||
|
earliestExpirationDate: 'Earliest expiration: {dateString}',
|
||||||
|
poolPartialSupport:
|
||||||
|
'Only {nHostsLicense, number} host{nHostsLicense, plural, one {} other {s}} under license on {nHosts, number} host{nHosts, plural, one {} other {s}}. This means this pool is not supported at all until you license all its hosts.',
|
||||||
poolTitleRamUsage: 'Pool RAM usage:',
|
poolTitleRamUsage: 'Pool RAM usage:',
|
||||||
poolRamUsage: '{used} used of {total} ({free} free)',
|
poolRamUsage: '{used} used of {total} ({free} free)',
|
||||||
poolMaster: 'Master:',
|
poolMaster: 'Master:',
|
||||||
@ -835,6 +838,9 @@ const messages = {
|
|||||||
poolHaDisabled: 'Disabled',
|
poolHaDisabled: 'Disabled',
|
||||||
poolGpuGroups: 'GPU groups',
|
poolGpuGroups: 'GPU groups',
|
||||||
poolRemoteSyslogPlaceHolder: 'Logging host',
|
poolRemoteSyslogPlaceHolder: 'Logging host',
|
||||||
|
poolSupportSourceUsers: 'Pool support not available for source users',
|
||||||
|
poolSupportXcpngOnly: 'Only available for pool of XCP-ng hosts',
|
||||||
|
poolLicenseAlreadyFullySupported: 'The pool is already fully supported',
|
||||||
setpoolMaster: 'Master',
|
setpoolMaster: 'Master',
|
||||||
syslogRemoteHost: 'Remote syslog host',
|
syslogRemoteHost: 'Remote syslog host',
|
||||||
defaultMigrationNetwork: 'Default migration network',
|
defaultMigrationNetwork: 'Default migration network',
|
||||||
@ -2400,6 +2406,16 @@ const messages = {
|
|||||||
auditInactiveUserActionsRecord: 'User actions recording is currently inactive',
|
auditInactiveUserActionsRecord: 'User actions recording is currently inactive',
|
||||||
|
|
||||||
// Licenses
|
// Licenses
|
||||||
|
allHostsMustBeBound: 'All hosts must be bound to a license',
|
||||||
|
bound: 'Bound',
|
||||||
|
bindXcpngLicenses: 'Bind XCP-ng licenses',
|
||||||
|
confirmBindingOnUnsupportedHost:
|
||||||
|
'You are about to bind {nLicenses, number} professional support license{nLicenses, plural, one {} other {s}} on older and unsupported XCP-ng version{nLicenses, plural, one {} other {s}}. Are you sure you want to continue?',
|
||||||
|
confirmRebindLicenseFromFullySupportedPool: 'The following pools will no longer be fully supported',
|
||||||
|
licenses: 'Licenses',
|
||||||
|
licensesBinding: 'Licenses binding',
|
||||||
|
notEnoughXcpngLicenses: 'Not enough XCP-ng licenses',
|
||||||
|
notBound: 'Not bound',
|
||||||
xosanUnregisteredDisclaimer:
|
xosanUnregisteredDisclaimer:
|
||||||
'You are not registered and therefore will not be able to create or manage your XOSAN SRs. {link}',
|
'You are not registered and therefore will not be able to create or manage your XOSAN SRs. {link}',
|
||||||
xosanSourcesDisclaimer:
|
xosanSourcesDisclaimer:
|
||||||
|
@ -6,16 +6,46 @@ import { injectIntl } from 'react-intl'
|
|||||||
import { injectState, provideState } from 'reaclette'
|
import { injectState, provideState } from 'reaclette'
|
||||||
import { map } from 'lodash'
|
import { map } from 'lodash'
|
||||||
|
|
||||||
|
import { renderXoItemFromId } from './render-xo-item'
|
||||||
|
|
||||||
|
const LicenseOptions = ({ license, formatTime }) =>
|
||||||
|
_(
|
||||||
|
'expiresOn',
|
||||||
|
{
|
||||||
|
date:
|
||||||
|
license.expires !== undefined
|
||||||
|
? formatTime(license.expires, {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
: '',
|
||||||
|
},
|
||||||
|
expirationDate => (
|
||||||
|
<option value={license.id}>
|
||||||
|
<span>
|
||||||
|
{license.id.slice(-4)} {expirationDate} {license.boundObjectId && renderXoItemFromId(license.boundObjectId)}
|
||||||
|
</span>
|
||||||
|
</option>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
const SelectLicense = decorate([
|
const SelectLicense = decorate([
|
||||||
injectIntl,
|
injectIntl,
|
||||||
provideState({
|
provideState({
|
||||||
computed: {
|
computed: {
|
||||||
licenses: async (state, { productType }) => {
|
licenses: async (state, { productType }) => {
|
||||||
try {
|
try {
|
||||||
return (await getLicenses({ productType }))?.filter(
|
const availableLicenses = {
|
||||||
({ boundObjectId, expires }) =>
|
bound: [],
|
||||||
boundObjectId === undefined && (expires === undefined || expires > Date.now())
|
notBound: [],
|
||||||
)
|
}
|
||||||
|
;(await getLicenses({ productType })).forEach(license => {
|
||||||
|
if (license.expires === undefined || license.expires > Date.now()) {
|
||||||
|
availableLicenses[license.boundObjectId === undefined ? 'notBound' : 'bound'].push(license)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return availableLicenses
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { licenseError: error }
|
return { licenseError: error }
|
||||||
}
|
}
|
||||||
@ -23,7 +53,7 @@ const SelectLicense = decorate([
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
injectState,
|
injectState,
|
||||||
({ state: { licenses }, intl: { formatTime }, onChange }) =>
|
({ state: { licenses }, intl: { formatTime }, onChange, showBoundLicenses }) =>
|
||||||
licenses?.licenseError !== undefined ? (
|
licenses?.licenseError !== undefined ? (
|
||||||
<span>
|
<span>
|
||||||
<em className='text-danger'>{_('getLicensesError')}</em>
|
<em className='text-danger'>{_('getLicensesError')}</em>
|
||||||
@ -35,26 +65,22 @@ const SelectLicense = decorate([
|
|||||||
{message}
|
{message}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
{map(licenses, license =>
|
|
||||||
_(
|
{_('notBound', i18nNotBound => (
|
||||||
'expiresOn',
|
<optgroup label={i18nNotBound}>
|
||||||
{
|
{map(licenses?.notBound, license => (
|
||||||
date:
|
<LicenseOptions formatTime={formatTime} key={license.id} license={license} />
|
||||||
license.expires !== undefined
|
))}
|
||||||
? formatTime(license.expires, {
|
</optgroup>
|
||||||
day: 'numeric',
|
))}
|
||||||
month: 'numeric',
|
{showBoundLicenses &&
|
||||||
year: 'numeric',
|
_('bound', i18nBound => (
|
||||||
})
|
<optgroup label={i18nBound}>
|
||||||
: '',
|
{map(licenses?.bound, license => (
|
||||||
},
|
<LicenseOptions formatTime={formatTime} key={license.id} license={license} />
|
||||||
message => (
|
))}
|
||||||
<option key={license.id} value={license.id}>
|
</optgroup>
|
||||||
{license.id.slice(-4)} {license.expires ? `(${message})` : ''}
|
))}
|
||||||
</option>
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</select>
|
</select>
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
|
@ -146,6 +146,11 @@ export { default as Debug } from './debug'
|
|||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
// Returns the current XOA Plan or the Plan name if number given
|
// Returns the current XOA Plan or the Plan name if number given
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*
|
||||||
|
* Use `getXoaPlan` from `xoa-plans` instead
|
||||||
|
*/
|
||||||
export const getXoaPlan = plan => {
|
export const getXoaPlan = plan => {
|
||||||
switch (plan || +process.env.XOA_PLAN) {
|
switch (plan || +process.env.XOA_PLAN) {
|
||||||
case 1:
|
case 1:
|
||||||
|
@ -31,6 +31,7 @@ import store from 'store'
|
|||||||
import { alert, chooseAction, confirm } from '../modal'
|
import { alert, chooseAction, confirm } from '../modal'
|
||||||
import { error, info, success } from '../notification'
|
import { error, info, success } from '../notification'
|
||||||
import { getObject } from 'selectors'
|
import { getObject } from 'selectors'
|
||||||
|
import { getXoaPlan, SOURCES } from '../xoa-plans'
|
||||||
import { noop, resolveId, resolveIds } from '../utils'
|
import { noop, resolveId, resolveIds } from '../utils'
|
||||||
import {
|
import {
|
||||||
connected,
|
connected,
|
||||||
@ -3236,6 +3237,16 @@ export const unlockXosan = (licenseId, srId) => _call('xosan.unlock', { licenseI
|
|||||||
|
|
||||||
export const bindLicense = (licenseId, boundObjectId) => _call('xoa.licenses.bind', { licenseId, boundObjectId })
|
export const bindLicense = (licenseId, boundObjectId) => _call('xoa.licenses.bind', { licenseId, boundObjectId })
|
||||||
|
|
||||||
|
export const bindXcpngLicense = (licenseId, boundObjectId) =>
|
||||||
|
bindLicense(licenseId, boundObjectId)::tap(subscribeXcpngLicenses.forceRefresh)
|
||||||
|
|
||||||
|
export const rebindLicense = (licenseType, licenseId, oldBoundObjectId, newBoundObjectId) =>
|
||||||
|
_call('xoa.licenses.rebind', { licenseId, oldBoundObjectId, newBoundObjectId })::tap(() => {
|
||||||
|
if (licenseType === 'xcpng-standard' || licenseType === 'xcpng-enterprise') {
|
||||||
|
return subscribeXcpngLicenses.forceRefresh()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
export const selfBindLicense = ({ id, plan, oldXoaId }) =>
|
export const selfBindLicense = ({ id, plan, oldXoaId }) =>
|
||||||
confirm({
|
confirm({
|
||||||
title: _('bindXoaLicense'),
|
title: _('bindXoaLicense'),
|
||||||
@ -3251,6 +3262,12 @@ export const selfBindLicense = ({ id, plan, oldXoaId }) =>
|
|||||||
|
|
||||||
export const subscribeSelfLicenses = createSubscription(() => _call('xoa.licenses.getSelf'))
|
export const subscribeSelfLicenses = createSubscription(() => _call('xoa.licenses.getSelf'))
|
||||||
|
|
||||||
|
export const subscribeXcpngLicenses = createSubscription(() =>
|
||||||
|
getXoaPlan() !== SOURCES && store.getState().user.permission === 'admin'
|
||||||
|
? _call('xoa.licenses.getAll', { productType: 'xcpng' })
|
||||||
|
: undefined
|
||||||
|
)
|
||||||
|
|
||||||
// Support --------------------------------------------------------------------
|
// Support --------------------------------------------------------------------
|
||||||
|
|
||||||
export const clearXoaCheckCache = () => _call('xoa.clearCheckCache')
|
export const clearXoaCheckCache = () => _call('xoa.clearCheckCache')
|
||||||
|
@ -0,0 +1,29 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import SelectLicense from 'select-license'
|
||||||
|
|
||||||
|
import BaseComponent from '../../base-component'
|
||||||
|
import { Host } from '../../render-xo-item'
|
||||||
|
|
||||||
|
export default class PoolBindLicenseModal extends BaseComponent {
|
||||||
|
licenseByHost = {}
|
||||||
|
|
||||||
|
get value() {
|
||||||
|
return this.licenseByHost
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelectLicense = hostId => event => (this.licenseByHost[hostId] = event.target.value)
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { hosts } = this.props
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{hosts.map(({ id }) => (
|
||||||
|
<div key={id}>
|
||||||
|
<Host id={id} link newTab />
|
||||||
|
<SelectLicense productType='xcpng' showBoundLicenses onChange={this.onSelectLicense(id)} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,8 @@
|
|||||||
.xo-icon {
|
.xo-icon {
|
||||||
|
&-pro-support {
|
||||||
|
@extend .fa;
|
||||||
|
@extend .fa-file-text;
|
||||||
|
}
|
||||||
&-pool {
|
&-pool {
|
||||||
@extend .fa;
|
@extend .fa;
|
||||||
@extend .fa-cloud;
|
@extend .fa-cloud;
|
||||||
|
@ -13,9 +13,13 @@ import { addTag, editPool, getHostMissingPatches, removeTag } from 'xo'
|
|||||||
import { connectStore, formatSizeShort } from 'utils'
|
import { connectStore, formatSizeShort } from 'utils'
|
||||||
import { compact, flatten, map, size, uniq } from 'lodash'
|
import { compact, flatten, map, size, uniq } from 'lodash'
|
||||||
import { createGetObjectsOfType, createGetHostMetrics, createSelector } from 'selectors'
|
import { createGetObjectsOfType, createGetHostMetrics, createSelector } from 'selectors'
|
||||||
|
import { injectState } from 'reaclette'
|
||||||
|
|
||||||
import styles from './index.css'
|
import styles from './index.css'
|
||||||
|
|
||||||
|
import { isAdmin } from '../../common/selectors'
|
||||||
|
import { ShortDate } from '../../common/utils'
|
||||||
|
|
||||||
@connectStore(() => {
|
@connectStore(() => {
|
||||||
const getPoolHosts = createGetObjectsOfType('host').filter(
|
const getPoolHosts = createGetObjectsOfType('host').filter(
|
||||||
createSelector(
|
createSelector(
|
||||||
@ -48,12 +52,14 @@ import styles from './index.css'
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
hostMetrics: getHostMetrics,
|
hostMetrics: getHostMetrics,
|
||||||
|
isAdmin,
|
||||||
missingPatches: getMissingPatches,
|
missingPatches: getMissingPatches,
|
||||||
poolHosts: getPoolHosts,
|
poolHosts: getPoolHosts,
|
||||||
nSrs: getNumberOfSrs,
|
nSrs: getNumberOfSrs,
|
||||||
nVms: getNumberOfVms,
|
nVms: getNumberOfVms,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@injectState
|
||||||
export default class PoolItem extends Component {
|
export default class PoolItem extends Component {
|
||||||
_addTag = tag => addTag(this.props.item.id, tag)
|
_addTag = tag => addTag(this.props.item.id, tag)
|
||||||
_removeTag = tag => removeTag(this.props.item.id, tag)
|
_removeTag = tag => removeTag(this.props.item.id, tag)
|
||||||
@ -66,9 +72,25 @@ export default class PoolItem extends Component {
|
|||||||
this.props.missingPatches.then(patches => this.setState({ missingPatchCount: size(patches) }))
|
this.props.missingPatches.then(patches => this.setState({ missingPatchCount: size(patches) }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_getPoolLicenseIcon() {
|
||||||
|
const { state: reacletteState, item: pool } = this.props
|
||||||
|
let tooltip
|
||||||
|
const { icon, earliestExpirationDate, nHostsUnderLicense, nHosts, supportLevel } =
|
||||||
|
reacletteState.poolLicenseInfoByPoolId[pool.id]
|
||||||
|
|
||||||
|
if (supportLevel === 'total') {
|
||||||
|
tooltip = _('earliestExpirationDate', { dateString: <ShortDate timestamp={earliestExpirationDate} /> })
|
||||||
|
}
|
||||||
|
if (supportLevel === 'partial') {
|
||||||
|
tooltip = _('poolPartialSupport', { nHostsLicense: nHostsUnderLicense, nHosts })
|
||||||
|
}
|
||||||
|
return icon(tooltip)
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { item: pool, expandAll, selected, hostMetrics, poolHosts, nSrs, nVms } = this.props
|
const { item: pool, expandAll, isAdmin, selected, hostMetrics, poolHosts, nSrs, nVms } = this.props
|
||||||
const { missingPatchCount } = this.state
|
const { missingPatchCount } = this.state
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.item}>
|
<div className={styles.item}>
|
||||||
<BlockLink to={`/pools/${pool.id}`}>
|
<BlockLink to={`/pools/${pool.id}`}>
|
||||||
@ -80,6 +102,7 @@ export default class PoolItem extends Component {
|
|||||||
<Ellipsis>
|
<Ellipsis>
|
||||||
<Text value={pool.name_label} onChange={this._setNameLabel} useLongClick />
|
<Text value={pool.name_label} onChange={this._setNameLabel} useLongClick />
|
||||||
</Ellipsis>
|
</Ellipsis>
|
||||||
|
{isAdmin && <span className='ml-1'>{this._getPoolLicenseIcon()}</span>}
|
||||||
|
|
||||||
{missingPatchCount > 0 && (
|
{missingPatchCount > 0 && (
|
||||||
<span>
|
<span>
|
||||||
|
@ -9,9 +9,11 @@ import React from 'react'
|
|||||||
import Shortcuts from 'shortcuts'
|
import Shortcuts from 'shortcuts'
|
||||||
import themes from 'themes'
|
import themes from 'themes'
|
||||||
import _, { IntlProvider } from 'intl'
|
import _, { IntlProvider } from 'intl'
|
||||||
|
// TODO: Replace all `getXoaPlan` by `getXoaPlan` from "xoa-plans"
|
||||||
|
import { addSubscriptions, connectStore, getXoaPlan, noop, routes } from 'utils'
|
||||||
import { blockXoaAccess, isTrialRunning } from 'xoa-updater'
|
import { blockXoaAccess, isTrialRunning } from 'xoa-updater'
|
||||||
import { checkXoa, clearXoaCheckCache } from 'xo'
|
import { checkXoa, clearXoaCheckCache } from 'xo'
|
||||||
import { connectStore, getXoaPlan, noop, routes } from 'utils'
|
import { forEach, groupBy, keyBy, pick } from 'lodash'
|
||||||
import { Notification } from 'notification'
|
import { Notification } from 'notification'
|
||||||
import { productId2Plan } from 'xoa-plans'
|
import { productId2Plan } from 'xoa-plans'
|
||||||
import { provideState } from 'reaclette'
|
import { provideState } from 'reaclette'
|
||||||
@ -48,6 +50,10 @@ import Xosan from './xosan'
|
|||||||
import Import from './import'
|
import Import from './import'
|
||||||
|
|
||||||
import keymap, { help } from '../keymap'
|
import keymap, { help } from '../keymap'
|
||||||
|
import Tooltip from '../common/tooltip'
|
||||||
|
import { createCollectionWrapper, createGetObjectsOfType } from '../common/selectors'
|
||||||
|
import { bindXcpngLicense, rebindLicense, subscribeXcpngLicenses } from '../common/xo'
|
||||||
|
import { SOURCES } from '../common/xoa-plans'
|
||||||
|
|
||||||
const shortcutManager = new ShortcutManager(keymap)
|
const shortcutManager = new ShortcutManager(keymap)
|
||||||
|
|
||||||
@ -76,6 +82,20 @@ const BODY_STYLE = {
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const ICON_POOL_LICENSE = {
|
||||||
|
total: tooltip => (
|
||||||
|
<Tooltip content={tooltip}>
|
||||||
|
<Icon icon='pro-support' className='text-success' />
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
partial: tooltip => (
|
||||||
|
<Tooltip content={tooltip}>
|
||||||
|
<Icon icon='alarm' className='text-warning' />
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
any: () => <Icon icon='alarm' className='text-danger' />,
|
||||||
|
}
|
||||||
|
|
||||||
@routes('home', {
|
@routes('home', {
|
||||||
about: About,
|
about: About,
|
||||||
backup: Backup,
|
backup: Backup,
|
||||||
@ -101,11 +121,16 @@ const BODY_STYLE = {
|
|||||||
hub: Hub,
|
hub: Hub,
|
||||||
proxies: Proxies,
|
proxies: Proxies,
|
||||||
})
|
})
|
||||||
|
@addSubscriptions({
|
||||||
|
xcpLicenses: subscribeXcpngLicenses,
|
||||||
|
})
|
||||||
@connectStore(state => {
|
@connectStore(state => {
|
||||||
|
const getHosts = createGetObjectsOfType('host')
|
||||||
return {
|
return {
|
||||||
trial: state.xoaTrialState,
|
trial: state.xoaTrialState,
|
||||||
registerNeeded: state.xoaUpdaterState === 'registerNeeded',
|
registerNeeded: state.xoaUpdaterState === 'registerNeeded',
|
||||||
signedUp: !!state.user,
|
signedUp: !!state.user,
|
||||||
|
hosts: getHosts(state),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@provideState({
|
@provideState({
|
||||||
@ -118,8 +143,71 @@ const BODY_STYLE = {
|
|||||||
refreshXoaStatus() {
|
refreshXoaStatus() {
|
||||||
this.state.checkXoaCount += 1
|
this.state.checkXoaCount += 1
|
||||||
},
|
},
|
||||||
|
async bindXcpngLicenses(_, xcpngLicensesByHost) {
|
||||||
|
await Promise.all(
|
||||||
|
map(xcpngLicensesByHost, ({ productId, id, boundObjectId }, hostId) =>
|
||||||
|
boundObjectId !== undefined
|
||||||
|
? rebindLicense(productId, id, boundObjectId, hostId)
|
||||||
|
: bindXcpngLicense(id, hostId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
// In case an host have more than 1 license, it's an issue.
|
||||||
|
// poolLicenseInfoByPoolId can be impacted because the license expiration check may not yield the right information.
|
||||||
|
xcpngLicenseByBoundObjectId: (_, { xcpLicenses }) => keyBy(xcpLicenses, 'boundObjectId'),
|
||||||
|
xcpngLicenseById: (_, { xcpLicenses }) => keyBy(xcpLicenses, 'id'),
|
||||||
|
hostsByPoolId: createCollectionWrapper((_, { hosts }) =>
|
||||||
|
groupBy(
|
||||||
|
map(hosts, host => pick(host, ['$poolId', 'id'])),
|
||||||
|
'$poolId'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
poolLicenseInfoByPoolId: ({ hostsByPoolId, xcpngLicenseByBoundObjectId }) => {
|
||||||
|
const poolLicenseInfoByPoolId = {}
|
||||||
|
|
||||||
|
forEach(hostsByPoolId, (hosts, poolId) => {
|
||||||
|
const nHosts = hosts.length
|
||||||
|
let earliestExpirationDate
|
||||||
|
let nHostsUnderLicense = 0
|
||||||
|
|
||||||
|
if (getXoaPlan() === SOURCES.name) {
|
||||||
|
poolLicenseInfoByPoolId[poolId] = {
|
||||||
|
nHostsUnderLicense,
|
||||||
|
icon: () => (
|
||||||
|
<Tooltip content={_('poolSupportSourceUsers')}>
|
||||||
|
<Icon icon='unknown-status' className='text-danger' />
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
nHosts,
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const host of hosts) {
|
||||||
|
const license = xcpngLicenseByBoundObjectId[host.id]
|
||||||
|
if (license !== undefined && license.expires > Date.now()) {
|
||||||
|
nHostsUnderLicense++
|
||||||
|
if (earliestExpirationDate === undefined || license.expires < earliestExpirationDate) {
|
||||||
|
earliestExpirationDate = license.expires
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const supportLevel = nHostsUnderLicense === 0 ? 'any' : nHostsUnderLicense === nHosts ? 'total' : 'partial'
|
||||||
|
|
||||||
|
poolLicenseInfoByPoolId[poolId] = {
|
||||||
|
earliestExpirationDate,
|
||||||
|
icon: ICON_POOL_LICENSE[supportLevel],
|
||||||
|
nHosts,
|
||||||
|
nHostsUnderLicense,
|
||||||
|
supportLevel,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return poolLicenseInfoByPoolId
|
||||||
|
},
|
||||||
xoaStatus: {
|
xoaStatus: {
|
||||||
get({ checkXoaCount }) {
|
get({ checkXoaCount }) {
|
||||||
// To avoid aggressive minification which would remove destructuration
|
// To avoid aggressive minification which would remove destructuration
|
||||||
|
@ -13,7 +13,7 @@ import { addSubscriptions, connectStore } from 'utils'
|
|||||||
import { Container, Row, Col } from 'grid'
|
import { Container, Row, Col } from 'grid'
|
||||||
import { CustomFields } from 'custom-fields'
|
import { CustomFields } from 'custom-fields'
|
||||||
import { injectIntl } from 'react-intl'
|
import { injectIntl } from 'react-intl'
|
||||||
import { map } from 'lodash'
|
import { forEach, map, values } from 'lodash'
|
||||||
import { Text, XoSelect } from 'editable'
|
import { Text, XoSelect } from 'editable'
|
||||||
import {
|
import {
|
||||||
createGetObject,
|
createGetObject,
|
||||||
@ -29,10 +29,142 @@ import {
|
|||||||
setPoolMaster,
|
setPoolMaster,
|
||||||
setRemoteSyslogHost,
|
setRemoteSyslogHost,
|
||||||
setRemoteSyslogHosts,
|
setRemoteSyslogHosts,
|
||||||
|
subscribeHvSupportedVersions,
|
||||||
subscribePlugins,
|
subscribePlugins,
|
||||||
|
subscribeXcpngLicenses,
|
||||||
synchronizeNetbox,
|
synchronizeNetbox,
|
||||||
} from 'xo'
|
} from 'xo'
|
||||||
|
import { injectState, provideState } from 'reaclette'
|
||||||
import { SelectSuspendSr } from 'select-suspend-sr'
|
import { SelectSuspendSr } from 'select-suspend-sr'
|
||||||
|
import { satisfies } from 'semver'
|
||||||
|
|
||||||
|
import decorate from '../../common/apply-decorators'
|
||||||
|
import PoolBindLicenseModal from '../../common/xo/pool-bind-licenses-modal/ index'
|
||||||
|
import { confirm } from '../../common/modal'
|
||||||
|
import { error } from '../../common/notification'
|
||||||
|
import { Host, Pool } from '../../common/render-xo-item'
|
||||||
|
import { isAdmin } from '../../common/selectors'
|
||||||
|
import { SOURCES, getXoaPlan } from '../../common/xoa-plans'
|
||||||
|
|
||||||
|
const BindLicensesButton = decorate([
|
||||||
|
addSubscriptions({
|
||||||
|
hvSupportedVersions: subscribeHvSupportedVersions,
|
||||||
|
xcpLicenses: subscribeXcpngLicenses,
|
||||||
|
}),
|
||||||
|
connectStore({
|
||||||
|
hosts: createGetObjectsOfType('host'),
|
||||||
|
}),
|
||||||
|
provideState({
|
||||||
|
effects: {
|
||||||
|
async handleBindLicense() {
|
||||||
|
const { poolHosts, xcpLicenses } = this.props
|
||||||
|
|
||||||
|
if (xcpLicenses.length < poolHosts.length) {
|
||||||
|
return error(_('licensesBinding'), _('notEnoughXcpngLicenses'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostsWithoutLicense = poolHosts.filter(
|
||||||
|
host => this.state.xcpngLicenseByBoundObjectId[host.id] === undefined
|
||||||
|
)
|
||||||
|
const licenseIdByHost = await confirm({
|
||||||
|
body: <PoolBindLicenseModal hosts={hostsWithoutLicense} />,
|
||||||
|
icon: 'connect',
|
||||||
|
title: _('licensesBinding'),
|
||||||
|
})
|
||||||
|
const licensesByHost = {}
|
||||||
|
|
||||||
|
// Pass values into a Set in order to remove duplicated licenseId
|
||||||
|
const nLicensesToBind = new Set(values(licenseIdByHost)).size
|
||||||
|
|
||||||
|
if (nLicensesToBind !== hostsWithoutLicense.length) {
|
||||||
|
return error(_('licensesBinding'), _('allHostsMustBeBound'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullySupportedPoolIds = []
|
||||||
|
const unsupportedXcpngHostIds = []
|
||||||
|
forEach(licenseIdByHost, (licenseId, hostId) => {
|
||||||
|
const license = this.state.xcpngLicenseById[licenseId]
|
||||||
|
const boundHost = this.props.hosts[license.boundObjectId]
|
||||||
|
const hostToBind = this.props.hosts[hostId]
|
||||||
|
const poolId = boundHost?.$pool
|
||||||
|
const poolLicenseInfo = this.state.poolLicenseInfoByPoolId[poolId]
|
||||||
|
licensesByHost[hostId] = license
|
||||||
|
|
||||||
|
if (poolLicenseInfo !== undefined && poolLicenseInfo.supportLevel === 'total' && poolLicenseInfo.nHosts > 1) {
|
||||||
|
fullySupportedPoolIds.push(poolId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!satisfies(hostToBind.version, this.props.hvSupportedVersions['XCP-ng'])) {
|
||||||
|
unsupportedXcpngHostIds.push(hostToBind.id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (fullySupportedPoolIds.length !== 0) {
|
||||||
|
await confirm({
|
||||||
|
body: (
|
||||||
|
<div>
|
||||||
|
<p>{_('confirmRebindLicenseFromFullySupportedPool')}</p>
|
||||||
|
<ul>
|
||||||
|
{fullySupportedPoolIds.map(poolId => (
|
||||||
|
<li key={poolId}>
|
||||||
|
<Pool id={poolId} link newTab />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
title: _('licensesBinding'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unsupportedXcpngHostIds.length !== 0) {
|
||||||
|
await confirm({
|
||||||
|
body: (
|
||||||
|
<div>
|
||||||
|
<p>{_('confirmBindingOnUnsupportedHost', { nLicenses: unsupportedXcpngHostIds.length })}</p>
|
||||||
|
<ul>
|
||||||
|
{unsupportedXcpngHostIds.map(hostId => (
|
||||||
|
<li key={hostId}>
|
||||||
|
<Host id={hostId} link newTab />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
title: _('licensesBinding'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.effects.bindXcpngLicenses(licensesByHost)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
isBindLicenseAvailable: (state, props) =>
|
||||||
|
getXoaPlan() !== SOURCES && state.poolLicenseInfoByPoolId[props.pool.id].supportLevel !== 'total',
|
||||||
|
isXcpngPool: (_, { poolHosts }) => poolHosts[0].productBrand === 'XCP-ng',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
injectState,
|
||||||
|
({ effects, state }) => (
|
||||||
|
<ActionButton
|
||||||
|
btnStyle='primary'
|
||||||
|
disabled={!state.isXcpngPool || !state.isBindLicenseAvailable}
|
||||||
|
handler={effects.handleBindLicense}
|
||||||
|
icon='connect'
|
||||||
|
tooltip={
|
||||||
|
getXoaPlan() === SOURCES
|
||||||
|
? _('poolSupportSourceUsers')
|
||||||
|
: !state.isXcpngPool
|
||||||
|
? _('poolSupportXcpngOnly')
|
||||||
|
: state.isBindLicenseAvailable
|
||||||
|
? undefined
|
||||||
|
: _('poolLicenseAlreadyFullySupported')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{_('bindXcpngLicenses')}
|
||||||
|
</ActionButton>
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
@connectStore(() => ({
|
@connectStore(() => ({
|
||||||
master: createGetObjectsOfType('host').find((_, { pool }) => ({
|
master: createGetObjectsOfType('host').find((_, { pool }) => ({
|
||||||
@ -72,6 +204,7 @@ class PoolMaster extends Component {
|
|||||||
gpuGroups: createGetObjectsOfType('gpuGroup')
|
gpuGroups: createGetObjectsOfType('gpuGroup')
|
||||||
.filter((_, { pool }) => ({ $pool: pool.id }))
|
.filter((_, { pool }) => ({ $pool: pool.id }))
|
||||||
.sort(),
|
.sort(),
|
||||||
|
isAdmin,
|
||||||
migrationNetwork: createGetObject((_, { pool }) => pool.otherConfig['xo:migrationNetwork']),
|
migrationNetwork: createGetObject((_, { pool }) => pool.otherConfig['xo:migrationNetwork']),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -116,7 +249,7 @@ export default class TabAdvanced extends Component {
|
|||||||
)
|
)
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { backupNetwork, hosts, gpuGroups, pool, hostsByMultipathing, migrationNetwork } = this.props
|
const { backupNetwork, hosts, isAdmin, gpuGroups, pool, hostsByMultipathing, migrationNetwork } = this.props
|
||||||
const { state } = this
|
const { state } = this
|
||||||
const { editRemoteSyslog } = state
|
const { editRemoteSyslog } = state
|
||||||
const { enabled: hostsEnabledMultipathing, disabled: hostsDisabledMultipathing } = hostsByMultipathing
|
const { enabled: hostsEnabledMultipathing, disabled: hostsDisabledMultipathing } = hostsByMultipathing
|
||||||
@ -214,6 +347,12 @@ export default class TabAdvanced extends Component {
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
</Container>
|
</Container>
|
||||||
|
{isAdmin && (
|
||||||
|
<div>
|
||||||
|
<h3>{_('licenses')}</h3>
|
||||||
|
<BindLicensesButton poolHosts={hosts} pool={pool} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<h3 className='mt-1 mb-1'>{_('poolGpuGroups')}</h3>
|
<h3 className='mt-1 mb-1'>{_('poolGpuGroups')}</h3>
|
||||||
<Container>
|
<Container>
|
||||||
<Row>
|
<Row>
|
||||||
|
@ -171,7 +171,7 @@ export default class Licenses extends Component {
|
|||||||
|
|
||||||
return getLicenses()
|
return getLicenses()
|
||||||
.then(licenses => {
|
.then(licenses => {
|
||||||
const { proxy, xoa, xosan } = groupBy(licenses, license => {
|
const { proxy, xcpng, xoa, xosan } = groupBy(licenses, license => {
|
||||||
for (const productType of license.productTypes) {
|
for (const productType of license.productTypes) {
|
||||||
if (productType === 'xo') {
|
if (productType === 'xo') {
|
||||||
return 'xoa'
|
return 'xoa'
|
||||||
@ -182,12 +182,16 @@ export default class Licenses extends Component {
|
|||||||
if (productType === 'xoproxy') {
|
if (productType === 'xoproxy') {
|
||||||
return 'proxy'
|
return 'proxy'
|
||||||
}
|
}
|
||||||
|
if (productType === 'xcpng') {
|
||||||
|
return 'xcpng'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return 'other'
|
return 'other'
|
||||||
})
|
})
|
||||||
this.setState({
|
this.setState({
|
||||||
licenses: {
|
licenses: {
|
||||||
proxy,
|
proxy,
|
||||||
|
xcpng,
|
||||||
xoa,
|
xoa,
|
||||||
xosan,
|
xosan,
|
||||||
},
|
},
|
||||||
@ -256,6 +260,21 @@ export default class Licenses extends Component {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- xcpng
|
||||||
|
forEach(licenses.xcpng, license => {
|
||||||
|
// When `expires` is undefined, the license isn't expired
|
||||||
|
if (!(license.expires < now)) {
|
||||||
|
products.push({
|
||||||
|
buyer: license.buyer,
|
||||||
|
expires: license.expires,
|
||||||
|
id: license.id,
|
||||||
|
product: 'XCP-ng',
|
||||||
|
type: 'xcpng',
|
||||||
|
hostId: license.boundObjectId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return products
|
return products
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
Loading…
Reference in New Issue
Block a user