feat(xosan): license management (#2528)

This commit is contained in:
Pierre Donias 2017-12-29 16:48:21 +01:00 committed by Julien Fontanet
parent 8cb53b0c4e
commit 02c715e1cc
17 changed files with 1493 additions and 669 deletions

View File

@ -57,7 +57,9 @@ const messages = {
selfServicePage: 'Self service',
backupPage: 'Backup',
jobsPage: 'Jobs',
xoaPage: 'XOA',
updatePage: 'Updates',
licensesPage: 'Licenses',
settingsPage: 'Settings',
settingsServersPage: 'Servers',
settingsUsersPage: 'Users',
@ -95,6 +97,7 @@ const messages = {
jobsSchedulingPage: 'Scheduling',
customJob: 'Custom Job',
userPage: 'User',
xoa: 'XOA',
// ----- Support -----
noSupport: 'No support',
@ -1547,6 +1550,8 @@ const messages = {
xosanSrTitle: 'Xen Orchestra SAN SR',
xosanAvailableSrsTitle: 'Select local SRs (lvm)',
xosanSuggestions: 'Suggestions',
xosanDisperseWarning:
'Warning: using disperse layout is not recommended right now. Please read {link}.',
xosanName: 'Name',
xosanHost: 'Host',
xosanHosts: 'Connected Hosts',
@ -1554,6 +1559,8 @@ const messages = {
xosanVolumeId: 'Volume ID',
xosanSize: 'Size',
xosanUsedSpace: 'Used space',
xosanLicense: 'License',
xosanMultipleLicenses: 'This XOSAN has more than 1 license!',
xosanNeedPack: 'XOSAN pack needs to be installed on each host of the pool.',
xosanInstallIt: 'Install it now!',
xosanNeedRestart:
@ -1595,11 +1602,9 @@ const messages = {
// Pack download modal
xosanInstallCloudPlugin: 'Install cloud plugin first',
xosanLoadCloudPlugin: 'Load cloud plugin first',
xosanRegister: 'Register your appliance first',
xosanLoading: 'Loading…',
xosanNotAvailable: 'XOSAN is not available at the moment',
xosanRegisterBeta: 'Register for the XOSAN beta',
xosanSuccessfullyRegistered:
'You have successfully registered for the XOSAN beta. Please wait until your request has been approved.',
xosanInstallPackOnHosts: 'Install XOSAN pack on these hosts:',
xosanInstallPack: 'Install {pack} v{version}?',
xosanNoPackFound:
@ -1641,11 +1646,53 @@ const messages = {
xosanRemove: 'Remove',
xosanVolume: 'Volume',
xosanVolumeOptions: 'Volume options',
xosanCouldNotFindVM: 'Could not find VM',
xosanCouldNotFindVm: 'Could not find VM',
xosanUnderlyingStorageUsage: 'Using {usage}',
xosanCustomIpNetwork: 'Custom IP network (/24)',
xosanIssueHostNotInNetwork:
'Will configure the host xosan network device with a static IP address and plug it in.',
// Licenses
licensesTitle: 'Licenses',
xosanUnregisteredDisclaimer:
'You are not registered and therefore will not be able to create or manage your XOSAN SRs. {link}',
xosanSourcesDisclaimer:
'In order to create a XOSAN SR, you need to use the Xen Orchestra Appliance and buy a XOSAN license on {link}.',
registerNow: 'Register now!',
licensesUnregisteredDisclaimer:
'You need to register your appliance to manage your licenses.',
licenseProduct: 'Product',
licenseBoundObject: 'Attached to',
licensePurchaser: 'Purchaser',
licenseExpires: 'Expires',
licensePurchaserYou: 'You',
productSupport: 'Support',
licenseNotBoundXosan: 'No XOSAN attached',
licenseBoundUnknownXosan: 'License attached to an unknown XOSAN',
licensesManage: 'Manage the licenses',
newLicense: 'New license',
refreshLicenses: 'Refresh',
xosanLicenseRestricted: 'Limited size because XOSAN is in trial',
xosanAdminNoLicenseDisclaimer:
'You need a license on this SR to manage the XOSAN.',
xosanAdminExpiredLicenseDisclaimer:
'Your XOSAN license has expired. You can still use the SR but cannot administrate it anymore.',
xosanCheckLicenseError: 'Could not check the license on this XOSAN SR',
xosanGetLicensesError: 'Could not fetch licenses',
xosanLicenseHasExpired: 'License has expired.',
xosanLicenseExpiresDate: 'License expires on {date}.',
xosanUpdateLicenseMessage: 'Update the license now!',
xosanUnknownSr: 'Unknown XOSAN SR.',
contactUs: 'Contact us!',
xosanNoLicense: 'No license.',
xosanUnlockNow: 'Unlock now!',
xosanBetaOverMessage:
'XOSAN Beta is over. You may now delete and create this storage again to be able to manage it.',
selectLicense: 'Select a license',
bindLicense: 'Bind license',
expiresOn: 'expires on {date}',
xosanInstallXoaPlugin: 'Install XOA plugin first',
xosanLoadXoaPlugin: 'Load XOA plugin first',
}
forEach(messages, function (message, id) {
if (isString(message)) {

View File

@ -3,6 +3,7 @@ import humanFormat from 'human-format'
import React from 'react'
import ReadableStream from 'readable-stream'
import { connect } from 'react-redux'
import { FormattedDate } from 'react-intl'
import {
clone,
escapeRegExp,
@ -572,6 +573,9 @@ export const cowSet = (object, path, value, depth = 0) => {
// This function returns an estimated progress value between 0 and 1
// based on the elapsed time since the createFakeProgress call and
// the given estimated duration d
//
// const getProgress = createFakeProgress(120)
// setInterval(() => console.log(`Progress: ${getProgress() * 100} %`), 1000)
export const createFakeProgress = (() => {
const S = 0.95 // Progress value after d seconds
return d => {
@ -582,3 +586,7 @@ export const createFakeProgress = (() => {
}
}
})()
export const ShortDate = ({ timestamp }) => (
<FormattedDate value={timestamp} month='short' day='numeric' year='numeric' />
)

View File

@ -328,6 +328,19 @@ export const subscribeCheckSrCurrentState = (pool, cb) => {
return checkSrCurrentStateSubscriptions[poolId](cb)
}
subscribeCheckSrCurrentState.forceRefresh = pool => {
if (pool === undefined) {
forEach(checkSrCurrentStateSubscriptions, subscription =>
subscription.forceRefresh()
)
return
}
const subscription = checkSrCurrentStateSubscriptions[resolveId(pool)]
if (subscription !== undefined) {
subscription.forceRefresh()
}
}
const missingPatchesByHost = {}
export const subscribeHostMissingPatches = (host, cb) => {
@ -2017,8 +2030,8 @@ export const createXosanSR = ({
brickSize,
memorySize,
ipRange,
}) =>
_call('xosan.createSR', {
}) => {
const promise = _call('xosan.createSR', {
template,
pif: pif.id,
vlan: String(vlan),
@ -2030,6 +2043,12 @@ export const createXosanSR = ({
ipRange,
})
// Force refresh in parallel to get the creation progress sooner
subscribeCheckSrCurrentState.forceRefresh()
return promise
}
export const addXosanBricks = (xosansr, lvmsrs, brickSize) =>
_call('xosan.addBricks', { xosansr, lvmsrs, brickSize })
@ -2065,8 +2084,15 @@ export const downloadAndInstallXosanPack = pool =>
})
)
export const registerXosan = namespace =>
_call('cloud.registerResource', { namespace: 'xosan' })
export const fixHostNotInXosanNetwork = (xosanSr, host) =>
_call('xosan.fixHostNotInNetwork', { xosanSr, host })
// Licenses --------------------------------------------------------------------
export const getLicenses = productId => _call('xoa.getLicenses', { productId })
export const getLicense = (productId, boundObjectId) =>
_call('xoa.getLicense', { productId, boundObjectId })
export const unlockXosan = (licenseId, srId) =>
_call('xosan.unlock', { licenseId, sr: srId })

View File

@ -744,10 +744,18 @@
@extend .fa-clock-o;
}
}
&-menu-xoa {
@extend .fa;
@extend .fa-cube;
}
&-menu-update {
@extend .fa;
@extend .fa-refresh;
}
&-menu-license {
@extend .fa;
@extend .fa-file-text-o;
}
&-menu-settings {
@extend .fa;
@extend .fa-cog;
@ -985,6 +993,10 @@
@extend .fa;
@extend .fa-star;
}
&-support {
@extend .fa;
@extend .fa-support;
}
// XOSAN related

View File

@ -100,7 +100,7 @@ export default class About extends Component {
<div>
<Row>
<Col>
<Link to={'/xoa-update'}>
<Link to='/xoa-update'>
<h2>{_('freeTrial')}</h2>
{_('freeTrialNow')}
</Link>

View File

@ -38,7 +38,8 @@ import Tasks from './tasks'
import User from './user'
import Vm from './vm'
import VmImport from './vm-import'
import XoaUpdates from './xoa-updates'
import Xoa from './xoa'
import XoaUpdates from './xoa/update'
import Xosan from './xosan'
import keymap, { help } from '../keymap'
@ -87,7 +88,7 @@ const BODY_STYLE = {
'vms/import': VmImport,
'vms/new': NewVm,
'vms/:id': Vm,
'xoa-update': XoaUpdates,
xoa: Xoa,
xosan: Xosan,
})
@connectStore(state => {

View File

@ -7,7 +7,7 @@ import Link from 'link'
import map from 'lodash/map'
import React from 'react'
import Tooltip from 'tooltip'
import { UpdateTag } from '../xoa-updates'
import { UpdateTag } from '../xoa/update'
import { addSubscriptions, connectStore, getXoaPlan, noop } from 'utils'
import {
connect,
@ -203,10 +203,14 @@ export default class Menu extends Component {
],
},
isAdmin && {
to: '/xoa-update',
icon: 'menu-update',
label: 'updatePage',
to: 'xoa/update',
icon: 'menu-xoa',
label: 'xoa',
extra: <UpdateTag />,
subMenu: [
{ to: 'xoa/update', icon: 'menu-update', label: 'updatePage' },
{ to: 'xoa/licenses', icon: 'menu-license', label: 'licensesPage' },
],
},
isAdmin && {
to: '/settings/servers',

View File

@ -14,10 +14,15 @@ export default class ReplaceBrickModalBody extends Component {
_getSrPredicate = createSelector(
() => this.props.vm,
() => this.state.onSameVm,
(vm, onSameVm) =>
onSameVm
(vm, onSameVm) => {
if (vm === undefined) {
return sr => sr.SR_type === 'lvm'
}
return onSameVm
? sr => sr.$container === vm.$container && sr.SR_type === 'lvm'
: sr => sr.$pool === vm.$pool && sr.SR_type === 'lvm'
}
)
_toggleOnSameVm = () =>
@ -36,6 +41,7 @@ export default class ReplaceBrickModalBody extends Component {
render () {
return (
<Container>
{this.props.vm !== undefined && (
<Row className='mb-1'>
<Col size={6}>
<strong>{_('xosanOnSameVm')}</strong>
@ -47,6 +53,7 @@ export default class ReplaceBrickModalBody extends Component {
/>
</Col>
</Row>
)}
<Row className='mb-1'>
<Col size={6}>
<strong>{_('xosanUnderlyingStorage')}</strong>

View File

@ -1,10 +1,11 @@
import _ from 'intl'
import Component from 'base-component'
import HomeTags from 'home-tags'
import Icon from 'icon'
import map from 'lodash/map'
import React from 'react'
import Usage, { UsageElement } from 'usage'
import { addTag, removeTag } from 'xo'
import { addTag, removeTag, getLicense } from 'xo'
import { connectStore, formatSize } from 'utils'
import { Container, Row, Col } from 'grid'
import { createGetObject } from 'selectors'
@ -20,7 +21,21 @@ const UsageTooltip = connectStore(() => ({
</span>
))
export default ({ sr, vdis, vdiSnapshots, unmanagedVdis }) => (
export default class TabGeneral extends Component {
componentDidMount () {
const { sr } = this.props
if (sr.SR_type === 'xosan') {
getLicense('xosan.trial', sr.id).then(() =>
this.setState({ licenseRestriction: true })
)
}
}
render () {
const { sr, vdis, vdiSnapshots, unmanagedVdis } = this.props
return (
<Container>
<Row className='text-xs-center'>
<Col mediumSize={4}>
@ -33,6 +48,9 @@ export default ({ sr, vdis, vdiSnapshots, unmanagedVdis }) => (
{formatSize(sr.size)} <Icon icon='sr' size='lg' />
</h2>
<p>Type: {sr.SR_type}</p>
{this.state.licenseRestriction && (
<p className='text-danger'>{_('xosanLicenseRestricted')}</p>
)}
</Col>
<Col mediumSize={4}>
<h2>
@ -92,4 +110,6 @@ export default ({ sr, vdis, vdiSnapshots, unmanagedVdis }) => (
</Col>
</Row>
</Container>
)
)
}
}

View File

@ -12,16 +12,18 @@ import { confirm } from 'modal'
import { error } from 'notification'
import { Toggle } from 'form'
import { Container, Col, Row } from 'grid'
import { forEach, isEmpty, map, reduce, sum } from 'lodash'
import { createGetObjectsOfType, createSelector } from 'selectors'
import { find, forEach, isEmpty, map, reduce, sum } from 'lodash'
import { createGetObjectsOfType, createSelector, isAdmin } from 'selectors'
import { addSubscriptions, connectStore, formatSize } from 'utils'
import {
addXosanBricks,
getLicense,
fixHostNotInXosanNetwork,
// TODO: uncomment when implementing subvolume deletion
// removeXosanBricks,
replaceXosanBrick,
startVm,
subscribePlugins,
subscribeVolumeInfo,
} from 'xo'
@ -350,6 +352,7 @@ class Node extends Component {
// -----------------------------------------------------------------------------
@connectStore(() => ({
isAdmin,
vms: createGetObjectsOfType('VM'),
hosts: createGetObjectsOfType('host'),
vbds: createGetObjectsOfType('VBD'),
@ -362,9 +365,22 @@ class Node extends Component {
subscribeVolumeInfo({ sr, infoType }, cb)
})
subscriptions.plugins = subscribePlugins
return subscriptions
})
export default class TabXosan extends Component {
componentDidMount () {
const { id } = this.props.sr
getLicense('xosan', id)
.catch(() => getLicense('xosan.trial', id))
.then(
license => this.setState({ license }),
error => this.setState({ licenseError: error })
)
}
_addSubvolume = async () => {
const { srs, brickSize } = await confirm({
icon: 'add',
@ -425,6 +441,24 @@ export default class TabXosan extends Component {
// }
// )
_getMissingXoaPlugin = createSelector(
() => this.props.plugins,
plugins => {
if (plugins === undefined) {
return _('xosanInstallXoaPlugin')
}
const xoaPlugin = find(plugins, { id: 'xoa' })
if (xoaPlugin === undefined) {
return _('xosanInstallXoaPlugin')
}
if (!xoaPlugin.loaded) {
return _('xosanLoadXoaPlugin')
}
}
)
_getConfig = createSelector(
() => this.props.sr && this.props.sr.other_config['xo:xosan_config'],
otherConfig => (otherConfig ? JSON.parse(otherConfig) : null)
@ -582,13 +616,59 @@ export default class TabXosan extends Component {
)
render () {
const { showAdvanced } = this.state
const { heal_, info_, sr, status_, statusDetail_, vbds, vdis } = this.props
const { license, licenseError, showAdvanced } = this.state
const {
heal_,
info_,
sr,
status_,
statusDetail_,
vbds,
vdis,
isAdmin,
} = this.props
const missingXoaPlugin = this._getMissingXoaPlugin()
if (missingXoaPlugin !== undefined) {
return <em>{missingXoaPlugin}</em>
}
const xosanConfig = this._getConfig()
if (
(license === undefined && licenseError === undefined) ||
xosanConfig === undefined
) {
return <em>{_('statusLoading')}</em>
}
if (!xosanConfig) {
return null
if (
licenseError !== undefined &&
licenseError.message !== 'No license found'
) {
return <span className='text-danger'>{_('xosanCheckLicenseError')}</span>
}
if (
licenseError !== undefined ||
(license !== undefined &&
license.productId !== 'xosan' &&
license.productId !== 'xosan.trial')
) {
return (
<span className='text-danger'>
{_('xosanAdminNoLicenseDisclaimer')}{' '}
{isAdmin && <Link to='/xoa/licenses'>{_('licensesManage')}</Link>}
</span>
)
}
if (license.expires < Date.now()) {
return (
<span className='text-danger'>
{_('xosanAdminExpiredLicenseDisclaimer')}{' '}
{isAdmin && <Link to='/xoa/licenses'>{_('licensesManage')}</Link>}
</span>
)
}
if (!xosanConfig.version) {

View File

@ -1,509 +0,0 @@
import _, { messages } from 'intl'
import ActionButton from 'action-button'
import AnsiUp from 'ansi_up'
import Button from 'button'
import Component from 'base-component'
import Icon from 'icon'
import Page from '../page'
import React from 'react'
import Tooltip from 'tooltip'
import xoaUpdater, { exposeTrial, isTrialRunning } from 'xoa-updater'
import { confirm } from 'modal'
import { connectStore } from 'utils'
import { Card, CardBlock, CardHeader } from 'card'
import { Container, Row, Col } from 'grid'
import { error } from 'notification'
import { injectIntl } from 'react-intl'
import { Password } from 'form'
import { serverVersion } from 'xo'
import { assign, includes, isEmpty, map } from 'lodash'
import pkg from '../../../package'
const ansiUp = new AnsiUp()
let updateSource
const promptForReload = (source, force) => {
if (force || (updateSource && source !== updateSource)) {
confirm({
title: _('promptUpgradeReloadTitle'),
body: <p>{_('promptUpgradeReloadMessage')}</p>,
}).then(() => window.location.reload())
}
updateSource = source
}
if (+process.env.XOA_PLAN < 5) {
xoaUpdater.start()
xoaUpdater.on('upgradeSuccessful', source => promptForReload(source, !source))
xoaUpdater.on('upToDate', promptForReload)
}
const HEADER = (
<Container>
<h2>
<Icon icon='menu-update' /> {_('updatePage')}
</h2>
</Container>
)
// FIXME: can't translate
const states = {
disconnected: 'Disconnected',
updating: 'Updating',
upgrading: 'Upgrading',
upToDate: 'Up to Date',
upgradeNeeded: 'Upgrade required',
registerNeeded: 'Registration required',
error: 'An error occured',
}
const update = () => xoaUpdater.update()
const upgrade = () => xoaUpdater.upgrade()
@connectStore(state => {
return {
configuration: state.xoaConfiguration,
log: state.xoaUpdaterLog,
registration: state.xoaRegisterState,
state: state.xoaUpdaterState,
trial: state.xoaTrialState,
}
})
@injectIntl
export default class XoaUpdates extends Component {
// These 3 inputs are "controlled" http://facebook.github.io/react/docs/forms.html#controlled-components
_handleProxyHostChange = event =>
this.setState({ proxyHost: event.target.value || '' })
_handleProxyPortChange = event =>
this.setState({ proxyPort: event.target.value || '' })
_handleProxyUserChange = event =>
this.setState({ proxyUser: event.target.value || '' })
_handleConfigReset = () => {
const { configuration } = this.props
const { proxyPassword } = this.refs
proxyPassword.value = ''
this.setState(configuration)
}
_register = async () => {
const { email, password } = this.state
const { registration } = this.props
const alreadyRegistered = registration.state === 'registered'
if (alreadyRegistered) {
try {
await confirm({
title: _('alreadyRegisteredModal'),
body: (
<p>
{_('alreadyRegisteredModalText', { email: registration.email })}
</p>
),
})
} catch (error) {
return
}
}
this.setState({ askRegisterAgain: false })
return xoaUpdater
.register(email, password, alreadyRegistered)
.then(() => this.setState({ email: '', password: '' }))
}
_configure = async () => {
const { proxyHost, proxyPort, proxyUser } = this.state
const { proxyPassword } = this.refs
return xoaUpdater
.configure({
proxyHost,
proxyPort,
proxyUser,
proxyPassword: proxyPassword.value,
})
.then(config => {
this.setState({
proxyHost: undefined,
proxyPort: undefined,
proxyUser: undefined,
})
proxyPassword.value = ''
})
}
_trialAllowed = trial => trial.state === 'default' && exposeTrial(trial.trial)
_trialAvailable = trial =>
trial.state === 'default' && isTrialRunning(trial.trial)
_trialConsumed = trial =>
trial.state === 'default' &&
!isTrialRunning(trial.trial) &&
!exposeTrial(trial.trial)
_updaterDown = trial => isEmpty(trial) || trial.state === 'ERROR'
_toggleAskRegisterAgain = () =>
this.setState({ askRegisterAgain: !this.state.askRegisterAgain })
_startTrial = async () => {
try {
await confirm({
title: _('trialReadyModal'),
body: <p>{_('trialReadyModalText')}</p>,
})
return xoaUpdater
.requestTrial()
.then(() => xoaUpdater.update())
.catch(err => error('Request Trial', err.message || String(err)))
} catch (_) {}
}
componentWillMount () {
this.setState({ askRegisterAgain: false })
serverVersion.then(serverVersion => {
this.setState({ serverVersion })
})
update()
}
render () {
const textClasses = {
info: 'text-info',
success: 'text-success',
warning: 'text-warning',
error: 'text-danger',
}
const { log, registration, state, trial } = this.props
let { configuration } = this.props // Configuration from the store
const alreadyRegistered = registration.state === 'registered'
configuration = assign({}, configuration)
const { proxyHost, proxyPort, proxyUser } = this.state // Edited non-saved configuration values override in view
let configEdited = false
proxyHost !== undefined &&
(configuration.proxyHost = proxyHost) &&
(configEdited = true)
proxyPort !== undefined &&
(configuration.proxyPort = proxyPort) &&
(configEdited = true)
proxyUser !== undefined &&
(configuration.proxyUser = proxyUser) &&
(configEdited = true)
const { formatMessage } = this.props.intl
return (
<Page header={HEADER} title='updateTitle' formatTitle>
<Container>
{+process.env.XOA_PLAN === 5 ? (
<div>
<h2 className='text-danger'>{_('noUpdaterCommunity')}</h2>
<p>
{_('considerSubscribe', {
link: (
<a href='https://xen-orchestra.com'>
https://xen-orchestra.com
</a>
),
})}
</p>
<p className='text-danger'>{_('noUpdaterWarning')}</p>
</div>
) : (
<div>
<Row>
<Col mediumSize={12}>
<Card>
<CardHeader>
<UpdateTag /> {states[state]}
</CardHeader>
<CardBlock>
<p>
{_('currentVersion')}{' '}
{`xo-server ${this.state.serverVersion}`} /{' '}
{`xo-web ${pkg.version}`}
</p>
{includes(['error', 'disconnected'], state) && (
<p>
<a href='https://xen-orchestra.com/docs/updater.html#troubleshooting'>
{_('updaterTroubleshootingLink')}
</a>
</p>
)}
<ActionButton
btnStyle='info'
handler={update}
icon='refresh'
>
{_('refresh')}
</ActionButton>{' '}
<ActionButton
btnStyle='success'
handler={upgrade}
icon='upgrade'
>
{_('upgrade')}
</ActionButton>
<hr />
<div>
{map(log, (log, key) => (
<p key={key}>
<span className={textClasses[log.level]}>
{log.date}
</span>:{' '}
<span
dangerouslySetInnerHTML={{
__html: ansiUp.ansi_to_html(log.message),
}}
/>
</p>
))}
</div>
</CardBlock>
</Card>
</Col>
</Row>
<Row>
<Col mediumSize={6}>
<Card>
<CardHeader>
{_('proxySettings')} {configEdited ? '*' : ''}
</CardHeader>
<CardBlock>
<form>
<fieldset>
<div className='form-group'>
<input
className='form-control'
placeholder={formatMessage(
messages.proxySettingsHostPlaceHolder
)}
type='text'
value={configuration.proxyHost}
onChange={this._handleProxyHostChange}
/>
</div>{' '}
<div className='form-group'>
<input
className='form-control'
placeholder={formatMessage(
messages.proxySettingsPortPlaceHolder
)}
type='text'
value={configuration.proxyPort}
onChange={this._handleProxyPortChange}
/>
</div>{' '}
<div className='form-group'>
<input
className='form-control'
placeholder={formatMessage(
messages.proxySettingsUsernamePlaceHolder
)}
type='text'
value={configuration.proxyUser}
onChange={this._handleProxyUserChange}
/>
</div>{' '}
<div className='form-group'>
<Password
placeholder={formatMessage(
messages.proxySettingsPasswordPlaceHolder
)}
ref='proxyPassword'
/>
</div>
</fieldset>
<br />
<fieldset>
<ActionButton
icon='save'
btnStyle='primary'
handler={this._configure}
>
{_('saveResourceSet')}
</ActionButton>{' '}
<Button
onClick={this._handleConfigReset}
disabled={!configEdited}
>
{_('resetResourceSet')}
</Button>
</fieldset>
</form>
</CardBlock>
</Card>
</Col>
<Col mediumSize={6}>
<Card>
<CardHeader>{_('registration')}</CardHeader>
<CardBlock>
<strong>{registration.state}</strong>
{registration.email && (
<span> to {registration.email}</span>
)}
<span className='text-danger'> {registration.error}</span>
{!alreadyRegistered || this.state.askRegisterAgain ? (
<form id='registrationForm'>
<div className='form-group'>
<input
className='form-control'
onChange={this.linkState('email')}
placeholder={formatMessage(
messages.updateRegistrationEmailPlaceHolder
)}
required
type='text'
/>
</div>{' '}
<div className='form-group'>
<Password
disabled={!this.state.email}
onChange={this.linkState('password')}
placeholder={formatMessage(
messages.updateRegistrationPasswordPlaceHolder
)}
required
/>
</div>{' '}
<ActionButton
form='registrationForm'
icon='success'
btnStyle='primary'
handler={this._register}
>
{_('register')}
</ActionButton>
</form>
) : (
<ActionButton
icon='edit'
btnStyle='primary'
handler={this._toggleAskRegisterAgain}
>
{_('editRegistration')}
</ActionButton>
)}
{+process.env.XOA_PLAN === 1 && (
<div>
<h2>{_('trial')}</h2>
{this._trialAllowed(trial) && (
<div>
{registration.state !== 'registered' && (
<p>{_('trialRegistration')}</p>
)}
{registration.state === 'registered' && (
<ActionButton
btnStyle='success'
handler={this._startTrial}
icon='trial'
>
{_('trialStartButton')}
</ActionButton>
)}
</div>
)}
{this._trialAvailable(trial) && (
<p className='text-success'>
{_('trialAvailableUntil', {
date: new Date(trial.trial.end),
})}
</p>
)}
{this._trialConsumed(trial) && (
<p>{_('trialConsumed')}</p>
)}
</div>
)}
{process.env.XOA_PLAN > 1 &&
process.env.XOA_PLAN < 5 && (
<div>
{trial.state === 'trustedTrial' && (
<p>{trial.message}</p>
)}
{trial.state === 'untrustedTrial' && (
<p className='text-danger'>{trial.message}</p>
)}
</div>
)}
{process.env.XOA_PLAN < 5 && (
<div>
{this._updaterDown(trial) && (
<p className='text-danger'>{_('trialLocked')}</p>
)}
</div>
)}
</CardBlock>
</Card>
</Col>
</Row>
</div>
)}
</Container>
</Page>
)
}
}
const UpdateAlarm = () => (
<span className='fa-stack'>
<i className='fa fa-circle fa-stack-2x text-danger' />
<i className='fa fa-exclamation fa-stack-1x' />
</span>
)
const UpdateError = () => (
<span className='fa-stack'>
<i className='fa fa-circle fa-stack-2x text-danger' />
<i className='fa fa-question fa-stack-1x' />
</span>
)
const UpdateWarning = () => (
<span className='fa-stack'>
<i className='fa fa-circle fa-stack-2x text-warning' />
<i className='fa fa-question fa-stack-1x' />
</span>
)
const UpdateSuccess = () => <Icon icon='success' />
const UpdateAlert = () => (
<span className='fa-stack'>
<i className='fa fa-circle fa-stack-2x text-success' />
<i className='fa fa-bell fa-stack-1x' />
</span>
)
const RegisterAlarm = () => (
<Icon icon='not-registered' className='text-warning' />
)
export const UpdateTag = connectStore(state => {
return {
configuration: state.xoaConfiguration,
log: state.xoaUpdaterLog,
registration: state.xoaRegisterState,
state: state.xoaUpdaterState,
trial: state.xoaTrialState,
}
})(props => {
const { state } = props
const components = {
disconnected: <UpdateError />,
connected: <UpdateWarning />,
upToDate: <UpdateSuccess />,
upgradeNeeded: <UpdateAlert />,
registerNeeded: <RegisterAlarm />,
error: <UpdateAlarm />,
}
const tooltips = {
disconnected: _('noUpdateInfo'),
connected: _('waitingUpdateInfo'),
upToDate: _('upToDate'),
upgradeNeeded: _('mustUpgrade'),
registerNeeded: _('registerNeeded'),
error: _('updaterError'),
}
return <Tooltip content={tooltips[state]}>{components[state]}</Tooltip>
})

58
src/xo-app/xoa/index.js Normal file
View File

@ -0,0 +1,58 @@
import _ from 'intl'
import Icon from 'icon'
import Page from '../page'
import React from 'react'
import { routes } from 'utils'
import { Container, Row, Col } from 'grid'
import { NavLink, NavTabs } from 'nav'
import Update from './update'
import Licenses from './licenses'
const HEADER = (
<Container>
<Row>
<Col mediumSize={3}>
<h2>
<Icon icon='menu-xoa' /> {_('xoaPage')}
</h2>
</Col>
<Col mediumSize={9}>
<NavTabs className='pull-right'>
<NavLink to={'/xoa/update'}>
<Icon icon='menu-xoa-update' /> {_('updatePage')}
</NavLink>
<NavLink to={'/xoa/licenses'}>
<Icon icon='menu-xoa-licenses' /> {_('licensesPage')}
</NavLink>
</NavTabs>
</Col>
</Row>
</Container>
)
const Xoa = routes('xoa', {
update: Update,
licenses: Licenses,
})(
({ children }) =>
+process.env.XOA_PLAN === 5 ? (
<Container>
<h2 className='text-danger'>{_('noUpdaterCommunity')}</h2>
<p>
{_('considerSubscribe', {
link: (
<a href='https://xen-orchestra.com'>https://xen-orchestra.com</a>
),
})}
</p>
<p className='text-danger'>{_('noUpdaterWarning')}</p>
</Container>
) : (
<Page header={HEADER} title='xoaPage' formatTitle>
{children}
</Page>
)
)
export default Xoa

View File

@ -0,0 +1,234 @@
import _ from 'intl'
import ActionButton from 'action-button'
import Component from 'base-component'
import Link from 'link'
import React from 'react'
import renderXoItem from 'render-xo-item'
import SortedTable from 'sorted-table'
import { Container, Row, Col } from 'grid'
import { createSelector, createGetObjectsOfType } from 'selectors'
import { find, forEach } from 'lodash'
import { addSubscriptions, connectStore, ShortDate } from 'utils'
import { subscribePlugins, getLicenses } from 'xo'
import { get } from 'xo-defined'
import Xosan from './xosan'
const openNewLicense = () => {
// FIXME: use link with target attribute
window.open('https://xen-orchestra.com/#!/member/purchaser')
}
const openSupport = () => {
// FIXME: use link with target attribute
window.open('https://xen-orchestra.com/#!/xosan-home/')
}
const PRODUCTS_COLUMNS = [
{
name: _('licenseProduct'),
itemRenderer: ({ product, id }) => (
<span>
{product} <span className='text-muted'>({id.slice(-4)})</span>
</span>
),
sortCriteria: ({ product, id }) => product + id.slice(-4),
default: true,
},
{
name: _('licenseBoundObject'),
itemRenderer: ({ renderBoundObject }) =>
renderBoundObject !== undefined && renderBoundObject(),
},
{
name: _('licensePurchaser'),
itemRenderer: ({ buyer }, { registeredEmail }) =>
buyer !== undefined ? (
buyer.email === registeredEmail ? (
_('licensePurchaserYou')
) : (
<a href={`mailto:${buyer.email}`}>{buyer.email}</a>
)
) : (
'-'
),
sortCriteria: 'buyer.email',
},
{
name: _('licenseExpires'),
itemRenderer: ({ expires }) =>
expires !== undefined ? <ShortDate timestamp={expires} /> : '-',
sortCriteria: 'expires',
sortOrder: 'desc',
},
]
const getBoundXosanRenderer = (boundObjectId, xosanSrs) => {
if (boundObjectId === undefined) {
return () => _('licenseNotBoundXosan')
}
const sr = xosanSrs[boundObjectId]
if (sr === undefined) {
return () => _('licenseBoundUnknownXosan')
}
return () => <Link to={`srs/${sr.id}`}>{renderXoItem(sr)}</Link>
}
@connectStore({
xosanSrs: createGetObjectsOfType('SR').filter([
({ SR_type }) => SR_type === 'xosan', // eslint-disable-line camelcase
]),
xoaRegistration: state => state.xoaRegisterState,
})
@addSubscriptions(() => ({
plugins: subscribePlugins,
}))
export default class Licenses extends Component {
constructor () {
super()
this.componentDidMount = this._updateLicenses
}
_updateLicenses = () =>
Promise.all([getLicenses('xosan'), getLicenses('xosan.trial')])
.then(([xosanLicenses, xosanTrialLicenses]) => {
this.setState({
xosanLicenses,
xosanTrialLicenses,
licenseError: undefined,
})
})
.catch(error => {
this.setState({ licenseError: error })
})
_getProducts = createSelector(
() => this.state.xosanLicenses,
() => this.props.xosanSrs,
(xosanLicenses, xosanSrs) => {
const products = []
if (get(() => xosanLicenses.state) === 'register-needed') {
// Should not happen
return
}
// XOSAN
const boundSrs = []
forEach(xosanLicenses, license => {
if (license.boundObjectId !== undefined) {
boundSrs.push(license.boundObjectId)
}
products.push({
product: 'XOSAN',
renderBoundObject: getBoundXosanRenderer(
license.boundObjectId,
xosanSrs
),
buyer: license.buyer,
expires: license.expires,
id: license.id,
})
})
return products
}
)
_getMissingXoaPlugin = createSelector(
() => this.props.plugins,
plugins => {
if (plugins === undefined) {
return true
}
const xoaPlugin = find(plugins, { id: 'xoa' })
if (!xoaPlugin) {
return _('xosanInstallXoaPlugin')
}
if (!xoaPlugin.loaded) {
return _('xosanLoadXoaPlugin')
}
}
)
render () {
if (get(() => this.props.xoaRegistration.state) !== 'registered') {
return (
<span>
<em>{_('licensesUnregisteredDisclaimer')}</em>{' '}
<Link to='xoa/update'>{_('registerNow')}</Link>
</span>
)
}
const missingXoaPlugin = this._getMissingXoaPlugin()
if (missingXoaPlugin !== undefined) {
return <em>{missingXoaPlugin}</em>
}
if (this.state.licenseError !== undefined) {
return <span className='text-danger'>{_('xosanGetLicensesError')}</span>
}
if (
this.state.xosanLicenses === undefined &&
this.state.xosanTrialLicenses === undefined
) {
return <em>{_('statusLoading')}</em>
}
return (
<Container>
<Row className='mb-1'>
<Col>
<ActionButton
btnStyle='success'
icon='add'
handler={openNewLicense}
>
{_('newLicense')}
</ActionButton>
<ActionButton
btnStyle='primary'
className='ml-1'
icon='refresh'
handler={this._updateLicenses}
>
{_('refreshLicenses')}
</ActionButton>
</Col>
</Row>
<Row>
<Col>
<SortedTable
collection={this._getProducts()}
columns={PRODUCTS_COLUMNS}
userData={{
registeredEmail: this.props.xoaRegistration.email,
}}
/>
</Col>
</Row>
<Row>
<Col>
<h2>
XOSAN
<ActionButton className='ml-1' handler={openSupport} icon='bug'>
{_('productSupport')}
</ActionButton>
</h2>
<Xosan
xosanLicenses={this.state.xosanLicenses}
xosanTrialLicenses={this.state.xosanTrialLicenses}
updateLicenses={this._updateLicenses}
/>
</Col>
</Row>
</Container>
)
}
}

View File

@ -0,0 +1,192 @@
import _ from 'intl'
import ActionButton from 'action-button'
import Component from 'base-component'
import Link from 'link'
import React from 'react'
import renderXoItem from 'render-xo-item'
import SortedTable from 'sorted-table'
import { connectStore } from 'utils'
import { createSelector, createGetObjectsOfType, createFilter } from 'selectors'
import { unlockXosan } from 'xo'
import { get } from 'xo-defined'
import { filter, forEach, includes, map } from 'lodash'
import { injectIntl } from 'react-intl'
@injectIntl
class SelectLicense extends Component {
state = { license: 'none' }
render () {
return (
<form className='form-inline'>
<select className='form-control' onChange={this.linkState('license')}>
{_('selectLicense', message => (
<option key='none' value='none'>
{message}
</option>
))}
{map(this.props.licenses, license =>
_(
'expiresOn',
{
date:
license.expires !== undefined
? this.props.intl.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>
)
)
)}
</select>
<ActionButton
btnStyle='primary'
className='ml-1'
disabled={this.state.license === 'none'}
handler={this.props.onChange}
handlerParam={get(() => this.state.license)}
icon='connect'
>
{_('bindLicense')}
</ActionButton>
</form>
)
}
}
const XOSAN_COLUMNS = [
{
name: _('xosanName'),
itemRenderer: sr => <Link to={`srs/${sr.id}`}>{renderXoItem(sr)}</Link>,
sortCriteria: 'name_label',
},
{
name: _('xosanPool'),
itemRenderer: (sr, { poolsBySr }) => {
const pool = poolsBySr[sr.id]
return <Link to={`pools/${pool.id}`}>{renderXoItem(pool)}</Link>
},
},
{
name: _('xosanLicense'),
itemRenderer: (
sr,
{ availableLicenses, licensesByXosan, updateLicenses }
) => {
const license = licensesByXosan[sr.id]
if (license === null) {
return (
<span className='text-danger'>
{_('xosanMultipleLicenses')}{' '}
<a href='https://xen-orchestra.com/'>{_('contactUs')}</a>
</span>
)
}
return license !== undefined ? (
license.id.slice(-4)
) : (
<SelectLicense
licenses={availableLicenses}
onChange={licenseId =>
unlockXosan(licenseId, sr.id).then(updateLicenses)
}
/>
)
},
},
]
const XOSAN_INDIVIDUAL_ACTIONS = [
{
label: _('productSupport'),
icon: 'support',
handler: () => window.open('https://xen-orchestra.com'),
},
]
@connectStore(() => {
const getXosanSrs = createGetObjectsOfType('SR').filter([
({ SR_type }) => SR_type === 'xosan', // eslint-disable-line camelcase
])
const getPoolsBySr = createSelector(
getXosanSrs,
createGetObjectsOfType('pool'),
(srs, pools) => {
const poolsBySr = {}
forEach(srs, sr => {
poolsBySr[sr.id] = pools[sr.$pool]
})
return poolsBySr
}
)
return {
xosanSrs: getXosanSrs,
poolsBySr: getPoolsBySr,
}
})
export default class Xosan extends Component {
_getLicensesByXosan = createSelector(
() => this.props.xosanLicenses,
licenses => {
const licensesByXosan = {}
forEach(licenses, license => {
let xosanId
if ((xosanId = license.boundObjectId) === undefined) {
return
}
licensesByXosan[xosanId] =
licensesByXosan[xosanId] !== undefined
? null // XOSAN bound to multiple licenses!
: license
})
return licensesByXosan
}
)
_getAvailableLicenses = createFilter(() => this.props.xosanLicenses, [
({ boundObjectId, expires }) =>
boundObjectId === undefined &&
(expires === undefined || expires > Date.now()),
])
_getKnownXosans = createSelector(
createSelector(
() => this.props.xosanLicenses,
() => this.props.xosanTrialLicenses,
(licenses = [], trialLicenses = []) =>
filter(map(licenses.concat(trialLicenses), 'boundObjectId'))
),
() => this.props.xosanSrs,
(knownXosanIds, xosanSrs) =>
filter(xosanSrs, ({ id }) => includes(knownXosanIds, id))
)
render () {
return (
<SortedTable
collection={this._getKnownXosans()}
columns={XOSAN_COLUMNS}
individualActions={XOSAN_INDIVIDUAL_ACTIONS}
userData={{
availableLicenses: this._getAvailableLicenses(),
licensesByXosan: this._getLicensesByXosan(),
poolsBySr: this.props.poolsBySr,
xosanSrs: this.props.xosanSrs,
updateLicenses: this.props.updateLicenses,
}}
/>
)
}
}

View File

@ -0,0 +1,468 @@
import _, { messages } from 'intl'
import ActionButton from 'action-button'
import AnsiUp from 'ansi_up'
import Button from 'button'
import Component from 'base-component'
import Icon from 'icon'
import React from 'react'
import Tooltip from 'tooltip'
import xoaUpdater, { exposeTrial, isTrialRunning } from 'xoa-updater'
import { confirm } from 'modal'
import { connectStore } from 'utils'
import { Card, CardBlock, CardHeader } from 'card'
import { Container, Row, Col } from 'grid'
import { error } from 'notification'
import { injectIntl } from 'react-intl'
import { Password } from 'form'
import { serverVersion } from 'xo'
import { assign, includes, isEmpty, map } from 'lodash'
import pkg from '../../../../package'
const ansiUp = new AnsiUp()
let updateSource
const promptForReload = (source, force) => {
if (force || (updateSource && source !== updateSource)) {
confirm({
title: _('promptUpgradeReloadTitle'),
body: <p>{_('promptUpgradeReloadMessage')}</p>,
}).then(() => window.location.reload())
}
updateSource = source
}
if (+process.env.XOA_PLAN < 5) {
xoaUpdater.start()
xoaUpdater.on('upgradeSuccessful', source => promptForReload(source, !source))
xoaUpdater.on('upToDate', promptForReload)
}
// FIXME: can't translate
const states = {
disconnected: 'Disconnected',
updating: 'Updating',
upgrading: 'Upgrading',
upToDate: 'Up to Date',
upgradeNeeded: 'Upgrade required',
registerNeeded: 'Registration required',
error: 'An error occured',
}
const update = () => xoaUpdater.update()
const upgrade = () => xoaUpdater.upgrade()
@connectStore(state => {
return {
configuration: state.xoaConfiguration,
log: state.xoaUpdaterLog,
registration: state.xoaRegisterState,
state: state.xoaUpdaterState,
trial: state.xoaTrialState,
}
})
@injectIntl
export default class XoaUpdates extends Component {
// These 3 inputs are "controlled" http://facebook.github.io/react/docs/forms.html#controlled-components
_handleProxyHostChange = event =>
this.setState({ proxyHost: event.target.value || '' })
_handleProxyPortChange = event =>
this.setState({ proxyPort: event.target.value || '' })
_handleProxyUserChange = event =>
this.setState({ proxyUser: event.target.value || '' })
_handleConfigReset = () => {
const { configuration } = this.props
const { proxyPassword } = this.refs
proxyPassword.value = ''
this.setState(configuration)
}
_register = async () => {
const { email, password } = this.state
const { registration } = this.props
const alreadyRegistered = registration.state === 'registered'
if (alreadyRegistered) {
try {
await confirm({
title: _('alreadyRegisteredModal'),
body: (
<p>
{_('alreadyRegisteredModalText', { email: registration.email })}
</p>
),
})
} catch (error) {
return
}
}
this.setState({ askRegisterAgain: false })
return xoaUpdater
.register(email, password, alreadyRegistered)
.then(() => this.setState({ email: '', password: '' }))
}
_configure = async () => {
const { proxyHost, proxyPort, proxyUser } = this.state
const { proxyPassword } = this.refs
return xoaUpdater
.configure({
proxyHost,
proxyPort,
proxyUser,
proxyPassword: proxyPassword.value,
})
.then(config => {
this.setState({
proxyHost: undefined,
proxyPort: undefined,
proxyUser: undefined,
})
proxyPassword.value = ''
})
}
_trialAllowed = trial => trial.state === 'default' && exposeTrial(trial.trial)
_trialAvailable = trial =>
trial.state === 'default' && isTrialRunning(trial.trial)
_trialConsumed = trial =>
trial.state === 'default' &&
!isTrialRunning(trial.trial) &&
!exposeTrial(trial.trial)
_updaterDown = trial => isEmpty(trial) || trial.state === 'ERROR'
_toggleAskRegisterAgain = () =>
this.setState({ askRegisterAgain: !this.state.askRegisterAgain })
_startTrial = async () => {
try {
await confirm({
title: _('trialReadyModal'),
body: <p>{_('trialReadyModalText')}</p>,
})
return xoaUpdater
.requestTrial()
.then(() => xoaUpdater.update())
.catch(err => error('Request Trial', err.message || String(err)))
} catch (_) {}
}
componentWillMount () {
this.setState({ askRegisterAgain: false })
serverVersion.then(serverVersion => {
this.setState({ serverVersion })
})
update()
}
render () {
const textClasses = {
info: 'text-info',
success: 'text-success',
warning: 'text-warning',
error: 'text-danger',
}
const { log, registration, state, trial } = this.props
let { configuration } = this.props // Configuration from the store
const alreadyRegistered = registration.state === 'registered'
configuration = assign({}, configuration)
const { proxyHost, proxyPort, proxyUser } = this.state // Edited non-saved configuration values override in view
let configEdited = false
proxyHost !== undefined &&
(configuration.proxyHost = proxyHost) &&
(configEdited = true)
proxyPort !== undefined &&
(configuration.proxyPort = proxyPort) &&
(configEdited = true)
proxyUser !== undefined &&
(configuration.proxyUser = proxyUser) &&
(configEdited = true)
const { formatMessage } = this.props.intl
return (
<Container>
<Row>
<Col mediumSize={12}>
<Card>
<CardHeader>
<UpdateTag /> {states[state]}
</CardHeader>
<CardBlock>
<p>
{_('currentVersion')}{' '}
{`xo-server ${this.state.serverVersion}`} /{' '}
{`xo-web ${pkg.version}`}
</p>
{includes(['error', 'disconnected'], state) && (
<p>
<a href='https://xen-orchestra.com/docs/updater.html#troubleshooting'>
{_('updaterTroubleshootingLink')}
</a>
</p>
)}
<ActionButton btnStyle='info' handler={update} icon='refresh'>
{_('refresh')}
</ActionButton>{' '}
<ActionButton
btnStyle='success'
handler={upgrade}
icon='upgrade'
>
{_('upgrade')}
</ActionButton>
<hr />
<div>
{map(log, (log, key) => (
<p key={key}>
<span className={textClasses[log.level]}>{log.date}</span>:{' '}
<span
dangerouslySetInnerHTML={{
__html: ansiUp.ansi_to_html(log.message),
}}
/>
</p>
))}
</div>
</CardBlock>
</Card>
</Col>
</Row>
<Row>
<Col mediumSize={6}>
<Card>
<CardHeader>
{_('proxySettings')} {configEdited ? '*' : ''}
</CardHeader>
<CardBlock>
<form>
<fieldset>
<div className='form-group'>
<input
className='form-control'
placeholder={formatMessage(
messages.proxySettingsHostPlaceHolder
)}
type='text'
value={configuration.proxyHost}
onChange={this._handleProxyHostChange}
/>
</div>{' '}
<div className='form-group'>
<input
className='form-control'
placeholder={formatMessage(
messages.proxySettingsPortPlaceHolder
)}
type='text'
value={configuration.proxyPort}
onChange={this._handleProxyPortChange}
/>
</div>{' '}
<div className='form-group'>
<input
className='form-control'
placeholder={formatMessage(
messages.proxySettingsUsernamePlaceHolder
)}
type='text'
value={configuration.proxyUser}
onChange={this._handleProxyUserChange}
/>
</div>{' '}
<div className='form-group'>
<Password
placeholder={formatMessage(
messages.proxySettingsPasswordPlaceHolder
)}
ref='proxyPassword'
/>
</div>
</fieldset>
<br />
<fieldset>
<ActionButton
icon='save'
btnStyle='primary'
handler={this._configure}
>
{_('saveResourceSet')}
</ActionButton>{' '}
<Button
onClick={this._handleConfigReset}
disabled={!configEdited}
>
{_('resetResourceSet')}
</Button>
</fieldset>
</form>
</CardBlock>
</Card>
</Col>
<Col mediumSize={6}>
<Card>
<CardHeader>{_('registration')}</CardHeader>
<CardBlock>
<strong>{registration.state}</strong>
{registration.email && <span> to {registration.email}</span>}
<span className='text-danger'> {registration.error}</span>
{!alreadyRegistered || this.state.askRegisterAgain ? (
<form id='registrationForm'>
<div className='form-group'>
<input
className='form-control'
onChange={this.linkState('email')}
placeholder={formatMessage(
messages.updateRegistrationEmailPlaceHolder
)}
required
type='text'
/>
</div>{' '}
<div className='form-group'>
<Password
disabled={!this.state.email}
onChange={this.linkState('password')}
placeholder={formatMessage(
messages.updateRegistrationPasswordPlaceHolder
)}
required
/>
</div>{' '}
<ActionButton
form='registrationForm'
icon='success'
btnStyle='primary'
handler={this._register}
>
{_('register')}
</ActionButton>
</form>
) : (
<ActionButton
icon='edit'
btnStyle='primary'
handler={this._toggleAskRegisterAgain}
>
{_('editRegistration')}
</ActionButton>
)}
{+process.env.XOA_PLAN === 1 && (
<div>
<h2>{_('trial')}</h2>
{this._trialAllowed(trial) && (
<div>
{registration.state !== 'registered' && (
<p>{_('trialRegistration')}</p>
)}
{registration.state === 'registered' && (
<ActionButton
btnStyle='success'
handler={this._startTrial}
icon='trial'
>
{_('trialStartButton')}
</ActionButton>
)}
</div>
)}
{this._trialAvailable(trial) && (
<p className='text-success'>
{_('trialAvailableUntil', {
date: new Date(trial.trial.end),
})}
</p>
)}
{this._trialConsumed(trial) && <p>{_('trialConsumed')}</p>}
</div>
)}
{process.env.XOA_PLAN > 1 &&
process.env.XOA_PLAN < 5 && (
<div>
{trial.state === 'trustedTrial' && <p>{trial.message}</p>}
{trial.state === 'untrustedTrial' && (
<p className='text-danger'>{trial.message}</p>
)}
</div>
)}
{process.env.XOA_PLAN < 5 && (
<div>
{this._updaterDown(trial) && (
<p className='text-danger'>{_('trialLocked')}</p>
)}
</div>
)}
</CardBlock>
</Card>
</Col>
</Row>
</Container>
)
}
}
const UpdateAlarm = () => (
<span className='fa-stack'>
<i className='fa fa-circle fa-stack-2x text-danger' />
<i className='fa fa-exclamation fa-stack-1x' />
</span>
)
const UpdateError = () => (
<span className='fa-stack'>
<i className='fa fa-circle fa-stack-2x text-danger' />
<i className='fa fa-question fa-stack-1x' />
</span>
)
const UpdateWarning = () => (
<span className='fa-stack'>
<i className='fa fa-circle fa-stack-2x text-warning' />
<i className='fa fa-question fa-stack-1x' />
</span>
)
const UpdateSuccess = () => <Icon icon='success' />
const UpdateAlert = () => (
<span className='fa-stack'>
<i className='fa fa-circle fa-stack-2x text-success' />
<i className='fa fa-bell fa-stack-1x' />
</span>
)
const RegisterAlarm = () => (
<Icon icon='not-registered' className='text-warning' />
)
export const UpdateTag = connectStore(state => {
return {
configuration: state.xoaConfiguration,
log: state.xoaUpdaterLog,
registration: state.xoaRegisterState,
state: state.xoaUpdaterState,
trial: state.xoaTrialState,
}
})(props => {
const { state } = props
const components = {
disconnected: <UpdateError />,
connected: <UpdateWarning />,
upToDate: <UpdateSuccess />,
upgradeNeeded: <UpdateAlert />,
registerNeeded: <RegisterAlarm />,
error: <UpdateAlarm />,
}
const tooltips = {
disconnected: _('noUpdateInfo'),
connected: _('waitingUpdateInfo'),
upToDate: _('upToDate'),
upgradeNeeded: _('mustUpgrade'),
registerNeeded: _('registerNeeded'),
error: _('updaterError'),
}
return <Tooltip content={tooltips[state]}>{components[state]}</Tooltip>
})

View File

@ -8,27 +8,30 @@ import React from 'react'
import SortedTable from 'sorted-table'
import Tooltip from 'tooltip'
import { Container, Col, Row } from 'grid'
import { get } from 'xo-defined'
import {
every,
filter,
find,
flatten,
forEach,
isEmpty,
map,
mapValues,
some,
} from 'lodash'
import { createGetObjectsOfType, createSelector } from 'selectors'
import { createGetObjectsOfType, createSelector, isAdmin } from 'selectors'
import {
addSubscriptions,
connectStore,
cowSet,
formatSize,
isXosanPack,
ShortDate,
} from 'utils'
import {
deleteSr,
registerXosan,
getLicenses,
subscribePlugins,
subscribeResourceCatalog,
subscribeVolumeInfo,
@ -38,6 +41,7 @@ import NewXosan from './new-xosan'
import CreationProgress from './creation-progress'
export const INFO_TYPES = ['heal', 'status', 'info', 'statusDetail', 'hosts']
const EXPIRES_SOON_DELAY = 30 * 24 * 60 * 60 * 1000 // 1 month
// ==================================================================
@ -54,11 +58,14 @@ const HEADER = (
const XOSAN_COLUMNS = [
{
itemRenderer: (sr, { status }) => {
if (status === undefined || status[sr.id] === undefined) {
return null
}
const pbdsDetached = every(map(sr.pbds, 'attached'))
? null
: _('xosanPbdsDetached')
const badStatus =
status && every(status[sr.id])
const badStatus = every(status[sr.id])
? null
: _('xosanBadStatus', {
badStatuses: (
@ -131,6 +138,71 @@ const XOSAN_COLUMNS = [
) : null,
sortCriteria: sr => sr.physical_usage * 100 / sr.size,
},
{
name: _('xosanLicense'),
itemRenderer: (sr, { isAdmin, licensesByXosan, licenseError }) => {
if (licenseError !== undefined) {
return
}
const license = licensesByXosan[sr.id]
// XOSAN not bound to any license, not even trial
if (license === undefined) {
return (
<span className='text-danger'>
{_('xosanUnknownSr')}{' '}
<a href='https://xen-orchestra.com/'>{_('contactUs')}</a>
</span>
)
}
// XOSAN bound to multiple licenses
if (license === null) {
return (
<span className='text-danger'>
{_('xosanMultipleLicenses')}{' '}
<a href='https://xen-orchestra.com/'>{_('contactUs')}</a>
</span>
)
}
const now = Date.now()
const expiresSoon = license.expires - now < EXPIRES_SOON_DELAY
const expired = license.expires < now
return license.productId === 'xosan' ? (
<span>
{license.expires === undefined ? (
'✔'
) : expired ? (
<span>
{_('xosanLicenseHasExpired')}{' '}
{isAdmin && (
<Link to='/xoa/licenses'>{_('xosanUpdateLicenseMessage')}</Link>
)}
</span>
) : (
<span className={expiresSoon && 'text-danger'}>
{_('xosanLicenseExpiresDate', {
date: <ShortDate timestamp={license.expires} />,
})}{' '}
{expiresSoon &&
isAdmin && (
<Link to='/xoa/licenses'>
{_('xosanUpdateLicenseMessage')}
</Link>
)}
</span>
)}
</span>
) : (
<span>
{_('xosanNoLicense')}{' '}
<Link to='/xoa/licenses'>{_('xosanUnlockNow')}</Link>
</span>
)
},
},
]
const XOSAN_INDIVIDUAL_ACTIONS = [
@ -214,11 +286,13 @@ const XOSAN_INDIVIDUAL_ACTIONS = [
)
return {
isAdmin,
isMasterOfflineByPool: getIsMasterOfflineByPool,
hostsNeedRestartByPool: getHostsNeedRestartByPool,
noPacksByPool,
poolPredicate: getPoolPredicate,
pools: getPools,
xoaRegistration: state => state.xoaRegisterState,
xosanSrs: getXosanSrs,
}
})
@ -228,20 +302,51 @@ const XOSAN_INDIVIDUAL_ACTIONS = [
})
export default class Xosan extends Component {
componentDidMount () {
this._updateLicenses().then(() =>
this._subscribeVolumeInfo(this.props.xosanSrs)
)
}
componentWillReceiveProps ({ pools, xosanSrs }) {
if (xosanSrs !== this.props.xosanSrs) this._subscribeVolumeInfo(xosanSrs)
if (xosanSrs !== this.props.xosanSrs) {
this.unsubscribeVolumeInfo && this.unsubscribeVolumeInfo()
this._subscribeVolumeInfo(xosanSrs)
}
}
componentWillUnmount () {
if (this.unsubscribeVolumeInfo != null) this.unsubscribeVolumeInfo()
}
_updateLicenses = () =>
Promise.all([getLicenses('xosan'), getLicenses('xosan.trial')])
.then(([xosanLicenses, xosanTrialLicenses]) => {
this.setState({
xosanLicenses,
xosanTrialLicenses,
})
})
.catch(error => {
this.setState({ licenseError: error })
})
_subscribeVolumeInfo = srs => {
const licensesByXosan = this._getLicensesByXosan()
const now = Date.now()
const canAdminXosan = sr => {
const license = licensesByXosan[sr.id]
return (
license !== undefined &&
(license.expires === undefined || license.expires > now)
)
}
const unsubscriptions = []
forEach(srs, sr => {
if (!canAdminXosan(sr)) {
return
}
forEach(INFO_TYPES, infoType =>
unsubscriptions.push(
subscribeVolumeInfo({ sr, infoType }, info =>
@ -256,10 +361,29 @@ export default class Xosan extends Component {
forEach(unsubscriptions, unsubscribe => unsubscribe())
}
_getLicensesByXosan = createSelector(
() => this.state.xosanLicenses,
() => this.state.xosanTrialLicenses,
(xosanLicenses = [], xosanTrialLicenses = []) => {
const licensesByXosan = {}
forEach(flatten([xosanLicenses, xosanTrialLicenses]), license => {
let xosanId
if ((xosanId = license.boundObjectId) === undefined) {
return
}
licensesByXosan[xosanId] =
licensesByXosan[xosanId] !== undefined
? null // XOSAN bound to multiple licenses!
: license
})
return licensesByXosan
}
)
_getError = createSelector(
() => this.props.plugins,
() => this.props.catalog,
(plugins, catalog) => {
plugins => {
const cloudPlugin = find(plugins, { id: 'cloud' })
if (!cloudPlugin) {
return _('xosanInstallCloudPlugin')
@ -268,43 +392,33 @@ export default class Xosan extends Component {
if (!cloudPlugin.loaded) {
return _('xosanLoadCloudPlugin')
}
if (!catalog) {
return _('xosanLoading')
}
const { xosan } = catalog._namespaces
if (!xosan) {
return (
<span>
<Icon icon='error' /> {_('xosanNotAvailable')}
</span>
)
}
if (xosan.available) {
return (
<ActionButton handler={registerXosan} btnStyle='primary' icon='add'>
{_('xosanRegisterBeta')}
</ActionButton>
)
}
if (xosan.pending) {
return _('xosanSuccessfullyRegistered')
}
}
_showBetaIsOver = createSelector(
() => this.props.catalog,
() => this.state.xosanLicenses,
() => this.state.xosanTrialLicenses,
() => this.state.licenseError,
(catalog, xosanLicenses, xosanTrialLicenses, licenseError) =>
licenseError === undefined &&
get(() => catalog._namespaces.xosan) !== undefined &&
isEmpty(xosanLicenses) &&
isEmpty(xosanTrialLicenses)
)
_onSrCreationStarted = () => this.setState({ showNewXosanForm: false })
render () {
const {
xosanSrs,
noPacksByPool,
hostsNeedRestartByPool,
isAdmin,
noPacksByPool,
poolPredicate,
xoaRegistration,
xosanSrs,
} = this.props
const { licenseError } = this.state
const error = this._getError()
return (
@ -319,7 +433,14 @@ export default class Xosan extends Component {
</Row>
) : (
[
<Row className='mb-1'>
this._showBetaIsOver() && (
<Row key='beta-is-over'>
<Col>
<em>{_('xosanBetaOverMessage')}</em>
</Col>
</Row>
),
<Row key='new-button' className='mb-1'>
<Col>
<ActionButton
btnStyle='primary'
@ -330,26 +451,39 @@ export default class Xosan extends Component {
</ActionButton>
</Col>
</Row>,
<Row>
<Row key='new-form'>
<Col>
{this.state.showNewXosanForm && (
<NewXosan
hostsNeedRestartByPool={hostsNeedRestartByPool}
noPacksByPool={noPacksByPool}
poolPredicate={poolPredicate}
onSrCreationFinished={this._updateLicenses}
onSrCreationStarted={this._onSrCreationStarted}
notRegistered={
get(() => xoaRegistration.state) !== 'registered'
}
/>
)}
</Col>
</Row>,
<Row>
<Row key='progress'>
<Col>
{map(this.props.pools, pool => (
<CreationProgress key={pool.id} pool={pool} />
))}
</Col>
</Row>,
licenseError !== undefined && (
<Row>
<Col>
<em className='text-danger'>
{_('xosanGetLicensesError')}
</em>
</Col>
</Row>
),
<Row key='srs'>
<Col>
{isEmpty(xosanSrs) ? (
<em>{_('xosanNoSrs')}</em>
@ -359,6 +493,9 @@ export default class Xosan extends Component {
columns={XOSAN_COLUMNS}
individualActions={XOSAN_INDIVIDUAL_ACTIONS}
userData={{
isAdmin,
licensesByXosan: this._getLicensesByXosan(),
licenseError,
status: this.state.status,
}}
/>

View File

@ -222,12 +222,24 @@ export default class NewXosan extends Component {
brickSize: this.state.customBrickSize ? this.state.brickSize : undefined,
memorySize: this.state.memorySize,
ipRange: this.state.customIpRange ? this.state.ipRange : undefined,
})
}).then(this.props.onSrCreationFinished)
this.props.onSrCreationStarted()
}
render () {
if (process.env.XOA_PLAN === 5) {
return (
<em>
{_('xosanSourcesDisclaimer', {
link: (
<a href='https://xen-orchestra.com'>https://xen-orchestra.com</a>
),
})}
</em>
)
}
const {
brickSize,
customBrickSize,
@ -243,7 +255,22 @@ export default class NewXosan extends Component {
vlan,
} = this.state
const { hostsNeedRestartByPool, noPacksByPool, poolPredicate } = this.props
const {
hostsNeedRestartByPool,
noPacksByPool,
poolPredicate,
notRegistered,
} = this.props
if (notRegistered) {
return (
<em>
{_('xosanUnregisteredDisclaimer', {
link: <Link to='/xoa/update'>{_('registerNow')}</Link>,
})}
</em>
)
}
const lvmsrs = this._getLvmSrs()
const hosts = this._getHosts()
@ -253,6 +280,7 @@ export default class NewXosan extends Component {
pool !== undefined &&
hostsNeedRestartByPool !== undefined &&
hostsNeedRestartByPool[pool.id]
const architecture = suggestions !== undefined && suggestions[suggestion]
return (
<Container>
@ -416,11 +444,22 @@ export default class NewXosan extends Component {
)}
</tbody>
</table>
{architecture.layout === 'disperse' && (
<div className='alert alert-danger'>
{_('xosanDisperseWarning', {
link: (
<a href='https://xen-orchestra.com/docs/xosan_types.html'>
xen-orchestra.com/docs/xosan_types.html
</a>
),
})}
</div>
)}
<Graph
height={160}
layout={suggestions[suggestion].layout}
layout={architecture.layout}
nSrs={this._getNSelectedSrs()}
redundancy={suggestions[suggestion].redundancy}
redundancy={architecture.redundancy}
width={600}
/>
<hr />