Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(xo-web/pool): XCP-ng license binding #6453

Merged
merged 12 commits into from
Oct 28, 2022
1 change: 1 addition & 0 deletions CHANGELOG.unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
> 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))
- [License] Possibility to bind XCP-ng license to hosts at pool level (PR [#6453](https://github.com/vatesfr/xen-orchestra/pull/6453))
pdonias marked this conversation as resolved.
Show resolved Hide resolved

### Bug fixes

Expand Down
16 changes: 16 additions & 0 deletions packages/xo-web/src/common/intl/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -811,6 +811,9 @@ const messages = {
noActiveVdi: 'No active VDI',

// ----- 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:',
poolRamUsage: '{used} used of {total} ({free} free)',
poolMaster: 'Master:',
Expand All @@ -835,6 +838,9 @@ const messages = {
poolHaDisabled: 'Disabled',
poolGpuGroups: 'GPU groups',
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',
syslogRemoteHost: 'Remote syslog host',
defaultMigrationNetwork: 'Default migration network',
Expand Down Expand Up @@ -2400,6 +2406,16 @@ const messages = {
auditInactiveUserActionsRecord: 'User actions recording is currently inactive',

// 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:
'You are not registered and therefore will not be able to create or manage your XOSAN SRs. {link}',
xosanSourcesDisclaimer:
Expand Down
76 changes: 51 additions & 25 deletions packages/xo-web/src/common/select-license.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,54 @@ import { injectIntl } from 'react-intl'
import { injectState, provideState } from 'reaclette'
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([
injectIntl,
provideState({
computed: {
licenses: async (state, { productType }) => {
try {
return (await getLicenses({ productType }))?.filter(
({ boundObjectId, expires }) =>
boundObjectId === undefined && (expires === undefined || expires > Date.now())
)
const availableLicenses = {
bound: [],
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) {
return { licenseError: error }
}
},
},
}),
injectState,
({ state: { licenses }, intl: { formatTime }, onChange }) =>
({ state: { licenses }, intl: { formatTime }, onChange, showBoundLicenses }) =>
licenses?.licenseError !== undefined ? (
<span>
<em className='text-danger'>{_('getLicensesError')}</em>
Expand All @@ -35,26 +65,22 @@ const SelectLicense = decorate([
{message}
</option>
))}
{map(licenses, license =>
_(
'expiresOn',
{
date:
license.expires !== undefined
? formatTime(license.expires, {
day: 'numeric',
month: 'numeric',
year: 'numeric',
})
: '',
},
message => (
<option key={license.id} value={license.id}>
{license.id.slice(-4)} {license.expires ? `(${message})` : ''}
</option>
)
)
)}

{_('notBound', i18nNotBound => (
<optgroup label={i18nNotBound}>
{map(licenses?.notBound, license => (
<LicenseOptions formatTime={formatTime} key={license.id} license={license} />
))}
</optgroup>
))}
{showBoundLicenses &&
_('bound', i18nBound => (
<optgroup label={i18nBound}>
{map(licenses?.bound, license => (
<LicenseOptions formatTime={formatTime} key={license.id} license={license} />
))}
</optgroup>
))}
</select>
),
])
Expand Down
5 changes: 5 additions & 0 deletions packages/xo-web/src/common/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,11 @@ export { default as Debug } from './debug'
// -------------------------------------------------------------------

// Returns the current XOA Plan or the Plan name if number given
/**
* @deprecated
*
* Use `getXoaPlan` from `xoa-plans` instead
*/
export const getXoaPlan = plan => {
switch (plan || +process.env.XOA_PLAN) {
case 1:
Expand Down
17 changes: 17 additions & 0 deletions packages/xo-web/src/common/xo/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import store from 'store'
import { alert, chooseAction, confirm } from '../modal'
import { error, info, success } from '../notification'
import { getObject } from 'selectors'
import { getXoaPlan, SOURCES } from '../xoa-plans'
import { noop, resolveId, resolveIds } from '../utils'
import {
connected,
Expand Down Expand Up @@ -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 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 }) =>
confirm({
title: _('bindXoaLicense'),
Expand All @@ -3251,6 +3262,12 @@ export const selfBindLicense = ({ id, plan, oldXoaId }) =>

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 --------------------------------------------------------------------

export const clearXoaCheckCache = () => _call('xoa.clearCheckCache')
Expand Down
29 changes: 29 additions & 0 deletions packages/xo-web/src/common/xo/pool-bind-licenses-modal/ index.js
Original file line number Diff line number Diff line change
@@ -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 }) => (
Rajaa-BARHTAOUI marked this conversation as resolved.
Show resolved Hide resolved
<div key={id}>
<Host id={id} link newTab />
<SelectLicense productType='xcpng' showBoundLicenses onChange={this.onSelectLicense(id)} />
</div>
))}
</div>
)
}
}
4 changes: 4 additions & 0 deletions packages/xo-web/src/icons.scss
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
.xo-icon {
&-pro-support {
@extend .fa;
@extend .fa-file-text;
}
&-pool {
@extend .fa;
@extend .fa-cloud;
Expand Down
25 changes: 24 additions & 1 deletion packages/xo-web/src/xo-app/home/pool-item.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,13 @@ import { addTag, editPool, getHostMissingPatches, removeTag } from 'xo'
import { connectStore, formatSizeShort } from 'utils'
import { compact, flatten, map, size, uniq } from 'lodash'
import { createGetObjectsOfType, createGetHostMetrics, createSelector } from 'selectors'
import { injectState } from 'reaclette'

import styles from './index.css'

import { isAdmin } from '../../common/selectors'
import { ShortDate } from '../../common/utils'

@connectStore(() => {
const getPoolHosts = createGetObjectsOfType('host').filter(
createSelector(
Expand Down Expand Up @@ -48,12 +52,14 @@ import styles from './index.css'

return {
hostMetrics: getHostMetrics,
isAdmin,
missingPatches: getMissingPatches,
poolHosts: getPoolHosts,
nSrs: getNumberOfSrs,
nVms: getNumberOfVms,
}
})
@injectState
export default class PoolItem extends Component {
_addTag = tag => addTag(this.props.item.id, tag)
_removeTag = tag => removeTag(this.props.item.id, tag)
Expand All @@ -66,9 +72,25 @@ export default class PoolItem extends Component {
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() {
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

return (
<div className={styles.item}>
<BlockLink to={`/pools/${pool.id}`}>
Expand All @@ -80,6 +102,7 @@ export default class PoolItem extends Component {
<Ellipsis>
<Text value={pool.name_label} onChange={this._setNameLabel} useLongClick />
</Ellipsis>
{isAdmin && <span className='ml-1'>{this._getPoolLicenseIcon()}</span>}
&nbsp;&nbsp;
{missingPatchCount > 0 && (
<span>
Expand Down
Loading