Compare commits

...

31 Commits

Author SHA1 Message Date
Julien Fontanet
f07a947580 5.1.0 2016-07-26 16:54:35 +02:00
Julien Fontanet
0b8a9eedbc feat(tooltip): float → solid, do not follow cursor 2016-07-26 16:50:46 +02:00
ABHAMON Ronan
8d24e596ac fix(tooltip): use position.top instead of position.right (#1322) 2016-07-26 14:49:07 +02:00
ABHAMON Ronan
c2378a44cd fix(tooltip): do not inject an intermediary element (#1321)
Fixes #1150
2016-07-26 14:28:11 +02:00
ABHAMON Ronan
023f7fdef1 feat(home): custom filters & configure default filters (#1308)
Fixes #1235
2016-07-25 15:20:39 +02:00
ABHAMON Ronan
5d7a64bc28 fix(scheduling): timezone support (#1318) 2016-07-25 14:57:38 +02:00
ABHAMON Ronan
8661957a97 feat(timezone-picker): xo-server timezone in the select (#1316)
Fixes #1314
2016-07-25 13:21:37 +02:00
ABHAMON Ronan
7a15d265b7 fix(new/sr): fix IQNs, LUNs selection (#1317)
Fixes #1281
2016-07-25 13:04:05 +02:00
Olivier Lambert
2736881975 fix(new sr): cast port number. See issue #1281 2016-07-23 16:42:58 +02:00
Greenkeeper
44a85f4e0c chore(package): update globby to version 6.0.0 (#1313)
https://greenkeeper.io/
2016-07-23 16:41:41 +02:00
Julien Fontanet
52a6e42e7e fix(pool/storage): display read-only SRs 2016-07-23 16:26:41 +02:00
Julien Fontanet
3dbe058d4e feat(home): add link to VMs console 2016-07-23 15:58:12 +02:00
Pierre Donias
620139efc1 feat(settings/acls): (un)select all objects of a specific type (#1310)
Fixes #1296
2016-07-22 17:45:38 +02:00
Pierre Donias
71464ac2e3 feat(menu): add types as Home sub-menus (#1309)
Fixes #1306
2016-07-22 16:18:16 +02:00
Pierre Donias
4a65489d39 fix(xo): polyfill Intl for Safari (#1307)
Fixes #1120
2016-07-22 15:51:32 +02:00
Pierre Donias
65d7eac590 feat(user): SSH keys management (#1302)
Fix #1299
2016-07-21 12:21:27 +02:00
ABHAMON Ronan
02bbc01dc4 feat(scheduling): improve utilisability (#1300)
Fixes #1295
2016-07-21 10:25:57 +02:00
Pierre Donias
3066237c86 feat(self/admin): recompute resource sets limits (#1298)
Fixes #1287
2016-07-20 11:36:49 +02:00
Pierre Donias
53f3c0bef1 fix(new-vm): fix CPU weight and add CPU cap (#1297)
Fixes #1286
2016-07-20 10:41:50 +02:00
ABHAMON Ronan
823c91b457 feat(plugins): supports predefined configurations (#1294)
Fixes #1289
2016-07-20 09:46:30 +02:00
ABHAMON Ronan
3bd7e20411 feat(backups): jobs support timezones (#1290)
Fixes #1258
2016-07-20 09:45:35 +02:00
Pierre Donias
24d4610b04 feat(vm/tab-advanced): editable CPU weight and cap (#1293)
Fixes #1283
2016-07-20 09:44:24 +02:00
ABHAMON Ronan
b16097767a feat(json-schema-input): use only schema.defaults in combobox options (#1292)
Fix #1288
2016-07-19 15:06:33 +02:00
ABHAMON Ronan
2ff74ffd39 feat(line-chart): many fixes on graphs legends (#1291)
Fixes #1247
2016-07-19 13:39:53 +02:00
Julien Fontanet
f0bb464136 fix(intl/locales/zh): fix moment import 2016-07-19 10:51:56 +02:00
Julien Fontanet
4767830386 feat(i18n): skeleton for Chinese 2016-07-19 10:02:33 +02:00
Julien Fontanet
ce23d4f164 feat(editable): change cursor to make it easier to see 2016-07-19 09:40:29 +02:00
Pierre Donias
c1380d1256 feat(home): focus search input after changing type (#1285)
Fixes #1228
2016-07-18 17:51:47 +02:00
Pierre Donias
ed9a848858 feat(new-vm): create mutiple VMs with a name pattern (#1271)
Implements parts of #949: initial sequence number.
2016-07-18 14:42:18 +02:00
ABHAMON Ronan
5e4e15fc12 fix(self/overview): display correctly resources set (#1284)
Fixes #1282
2016-07-18 09:36:46 +02:00
Greenkeeper
0dea952a2a chore(package): update modular-css to version 0.23.2 (#1239)
https://greenkeeper.io/
2016-07-15 12:19:47 +02:00
39 changed files with 4241 additions and 574 deletions

View File

@@ -1,7 +1,7 @@
{
"private": false,
"name": "xo-web",
"version": "5.0.9",
"version": "5.1.0",
"license": "AGPL-3.0",
"description": "Web interface client for Xen-Orchestra",
"keywords": [
@@ -58,7 +58,7 @@
"font-awesome": "^4.5.0",
"font-mfizz": "github:fizzed/font-mfizz",
"ghooks": "^1.1.1",
"globby": "^5.0.0",
"globby": "^6.0.0",
"gulp": "github:gulpjs/gulp#4.0",
"gulp-autoprefixer": "^3.1.0",
"gulp-csso": "^2.0.0",
@@ -75,8 +75,9 @@
"lodash": "^4.6.1",
"loose-envify": "^1.1.0",
"marked": "^0.3.5",
"modular-css": "^0.22.1",
"modular-css": "^0.23.2",
"moment": "^2.13.0",
"moment-timezone": "^0.5.4",
"notifyjs": "^2.0.1",
"novnc-node": "^0.5.3",
"promise-toolbox": "^0.4.0",
@@ -113,7 +114,7 @@
"vinyl": "^1.1.1",
"watchify": "^3.7.0",
"xo-acl-resolver": "^0.2.1",
"xo-lib": "^0.8.0-1",
"xo-lib": "^0.8.0",
"xo-remote-parser": "^0.3"
},
"scripts": {

View File

@@ -22,6 +22,11 @@ $ct-series-colors: (
@import "../node_modules/chartist/dist/scss/settings/_chartist-settings";
@import "../node_modules/chartist/dist/scss/chartist";
.ct-chart {
display: flex;
flex-direction: column-reverse;
}
// Line in charts with only 2px in width
.ct-line {
stroke-width: 2px;
@@ -55,7 +60,6 @@ $ct-series-colors: (
// Arrow!
&:before {
position: absolute;
bottom: -14px;
top: 100%;
left: 50%;
@@ -80,28 +84,27 @@ $ct-series-colors: (
// CHARTIST LEGEND =============================================================
.ct-legend {
position: absolute;
bottom: 0;
margin-bottom: -1em;
li {
position: relative;
padding-left: 1.4em;
padding-left: 0.5em;
list-style-type: none;
display: inline;
display: inline-block;
margin-right: 0.5em;
font-size: 0.8em;
}
li:before {
display: inline-block;
width: 1em;
height: 1em;
position: absolute;
left: 0;
content: '';
border: 3px solid transparent;
border-radius: 2px;
margin-top: 0.5em;
margin-right: 0.2em;
}
li.inactive:before {

View File

@@ -1,8 +1,8 @@
// import _ from 'intl' TODO: fix tooltip
import _ from 'intl'
import ActionButton from 'action-button'
import map from 'lodash/map'
import React from 'react'
// import Tooltip from 'tooltip' TODO: fix tooltip
import Tooltip from 'tooltip'
import {
ButtonGroup
} from 'react-bootstrap-4/lib'
@@ -10,17 +10,17 @@ import {
const ActionBar = ({ actions, param }) => (
<ButtonGroup>
{map(actions, ({ handler, handlerParam = param, label, icon }, index) => (
/* <Tooltip key={index} content={_(label)}> TODO: fix tooltip */
<ActionButton
key={index}
btnStyle='secondary'
handler={handler}
handlerParam={handlerParam}
icon={icon}
size='large'
/>
/* </Tooltip> */
))}
<Tooltip key={index} content={_(label)}>
<ActionButton
key={index}
btnStyle='secondary'
handler={handler}
handlerParam={handlerParam}
icon={icon}
size='large'
/>
</Tooltip>
))}
</ButtonGroup>
)
ActionBar.propTypes = {

View File

@@ -13,7 +13,6 @@ import {
import styles from './index.css'
@propTypes({
buttonTitle: propTypes.any,
defaultValue: propTypes.any,
disabled: propTypes.bool,
options: propTypes.oneOfType([
@@ -31,7 +30,6 @@ import styles from './index.css'
})
export default class Combobox extends Component {
static defaultProps = {
buttonTitle: 'Presets',
type: 'text'
}
@@ -45,7 +43,10 @@ export default class Combobox extends Component {
_handleChange = event => {
const { onChange } = this.props
onChange && (() => onChange(event.target.value))
if (onChange) {
onChange(event.target.value)
}
}
_setText (value) {
@@ -59,7 +60,7 @@ export default class Combobox extends Component {
const Input = (
<input
className='form-control'
defaultValue={props.defaultValue || ''}
defaultValue={props.defaultValue}
disabled={props.disabled}
options={options}
onChange={this._handleChange}
@@ -84,7 +85,7 @@ export default class Combobox extends Component {
className={styles.button}
disabled={props.disabled}
id='selectInput'
title={props.buttonTitle}
title=''
>
{map(options, option => (
<MenuItem key={option} onClick={() => this._setText(option)}>

View File

@@ -28,7 +28,13 @@ import {
const LONG_CLICK = 400
const SELECT_STYLE = { padding: '0px' }
const SIZE_STYLE = { width: '10rem' }
const EDITABLE_STYLE = { borderBottom: '1px dashed #ccc' }
const EDITABLE_STYLE = {
borderBottom: '1px dashed #ccc',
cursor: 'context-menu'
}
const LONG_EDITABLE_STYLE = {
cursor: 'context-menu'
}
@propTypes({
alt: propTypes.node.isRequired
@@ -157,7 +163,7 @@ class Editable extends Component {
const { useLongClick } = props
const success = <Icon icon='success' />
return <span style={useLongClick ? null : EDITABLE_STYLE}>
return <span style={useLongClick ? LONG_EDITABLE_STYLE : EDITABLE_STYLE}>
<span
onClick={!useLongClick && this._openEdition}
onMouseDown={useLongClick && this.__startTimer}
@@ -271,20 +277,34 @@ export class Password extends Text {
}
@propTypes({
value: propTypes.number.isRequired
nullable: propTypes.bool,
value: propTypes.number
})
export class Number extends Component {
get value () {
return +this.refs.input.value
}
_onChange = value => this.props.onChange(+value)
_onChange = value => {
if (value === '') {
if (this.props.nullable) {
value = null
} else {
return
}
} else {
value = +value
}
this.props.onChange(value)
}
render () {
const { value } = this.props
return <Text
{...this.props}
onChange={this._onChange}
value={String(this.props.value)}
value={value === null ? '' : String(value)}
/>
}
}

View File

@@ -0,0 +1,16 @@
export const VM = {
homeFilterPendingVms: 'current_operations:"" ',
homeFilterNonRunningVms: '!power_state:running ',
homeFilterHvmGuests: 'virtualizationMode:hvm ',
homeFilterRunningVms: 'power_state:running ',
homeFilterTags: 'tags:'
}
export const host = {
homeFilterRunningHosts: 'power_state:running ',
homeFilterTags: 'tags:'
}
export const pool = {
homeFilterTags: 'tags:'
}

View File

@@ -82,20 +82,15 @@ export default {
fillOptionalInformations: 'Remplir informations (optionnel)',
selectTableReset: 'Réinitialiser',
schedulingMonth: 'Mois',
schedulingEveryMonth: 'Tous les mois',
schedulingEachSelectedMonth: 'Chaque mois sélectionné',
schedulingMonthDay: 'Jour du mois',
schedulingEveryMonthDay: 'Tous les jours',
schedulingEachSelectedMonthDay: 'Chaque jour sélectionné',
schedulingWeekDay: 'Jour de la semaine',
schedulingEveryWeekDay: 'Tous les jours',
schedulingEachSelectedWeekDay: 'Chaque jour sélectionné',
schedulingHour: 'Heure',
schedulingEveryHour: 'Toutes les heures',
schedulingEveryNHour: 'Toutes les N heures',
schedulingEachSelectedHour: 'Chaque heure sélectionnée',
schedulingMinute: 'Minute',
schedulingEveryMinute: 'Toutes les minutes',
schedulingEveryNMinute: 'Toutes les N minutes',
schedulingEachSelectedMinute: 'Chaque minute sélectionnée',
schedulingReset: 'Reset',

File diff suppressed because it is too large Load Diff

View File

@@ -19,6 +19,9 @@ var messages = {
// ----- Titles -----
homePage: 'Home',
homeVmPage: 'VMs',
homeHostPage: 'Hosts',
homePoolPage: 'Pools',
dashboardPage: 'Dashboard',
overviewDashboardPage: 'Overview',
overviewVisualizationDashboardPage: 'Visualizations',
@@ -136,6 +139,7 @@ var messages = {
selectVmTemplates: 'Select VM template(s)…',
selectTags: 'Select tag(s)…',
selectVdis: 'Select disk(s)…',
selectTimezone: 'Select timezone…',
fillRequiredInformations: 'Fill required informations.',
fillOptionalInformations: 'Fill informations (optional)',
selectTableReset: 'Reset',
@@ -143,24 +147,24 @@ var messages = {
// --- Dates/Scheduler ---
schedulingMonth: 'Month',
schedulingEveryMonth: 'Every month',
schedulingEachSelectedMonth: 'Each selected month',
schedulingMonthDay: 'Day of the month',
schedulingEveryMonthDay: 'Every day',
schedulingEachSelectedMonthDay: 'Each selected day',
schedulingWeekDay: 'Day of the week',
schedulingEveryWeekDay: 'Every day',
schedulingEachSelectedWeekDay: 'Each selected day',
schedulingHour: 'Hour',
schedulingEveryHour: 'Every hour',
schedulingEveryNHour: 'Every N hour',
schedulingEachSelectedHour: 'Each selected hour',
schedulingMinute: 'Minute',
schedulingEveryMinute: 'Every minute',
schedulingEveryNMinute: 'Every N minute',
schedulingEachSelectedMinute: 'Each selected minute',
schedulingReset: 'Reset',
unknownSchedule: 'Unknown',
timezonePickerServerValue: 'Xo-server timezone:',
timezonePickerUseLocalTime: 'Web browser timezone',
timezonePickerUseServerTime: 'Xo-server timezone',
serverTimezoneOption: 'Server timezone ({value})',
cronPattern: 'Cron Pattern:',
backupEditNotFoundTitle: 'Cannot edit backup',
backupEditNotFoundMessage: 'Missing required info for edition',
job: 'Job',
@@ -174,6 +178,8 @@ var messages = {
jobTag: 'Tag',
jobScheduling: 'Scheduling',
jobState: 'State',
jobTimezone: 'Timezone',
jobServerTimezone: 'xo-server',
runJob: 'Run job',
runJobVerbose: 'One shot running started. See overview for logs.',
jobStarted: 'Started',
@@ -273,6 +279,25 @@ var messages = {
cancelPluginEdition: 'Cancel',
pluginConfigurationSuccess: 'Plugin configuration',
pluginConfigurationChanges: 'Plugin configuration successfully saved!',
pluginConfigurationPresetTitle: 'Predefined configuration',
pluginConfigurationChoosePreset: 'Choose a predefined configuration.',
applyPluginPreset: 'Apply',
// ----- User preferences -----
saveNewUserFilterErrorTitle: 'Save filter error',
saveNewUserFilterErrorBody: 'Bad parameter: name must be given.',
filterName: 'Name:',
filterValue: 'Value:',
saveNewFilterTitle: 'Save new filter',
setUserFiltersTitle: 'Set custom filters',
setUserFiltersBody: 'Are you sure you want to set custom filters?',
removeUserFilterTitle: 'Remove custom filter',
removeUserFilterBody: 'Are you sure you want to remove custom filter?',
defaultFilter: 'Default filter',
defaultFilters: 'Default filters',
customFilters: 'Custom filters',
customizeFilters: 'Customize filters',
saveCustomFilters: 'Save custom filters',
// ----- VM actions ------
startVmLabel: 'Start',
@@ -504,7 +529,9 @@ var messages = {
uuid: 'UUID',
virtualizationMode: 'Virtualization mode',
cpuWeightLabel: 'CPU weight',
defaultCpuWeight: 'Default',
defaultCpuWeight: 'Default ({value, number})',
cpuCapLabel: 'CPU cap',
defaultCpuCap: 'Default ({value, number})',
pvArgsLabel: 'PV args',
xenToolsStatus: 'Xen tools status',
xenToolsStatusValue: {
@@ -621,21 +648,24 @@ var messages = {
newVmBootAfterCreate: 'Boot VM after creation',
newVmMacPlaceholder: 'Auto-generated if empty',
newVmCpuWeightLabel: 'CPU weight',
newVmCpuWeightQuarter: 'Quarter (1/4)',
newVmCpuWeightHalf: 'Half (1/2)',
newVmCpuWeightNormal: 'Normal',
newVmCpuWeightDouble: 'Double (x2)',
newVmDefaultCpuWeight: 'Default: {value, number}',
newVmCpuCapLabel: 'CPU cap',
newVmDefaultCpuCap: 'Default: {value, number}',
newVmCloudConfig: 'Cloud config',
newVmCreateVms: 'Create VMs',
newVmCreateVmsConfirm: 'Are you sure you want to create {nbVms} VMs?',
newVmMultipleVms: 'Multiple VMs:',
newVmSelectResourceSet: 'Select a resource set:',
newVmMultipleVmsPattern: 'Name pattern:',
newVmMultipleVmsPatternPlaceholder: 'e.g.: \\{name\\}_%',
newVmFirstIndex: 'First index:',
// ----- Self -----
resourceSets: 'Resource sets',
noResourceSets: 'No resource sets.',
resourceSetName: 'Resource set name',
resourceSetCreation: 'Creation and edition',
recomputeResourceSets: 'Recompute all limits',
saveResourceSet: 'Save',
resetResourceSet: 'Reset',
editResourceSet: 'Edit',
@@ -867,6 +897,17 @@ var messages = {
pwdChangeError: 'Incorrect password',
pwdChangeErrorBody: 'The old password provided is incorrect. Your password has not been changed.',
changePasswordOk: 'OK',
sshKeys: 'SSH keys',
newSshKey: 'New SSH key',
deleteSshKey: 'Delete',
noSshKeys: 'No SSH keys',
newSshKeyModalTitle: 'New SSH key',
sshKeyErrorTitle: 'Invalid key',
sshKeyErrorMessage: 'An SSH key requires both a title and a key.',
title: 'Title',
key: 'Key',
deleteSshKeyConfirm: 'Delete SSH key',
deleteSshKeyConfirmMessage: 'Are you sure you want to delete the SSH key {title}?',
// ----- Usage -----
others: 'Others'

View File

@@ -29,8 +29,8 @@ export default class IntegerInput extends AbstractInput {
defaultValue={props.defaultValue}
disabled={props.disabled}
onChange={props.onChange}
options={schema.defaults || schema.default}
placeholder={props.placeholder}
options={schema.defaults}
placeholder={props.placeholder || schema.default}
ref='input'
required={props.required}
step={1}

View File

@@ -29,8 +29,8 @@ export default class NumberInput extends AbstractInput {
defaultValue={props.defaultValue}
disabled={props.disabled}
onChange={props.onChange}
options={schema.defaults || schema.default}
placeholder={props.placeholder}
options={schema.defaults}
placeholder={props.placeholder || schema.default}
ref='input'
required={props.required}
step='any'

View File

@@ -21,8 +21,8 @@ export default class StringInput extends AbstractInput {
defaultValue={props.defaultValue}
disabled={props.disabled}
onChange={props.onChange}
options={schema.defaults || schema.default}
placeholder={props.placeholder}
options={schema.defaults}
placeholder={props.placeholder || schema.default}
ref='input'
required={props.required}
type={props.password && 'password'}

View File

@@ -1,4 +1,3 @@
import forEach from 'lodash/forEach'
import includes from 'lodash/includes'
import join from 'lodash/join'
import later from 'later'
@@ -7,7 +6,6 @@ import React from 'react'
import sortedIndex from 'lodash/sortedIndex'
import { FormattedTime } from 'react-intl'
import {
Panel,
Tab,
Tabs
} from 'react-bootstrap-4/lib'
@@ -15,14 +13,20 @@ import {
import _ from './intl'
import Component from './base-component'
import propTypes from './prop-types'
import TimezonePicker from './timezone-picker'
import { Card, CardHeader, CardBlock } from './card'
import { Col, Row } from './grid'
import { Range } from './form'
// ===================================================================
const NAV_EVERY = 1
const NAV_EACH_SELECTED = 2
const NAV_EVERY_N = 3
// By default later use UTC but we use this line for futures versions.
later.date.UTC()
// ===================================================================
const NAV_EACH_SELECTED = 1
const NAV_EVERY_N = 2
const MIN_PREVIEWS = 5
const MAX_PREVIEWS = 20
@@ -78,13 +82,29 @@ const MINS = (() => {
return minutes
})()
const PICKTIME_TO_ID = {
minute: 0,
hour: 1,
monthDay: 2,
month: 3,
weekDay: 4
}
const TIME_FORMAT = {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: 'numeric'
minute: 'numeric',
// The timezone is not significant for displaying the date previews
// as long as it is the same used to generate the next occurrences
// from the cron patterns.
// Therefore we can use UTC everywhere and say to the user that the
// previews are in the configured timezone.
timeZone: 'UTC'
}
// ===================================================================
@@ -101,7 +121,7 @@ const getDayName = (dayNum) =>
// ===================================================================
@propTypes({
cron: propTypes.string.isRequired
cronPattern: propTypes.string.isRequired
})
export class SchedulePreview extends Component {
_handleChange = value => {
@@ -111,12 +131,15 @@ export class SchedulePreview extends Component {
}
render () {
const { props } = this
const cronSched = later.parse.cron(props.cron)
const { cronPattern } = this.props
const cronSched = later.parse.cron(cronPattern)
const dates = later.schedule(cronSched).next(this.state.value || MIN_PREVIEWS)
return (
<div>
<div className='alert alert-info' role='alert'>
{_('cronPattern')} <strong>{cronPattern}</strong>
</div>
<div className='form-inline p-b-1'>
<Range min={MIN_PREVIEWS} max={MAX_PREVIEWS} onChange={this._handleChange} />
</div>
@@ -137,34 +160,21 @@ export class SchedulePreview extends Component {
@propTypes({
children: propTypes.any.isRequired,
onChange: propTypes.func
onChange: propTypes.func.isRequired,
tdId: propTypes.number.isRequired,
value: propTypes.bool.isRequired
})
class ToggleTd extends Component {
get value () {
return this.state.value
}
set value (value) {
const { onChange } = this.props
this.setState({
value
}, onChange && (() => onChange(value)))
}
_onClick = () => {
const { onChange } = this.props
const value = !this.state.value
this.setState({
value
}, onChange && (() => onChange(value)))
const { props } = this
props.onChange(props.tdId, !props.value)
}
render () {
const { props } = this
return (
<td style={{ cursor: 'pointer' }} className={this.state.value ? 'table-success' : ''} onClick={this._onClick}>
{this.props.children}
<td style={{ cursor: 'pointer' }} className={props.value ? 'table-success' : ''} onClick={this._onClick}>
{props.children}
</td>
)
}
@@ -173,79 +183,64 @@ class ToggleTd extends Component {
// ===================================================================
@propTypes({
data: propTypes.array.isRequired,
dataRender: propTypes.func,
onChange: propTypes.func
options: propTypes.array.isRequired,
optionsRenderer: propTypes.func,
onChange: propTypes.func.isRequired,
value: propTypes.array.isRequired
})
class TableSelect extends Component {
constructor () {
super()
this.state = {
value: []
}
}
get value () {
return this.state.value
}
set value (value) {
const { onChange } = this.props
forEach(this.refs, (ref, id) => {
// Don't call ref.input directly because onChange of each ToggleTd is called else!
ref.setState({
value: includes(value, +id)
})
})
this.setState({
value
}, onChange && (() => onChange(value)))
static defaultProps = {
optionsRenderer: value => value
}
_reset = () => {
this.value = []
this.props.onChange([])
}
_handleChange = (id, value) => {
const { onChange } = this.props
const newValue = this.state.value.slice()
_handleChange = (tdId, tdValue) => {
const { props } = this
if (value) {
newValue.splice(sortedIndex(newValue, id), 0, id)
const newValue = props.value.slice()
const index = sortedIndex(newValue, tdId)
if (tdValue) {
// Add
if (newValue[index] !== tdId) {
newValue.splice(index, 0, tdId)
}
} else {
newValue.splice(sortedIndex(newValue, id), 1)
// Remove
if (newValue[index] === tdId) {
newValue.splice(index, 1)
}
}
this.setState({
value: newValue
}, onChange && (() => onChange(newValue)))
props.onChange(newValue)
}
render () {
const dataRender = this.props.dataRender || ((value) => value)
const {
props: {
data
}
} = this
const { length } = data[0]
options,
optionsRenderer,
value
} = this.props
const { length } = options[0]
return (
<div>
<table className='table table-bordered table-sm'>
<tbody>
{map(data, (line, i) => (
{map(options, (line, i) => (
<tr key={i}>
{map(line, (value, j) => {
const id = length * i + j
{map(line, (tdOption, j) => {
const tdId = length * i + j
return (
<ToggleTd
key={id}
ref={id}
children={dataRender(value)}
onChange={(value) => { this._handleChange(id, value) }}
children={optionsRenderer(tdOption)}
tdId={tdId}
key={tdId}
onChange={this._handleChange}
value={includes(value, tdId)}
/>
)
})}
@@ -264,177 +259,148 @@ class TableSelect extends Component {
// ===================================================================
@propTypes({
dataRender: propTypes.func,
onChange: propTypes.func,
optionsRenderer: propTypes.func,
onChange: propTypes.func.isRequired,
range: propTypes.array,
type: propTypes.string.isRequired
labelId: propTypes.string.isRequired,
value: propTypes.any.isRequired
})
class TimePicker extends Component {
constructor () {
super()
this.state = {
activeKey: NAV_EVERY
activeKey: NAV_EACH_SELECTED,
tableValue: []
}
}
get value () {
const { activeKey } = this.state
_update (props) {
const { refs } = this
const { value } = props
if (activeKey === NAV_EVERY) {
return 'all'
}
if (activeKey === NAV_EACH_SELECTED) {
return refs.select.value
}
return refs.range.value
}
set value (value) {
const { refs } = this
const { onChange } = this.props
if (value === 'all') {
this.setState({
activeKey: NAV_EVERY
}, onChange && (() => onChange(value)))
} else if (Array.isArray(value)) {
this.setState({
activeKey: NAV_EACH_SELECTED
})
refs.select.value = value
} else {
if (value.indexOf('/') === 1) {
this.setState({
activeKey: NAV_EVERY_N
})
refs.range.value = value
refs.range.value = value.split('/')[1]
} else {
this.setState({
activeKey: NAV_EACH_SELECTED,
tableValue: value === '*'
? []
: map(value.split(','), e => +e)
})
}
}
_updateOpen = () => {
this.setState({
open: !this.state.open
})
componentWillMount () {
this._update(this.props)
}
componentWillReceiveProps (props) {
this._update(props)
}
_selectTab = activeKey => {
const { onChange } = this.props
this.setState({
activeKey
}, onChange && (() => onChange(this.value)))
}, () => {
const { activeKey, tableValue } = this.state
const { onChange } = this.props
const { refs } = this
if (activeKey === NAV_EACH_SELECTED) {
onChange(tableValue)
} else {
onChange(refs.range.value)
}
})
}
_handleTableValue = tableValue => {
this.setState({
tableValue
}, () => this.props.onChange(tableValue))
}
render () {
const {
props,
state
} = this
const {
onChange,
options,
optionsRenderer,
range,
type
} = props
labelId
} = this.props
const { tableValue } = this.state
const tableSelect = (
<TableSelect
onChange={this._handleTableValue}
options={options}
optionsRenderer={optionsRenderer}
value={tableValue}
/>
)
return (
<div className='card'>
<button className='card-header btn btn-lg btn-block' onClick={this._updateOpen}>
{_(`scheduling${type}`)}
</button>
<Panel collapsible expanded={state.open}>
<div className='card-block'>
<Tabs bsStyle='tabs' activeKey={state.activeKey} onSelect={this._selectTab}>
<Tab tabClassName='nav-item' eventKey={NAV_EVERY} title={_(`schedulingEvery${type}`)} />
<Tab tabClassName='nav-item' eventKey={NAV_EACH_SELECTED} title={_(`schedulingEachSelected${type}`)}>
<TableSelect ref='select' data={props.data} dataRender={props.dataRender} onChange={onChange} />
<Card>
<CardHeader>
{_(`scheduling${labelId}`)}
</CardHeader>
<CardBlock>
{range
? (
<Tabs bsStyle='tabs' activeKey={this.state.activeKey} onSelect={this._selectTab}>
<Tab tabClassName='nav-item' eventKey={NAV_EACH_SELECTED} title={_(`schedulingEachSelected${labelId}`)}>
{tableSelect}
</Tab>
<Tab tabClassName='nav-item' eventKey={NAV_EVERY_N} title={_(`schedulingEveryN${labelId}`)}>
<Range ref='range' min={range[0]} max={range[1]} onChange={onChange} />
</Tab>
{range &&
<Tab tabClassName='nav-item' eventKey={NAV_EVERY_N} title={_(`schedulingEveryN${type}`)}>
<Range ref='range' min={range[0]} max={range[1]} onChange={onChange} />
</Tab>}
</Tabs>
</div>
</Panel>
</div>
) : tableSelect
}
</CardBlock>
</Card>
)
}
}
// ===================================================================
const ID_TO_PICKTIME = [
'minute',
'hour',
'monthDay',
'month',
'weekDay'
]
const HOURS_RANGE = [2, 12]
const MINUTES_RANGE = [2, 30]
@propTypes({
onChange: propTypes.func
cronPattern: propTypes.string.isRequired,
onChange: propTypes.func,
timezone: propTypes.string
})
export default class Scheduler extends Component {
constructor () {
super()
this.cron = {
minute: '*',
hour: '*',
monthDay: '*',
month: '*',
weekDay: '*'
}
}
get value () {
const { cron } = this
return `${cron.minute} ${cron.hour} ${cron.monthDay} ${cron.month} ${cron.weekDay}`
}
set value (value) {
if (!value) {
value = '* * * * *'
}
forEach(value.split(' '), (t, id) => {
const ref = this.refs[ID_TO_PICKTIME[id]]
if (t === '*') {
ref.value = 'all'
} else if (t.indexOf('/') === 1) {
ref.value = t.split('/')[1]
} else {
ref.value = map(t.split(','), e => +e)
}
})
}
_update (type, value) {
const { cron } = this
const { onChange } = this.props
if (value === 'all') {
cron[type] = '*'
} else if (Array.isArray(value)) {
if (Array.isArray(value)) {
if (!value.length) {
cron[type] = '*'
value = '*'
} else {
cron[type] = join(
value = join(
(type === 'monthDay' || type === 'month')
? map(value, (n) => n + 1)
? map(value, n => n + 1)
: value,
','
)
}
} else {
cron[type] = `*/${value}`
value = `*/${value}`
}
if (onChange) {
onChange(this.value)
}
const { props } = this
const cronPattern = props.cronPattern.split(' ')
cronPattern[PICKTIME_TO_ID[type]] = value
this.props.onChange({
cronPattern: cronPattern.join(' '),
timezone: props.timezone
})
}
_onHourChange = value => this._update('hour', value)
@@ -443,49 +409,69 @@ export default class Scheduler extends Component {
_onMonthDayChange = value => this._update('monthDay', value)
_onWeekDayChange = value => this._update('weekDay', value)
_onTimezoneChange = timezone => {
const { props } = this
props.onChange({
cronPattern: props.cronPattern,
timezone
})
}
render () {
const {
cronPattern,
timezone
} = this.props
const cronPatternArr = cronPattern.split(' ')
return (
<div className='card-block'>
<Row>
<Col mediumSize={6}>
<TimePicker
ref='month'
type='Month'
dataRender={getMonthName}
data={MONTHS}
labelId='Month'
optionsRenderer={getMonthName}
options={MONTHS}
onChange={this._onMonthChange}
value={cronPatternArr[PICKTIME_TO_ID['month']]}
/>
<TimePicker
ref='monthDay'
type='MonthDay'
data={DAYS}
labelId='MonthDay'
options={DAYS}
onChange={this._onMonthDayChange}
value={cronPatternArr[PICKTIME_TO_ID['monthDay']]}
/>
<TimePicker
ref='weekDay'
type='WeekDay'
dataRender={getDayName}
data={WEEK_DAYS}
labelId='WeekDay'
optionsRenderer={getDayName}
options={WEEK_DAYS}
onChange={this._onWeekDayChange}
value={cronPatternArr[PICKTIME_TO_ID['weekDay']]}
/>
</Col>
<Col mediumSize={6}>
<TimePicker
ref='hour'
type='Hour'
data={HOURS}
range={[2, 12]}
labelId='Hour'
options={HOURS}
range={HOURS_RANGE}
onChange={this._onHourChange}
value={cronPatternArr[PICKTIME_TO_ID['hour']]}
/>
<TimePicker
ref='minute'
type='Minute'
data={MINS}
range={[2, 30]}
labelId='Minute'
options={MINS}
range={MINUTES_RANGE}
onChange={this._onMinuteChange}
value={cronPatternArr[PICKTIME_TO_ID['minute']]}
/>
</Col>
</Row>
<Row>
<Col>
<hr />
<TimezonePicker value={timezone} onChange={this._onTimezoneChange} />
</Col>
</Row>
</div>
)
}

View File

@@ -0,0 +1,106 @@
import ActionButton from 'action-button'
import map from 'lodash/map'
import moment from 'moment-timezone'
import React from 'react'
import _ from './intl'
import Component from './base-component'
import propTypes from './prop-types'
import { getXoServerTimezone } from './xo'
import { Select } from './form'
const XO_SERVER_TIMEZONE = 'xo-server'
@propTypes({
defaultValue: propTypes.string,
onChange: propTypes.func.isRequired,
value: propTypes.string
})
export default class TimezonePicker extends Component {
constructor (props) {
super(props)
this.state.options = map(moment.tz.names(), value => ({ label: value, value }))
}
get value () {
const value = this.refs.select.value
return (value === XO_SERVER_TIMEZONE) ? null : value
}
set value (value) {
this.refs.select.value = value || XO_SERVER_TIMEZONE
}
_updateTimezone (value) {
this.props.onChange(value)
}
_handleChange = option => {
return this._updateTimezone(
!option || option.value === XO_SERVER_TIMEZONE
? null
: option.value
)
}
_useServerTime = () => {
this._updateTimezone(null)
}
_useLocalTime = () => {
this._updateTimezone(moment.tz.guess())
}
componentWillMount () {
// Use local timezone (Web browser) if no default value.
if (this.props.value === undefined) {
this._useLocalTime()
}
getXoServerTimezone.then(serverTimezone => {
this.setState({
options: [{
label: _('serverTimezoneOption', {
value: serverTimezone
}),
value: XO_SERVER_TIMEZONE
}].concat(this.state.options),
serverTimezone
})
})
}
render () {
const { props, state } = this
return (
<div>
<div className='alert alert-info' role='alert'>
{_('timezonePickerServerValue')} <strong>{state.serverTimezone}</strong>
</div>
<Select
className='m-b-1'
defaultValue={props.defaultValue}
onChange={this._handleChange}
options={state.options}
placeholder={_('selectTimezone')}
ref='select'
value={props.value || XO_SERVER_TIMEZONE}
/>
<div className='pull-right'>
<ActionButton
btnStyle='primary'
className='m-r-1'
handler={this._useServerTime}
icon='time'
>
{_('timezonePickerUseServerTime')}
</ActionButton>
<ActionButton
btnStyle='secondary'
handler={this._useLocalTime}
icon='time'
>
{_('timezonePickerUseLocalTime')}
</ActionButton>
</div>
</div>
)
}
}

View File

@@ -0,0 +1,287 @@
// Source: https://github.com/wwayne/react-tooltip/blob/master/src/utils/getPosition.js
/**
* Calculate the position of tooltip
*
* @params
* - `e` {Event} the event of current mouse
* - `target` {Element} the currentTarget of the event
* - `node` {DOM} the react-tooltip object
* - `place` {String} top / right / bottom / left
* - `effect` {String} float / solid
* - `offset` {Object} the offset to default position
*
* @return {Object
* - `isNewState` {Bool} required
* - `newState` {Object}
* - `position` {OBject} {left: {Number}, top: {Number}}
*/
export default function (e, target, node, place, effect, offset) {
const tipWidth = node.clientWidth
const tipHeight = node.clientHeight
const {mouseX, mouseY} = getCurrentOffset(e, target, effect)
const defaultOffset = getDefaultPosition(effect, target.clientWidth, target.clientHeight, tipWidth, tipHeight)
const {extraOffsetX, extraOffsetY} = calculateOffset(offset)
const windowWidth = window.innerWidth
const windowHeight = window.innerHeight
const {parentTop, parentLeft} = getParent(target)
// Get the edge offset of the tooltip
const getTipOffsetLeft = (place) => {
const offsetX = defaultOffset[place].l
return mouseX + offsetX + extraOffsetX
}
const getTipOffsetRight = (place) => {
const offsetX = defaultOffset[place].r
return mouseX + offsetX + extraOffsetX
}
const getTipOffsetTop = (place) => {
const offsetY = defaultOffset[place].t
return mouseY + offsetY + extraOffsetY
}
const getTipOffsetBottom = (place) => {
const offsetY = defaultOffset[place].b
return mouseY + offsetY + extraOffsetY
}
// Judge if the tooltip has over the window(screen)
const outsideVertical = () => {
let result = false
let newPlace
if (getTipOffsetTop('left') < 0 &&
getTipOffsetBottom('left') <= windowHeight &&
getTipOffsetBottom('bottom') <= windowHeight) {
result = true
newPlace = 'bottom'
} else if (getTipOffsetBottom('left') > windowHeight &&
getTipOffsetTop('left') >= 0 &&
getTipOffsetTop('top') >= 0) {
result = true
newPlace = 'top'
}
return {result, newPlace}
}
const outsideLeft = () => {
let {result, newPlace} = outsideVertical() // Deal with vertical as first priority
if (result && outsideHorizontal().result) {
return {result: false} // No need to change, if change to vertical will out of space
}
if (!result && getTipOffsetLeft('left') < 0 && getTipOffsetRight('right') <= windowWidth) {
result = true // If vertical ok, but let out of side and right won't out of side
newPlace = 'right'
}
return {result, newPlace}
}
const outsideRight = () => {
let {result, newPlace} = outsideVertical()
if (result && outsideHorizontal().result) {
return {result: false} // No need to change, if change to vertical will out of space
}
if (!result && getTipOffsetRight('right') > windowWidth && getTipOffsetLeft('left') >= 0) {
result = true
newPlace = 'left'
}
return {result, newPlace}
}
const outsideHorizontal = () => {
let result = false
let newPlace
if (getTipOffsetLeft('top') < 0 &&
getTipOffsetRight('top') <= windowWidth &&
getTipOffsetRight('right') <= windowWidth) {
result = true
newPlace = 'right'
} else if (getTipOffsetRight('top') > windowWidth &&
getTipOffsetLeft('top') >= 0 &&
getTipOffsetLeft('left') >= 0) {
result = true
newPlace = 'left'
}
return {result, newPlace}
}
const outsideTop = () => {
let {result, newPlace} = outsideHorizontal()
if (result && outsideVertical().result) {
return {result: false}
}
if (!result && getTipOffsetTop('top') < 0 && getTipOffsetBottom('bottom') <= windowHeight) {
result = true
newPlace = 'bottom'
}
return {result, newPlace}
}
const outsideBottom = () => {
let {result, newPlace} = outsideHorizontal()
if (result && outsideVertical().result) {
return {result: false}
}
if (!result && getTipOffsetBottom('bottom') > windowHeight && getTipOffsetTop('top') >= 0) {
result = true
newPlace = 'top'
}
return {result, newPlace}
}
// Return new state to change the placement to the reverse if possible
const outsideLeftResult = outsideLeft()
const outsideRightResult = outsideRight()
const outsideTopResult = outsideTop()
const outsideBottomResult = outsideBottom()
if (place === 'left' && outsideLeftResult.result) {
return {
isNewState: true,
newState: {place: outsideLeftResult.newPlace}
}
} else if (place === 'right' && outsideRightResult.result) {
return {
isNewState: true,
newState: {place: outsideRightResult.newPlace}
}
} else if (place === 'top' && outsideTopResult.result) {
return {
isNewState: true,
newState: {place: outsideTopResult.newPlace}
}
} else if (place === 'bottom' && outsideBottomResult.result) {
return {
isNewState: true,
newState: {place: outsideBottomResult.newPlace}
}
}
// Return tooltip offset position
return {
isNewState: false,
position: {
left: getTipOffsetLeft(place) - parentLeft,
top: getTipOffsetTop(place) - parentTop
}
}
}
// Get current mouse offset
const getCurrentOffset = (e, currentTarget, effect) => {
const boundingClientRect = currentTarget.getBoundingClientRect()
const targetTop = boundingClientRect.top
const targetLeft = boundingClientRect.left
const targetWidth = currentTarget.clientWidth
const targetHeight = currentTarget.clientHeight
if (effect === 'float') {
return {
mouseX: e.clientX,
mouseY: e.clientY
}
}
return {
mouseX: targetLeft + (targetWidth / 2),
mouseY: targetTop + (targetHeight / 2)
}
}
// List all possibility of tooltip final offset
// This is useful in judging if it is necessary for tooltip to switch position when out of window
const getDefaultPosition = (effect, targetWidth, targetHeight, tipWidth, tipHeight) => {
let top
let right
let bottom
let left
const disToMouse = 3
const triangleHeight = 2
const cursorHeight = 12 // Optimize for float bottom only, cause the cursor will hide the tooltip
if (effect === 'float') {
top = {
l: -(tipWidth / 2),
r: tipWidth / 2,
t: -(tipHeight + disToMouse + triangleHeight),
b: -disToMouse
}
bottom = {
l: -(tipWidth / 2),
r: tipWidth / 2,
t: disToMouse + cursorHeight,
b: tipHeight + disToMouse + triangleHeight + cursorHeight
}
left = {
l: -(tipWidth + disToMouse + triangleHeight),
r: -disToMouse,
t: -(tipHeight / 2),
b: tipHeight / 2
}
right = {
l: disToMouse,
r: tipWidth + disToMouse + triangleHeight,
t: -(tipHeight / 2),
b: tipHeight / 2
}
} else if (effect === 'solid') {
top = {
l: -(tipWidth / 2),
r: tipWidth / 2,
t: -(targetHeight / 2 + tipHeight + triangleHeight),
b: -(targetHeight / 2)
}
bottom = {
l: -(tipWidth / 2),
r: tipWidth / 2,
t: targetHeight / 2,
b: targetHeight / 2 + tipHeight + triangleHeight
}
left = {
l: -(tipWidth + targetWidth / 2 + triangleHeight),
r: -(targetWidth / 2),
t: -(tipHeight / 2),
b: tipHeight / 2
}
right = {
l: targetWidth / 2,
r: tipWidth + targetWidth / 2 + triangleHeight,
t: -(tipHeight / 2),
b: tipHeight / 2
}
}
return {top, bottom, left, right}
}
// Consider additional offset into position calculation
const calculateOffset = (offset) => {
let extraOffsetX = 0
let extraOffsetY = 0
if (Object.prototype.toString.apply(offset) === '[object String]') {
offset = JSON.parse(offset.toString().replace(/'/g, '"'))
}
for (let key in offset) {
if (key === 'top') {
extraOffsetY -= parseInt(offset[key], 10)
} else if (key === 'bottom') {
extraOffsetY += parseInt(offset[key], 10)
} else if (key === 'left') {
extraOffsetX -= parseInt(offset[key], 10)
} else if (key === 'right') {
extraOffsetX += parseInt(offset[key], 10)
}
}
return {extraOffsetX, extraOffsetY}
}
// Get the offset of the parent elements
const getParent = (currentTarget) => {
let currentParent = currentTarget
while (currentParent) {
if (currentParent.style.transform.length > 0) break
currentParent = currentParent.parentElement
}
const parentTop = currentParent && currentParent.getBoundingClientRect().top || 0
const parentLeft = currentParent && currentParent.getBoundingClientRect().left || 0
return {parentTop, parentLeft}
}

View File

@@ -1,45 +1,20 @@
.container {
position: relative;
}
.common {
opacity: 0;
transition: opacity .3s;
visibility: hidden;
}
.container:hover .common {
visibility: visible;
opacity: 1;
z-index: 9999;
}
.arrow {
composes: common;
border-bottom: .5em solid rgba(0, 0, 0, .8);
border-left: .5em solid transparent;
border-right: .5em solid transparent;
font-size: 1rem;
left: 25%;
margin-left: 1em;
position: absolute;
top: 100%;
}
.tooltip {
composes: common;
background: #333;
background: rgba(0, 0, 0, .8);
border-radius: .25em;
.tooltipEnabled {
background-color: #222;
border-radius: 3px;
border: 1px solid $fff;
color: #fff;
font-size: 1rem;
left: 25%;
margin-top: .5em;
padding: .5em;
position: absolute;
top: 100%;
min-width: fit-content;
max-width: 20em;
display: inline-block;
font-size: 13px;
margin-left: 0px;
margin-top: 0px;
opacity: 0.9;
padding: 8px 21px;
pointer-events: none;
position: fixed;
transition: opacity 0.3s ease-out, margin-top 0.3s ease-out, margin-left 0.3s ease-out;
z-index: 999;
}
.tooltipDisabled {
display: none;
}

View File

@@ -1,30 +1,133 @@
import classNames from 'classnames'
import React, { PropTypes } from 'react'
import React from 'react'
import ReactDOM from 'react-dom'
import Component from '../base-component'
import getPosition from './get-position'
import propTypes from '../prop-types'
import styles from './index.css'
const Tooltip = ({
children,
className,
content,
style,
tagName: Component = 'span'
}) => (
<Component className={classNames(className, styles.container)} style={style}>
<div className={styles.arrow} />
<div className={styles.tooltip}>
{content}
</div>
{children}
</Component>
)
// ===================================================================
Tooltip.propTypes = {
children: PropTypes.node,
className: PropTypes.string,
content: PropTypes.any.isRequired,
style: PropTypes.object,
tagName: PropTypes.string
let instance
export class TooltipViewer extends Component {
constructor () {
super()
if (instance) {
throw new Error('Tooltip viewer is a singleton!')
}
instance = this
this.state.place = 'top'
}
render () {
const {
className,
content,
place,
show,
style
} = this.state
return (
<div
className={classNames(show ? styles.tooltipEnabled : styles.tooltipDisabled, className)}
style={{
marginTop: (place === 'top' && '-10px') || (place === 'bottom' && '10px'),
marginLeft: (place === 'left' && '-10px') || (place === 'right' && '10px'),
...style
}}
>
{content}
</div>
)
}
}
export { Tooltip as default }
// ===================================================================
@propTypes({
children: propTypes.any.isRequired,
className: propTypes.string,
content: propTypes.any.isRequired,
style: propTypes.object,
tagName: propTypes.string
})
export default class Tooltip extends Component {
componentDidMount () {
this._addListeners()
}
componentWillUnmount () {
this._removeListeners()
}
componentWillReceiveProps (props) {
if (props.children !== this.props.children) {
this._removeListeners()
}
}
componentDidUpdate (prevProps) {
if (prevProps.children !== this.props.children) {
this._addListeners()
}
}
_addListeners () {
const node = this._node = ReactDOM.findDOMNode(this)
node.addEventListener('mouseenter', this._showTooltip)
node.addEventListener('mouseleave', this._hideTooltip)
node.addEventListener('mousemove', this._updateTooltip)
}
_removeListeners () {
const node = this._node
if (!node) {
return
}
node.removeEventListener('mouseenter', this._showTooltip)
node.removeEventListener('mouseleave', this._hideTooltip)
node.removeEventListener('mousemove', this._updateTooltip)
this._node = null
}
_showTooltip = () => {
const { props } = this
instance.setState({
className: props.className,
content: props.content,
show: true,
style: props.style
})
}
_hideTooltip = () => {
instance.setState({ show: false })
}
_updateTooltip = event => {
const node = ReactDOM.findDOMNode(instance)
const result = getPosition(event, event.currentTarget, node, instance.state.place, 'solid', {})
if (result.isNewState) {
return instance.setState(result.newState, () => this._updateTooltip(event))
}
const { position } = result
node.style.left = `${position.left}px`
node.style.top = `${position.top}px`
}
render () {
return this.props.children
}
}

View File

@@ -18,7 +18,7 @@ import styles from './index.css'
const N_LABELS_X = 5
const LABEL_OFFSET_X = 40
const LABEL_OFFSET_Y = 75
const LABEL_OFFSET_Y = 85
// ===================================================================

View File

@@ -0,0 +1,61 @@
import keys from 'lodash/keys'
import React from 'react'
import * as FormGrid from '../../form-grid'
import _ from '../../intl'
import Combobox from '../../combobox'
import Component from '../../base-component'
import propTypes from '../../prop-types'
import { createSelector } from '../../selectors'
@propTypes({
type: propTypes.string.isRequired,
user: propTypes.object.isRequired,
value: propTypes.string.isRequired
})
export default class SaveNewUserFilterModalBody extends Component {
get value () {
return this.state.name || ''
}
_getFilterOptions = createSelector(
tmp => (
(tmp = this.props.user) &&
(tmp = tmp.preferences) &&
(tmp = tmp.filters) &&
tmp[this.props.type]
),
keys
)
render () {
const { value } = this.props
const options = this._getFilterOptions()
return (
<div>
<FormGrid.Row>
<FormGrid.LabelCol>{_('filterName')}</FormGrid.LabelCol>
<FormGrid.InputCol>
<Combobox
onChange={this.linkState('name')}
options={options}
value={this.state.name || ''}
/>
</FormGrid.InputCol>
</FormGrid.Row>
<FormGrid.Row>
<FormGrid.LabelCol>{_('filterValue')}</FormGrid.LabelCol>
<FormGrid.InputCol>
<input
className='form-control'
disabled
type='text'
value={value}
/>
</FormGrid.InputCol>
</FormGrid.Row>
</div>
)
}
}

View File

@@ -3,6 +3,7 @@ import assign from 'lodash/assign'
import cookies from 'cookies-js'
import isEmpty from 'lodash/isEmpty'
import isEqual from 'lodash/isEqual'
import filter from 'lodash/filter'
import forEach from 'lodash/forEach'
import map from 'lodash/map'
import once from 'lodash/once'
@@ -33,6 +34,11 @@ import {
// ===================================================================
export const XEN_DEFAULT_CPU_WEIGHT = 256
export const XEN_DEFAULT_CPU_CAP = 0
// ===================================================================
export const isSrWritable = sr => sr.content_type !== 'iso' && sr.size > 0
export const isSrShared = sr => sr.$PBDs.length > 1
@@ -192,6 +198,8 @@ const createSubscription = cb => {
// Subscriptions -----------------------------------------------------
export const subscribeCurrentUser = createSubscription(() => xo.refreshUser())
export const subscribeAcls = createSubscription(() => _call('acl.get'))
export const subscribeJobs = createSubscription(() => _call('job.getAll'))
@@ -242,6 +250,8 @@ export const apiMethods = _call('system.getMethodsInfo')
export const serverVersion = _call('system.getServerVersion')
export const getXoServerTimezone = _call('system.getServerTimezone')
// ===================================================================
const resolveId = value =>
@@ -958,8 +968,13 @@ export const destroyTask = task => (
// Backups -----------------------------------------------------------
export const createSchedule = (jobId, cron, enabled, name = undefined) => (
_call('schedule.create', { jobId, cron, enabled, name })::tap(
export const createSchedule = (jobId, {
cron,
enabled,
name = undefined,
timezone = undefined
}) => (
_call('schedule.create', { jobId, cron, enabled, name, timezone })::tap(
subscribeSchedules.forceRefresh
)
)
@@ -989,12 +1004,6 @@ export const getSchedule = id => (
_call('schedule.get', { id })
)
export const setSchedule = schedule => (
_call('schedule.set', schedule)::tap(
subscribeSchedules.forceRefresh
)
)
export const enableSchedule = id => (
_call('scheduler.enable', { id })::tap(
subscribeScheduleTable.forceRefresh
@@ -1094,6 +1103,10 @@ export const deleteResourceSet = async id => {
subscribeResourceSets.forceRefresh()
}
export const recomputeResourceSetsLimits = () => (
_call('resourceSet.recomputeAllLimits')
)
// Remote ------------------------------------------------------------
export const createRemote = (name, url) => (
@@ -1318,6 +1331,128 @@ export const changePassword = (oldPassword, newPassword) => (
)
)
const _setUserPreferences = preferences => (
_call('user.set', {
id: xo.user.id,
preferences
})::tap(
subscribeCurrentUser.forceRefresh
)
)
import NewSshKeyModalBody from './new-ssh-key-modal'
export const addSshKey = () => (
confirm({
icon: 'ssh-key',
title: _('newSshKeyModalTitle'),
body: <NewSshKeyModalBody />
}).then(
newKey => {
if (!newKey.title || !newKey.key) {
error(_('sshKeyErrorTitle'), _('sshKeyErrorMessage'))
return
}
const { preferences } = xo.user
const otherKeys = preferences && preferences.sshKeys || []
return _setUserPreferences({ sshKeys: [
...otherKeys,
newKey
]})
},
noop
)
)
export const deleteSshKey = key => (
confirm({
title: _('deleteSshKeyConfirm'),
body: _('deleteSshKeyConfirmMessage', { title: <strong>{key.title}</strong> })
}).then(
() => {
const { preferences } = xo.user
return _setUserPreferences({
sshKeys: filter(preferences && preferences.sshKeys, k => !isEqual(k, key))
})
},
noop
)
)
// User filters --------------------------------------------------
import AddUserFilterModalBody from './add-user-filter-modal'
export const addCustomFilter = (type, value) => {
const { user } = xo
return confirm({
title: _('saveNewFilterTitle'),
body: <AddUserFilterModalBody user={user} type={type} value={value} />
}).then(name => {
if (name.length === 0) {
return error(_('saveNewUserFilterErrorTitle'), _('saveNewUserFilterErrorBody'))
}
const { preferences } = user
const filters = (preferences && preferences.filters) || {}
return _setUserPreferences({
filters: {
...filters,
[type]: {
...filters[type],
[name]: value
}
}
})
})
}
export const removeCustomFilter = (type, name) => (
confirm({
title: _('removeUserFilterTitle'),
body: <p>{_('removeUserFilterBody')}</p>
}).then(() => {
const { user } = xo
const { filters } = user.preferences
return _setUserPreferences({
filters: {
...filters,
[type]: {
...filters[type],
[name]: undefined
}
}
})
})
)
export const editCustomFilter = (type, name, { newName = name, newValue }) => {
const { filters } = xo.user.preferences
return _setUserPreferences({
filters: {
...filters,
[type]: {
...filters[type],
[name]: undefined,
[newName]: newValue || filters[type][name]
}
}
})
}
export const setDefaultHomeFilter = (type, name) => {
const { user } = xo
const { preferences } = user
const defaultFilters = (preferences && preferences.defaultHomeFilters) || {}
return _setUserPreferences({
defaultHomeFilters: {
...defaultFilters,
[type]: name
}
})
}
// Jobs ----------------------------------------------------------
export const deleteJob = job => (
@@ -1338,8 +1473,8 @@ export const updateJob = job => (
)
)
export const updateSchedule = ({id, job: jobId, cron, enabled, name}) => (
_call('schedule.set', {id, jobId, cron, enabled, name})::tap(
export const updateSchedule = ({ id, job: jobId, cron, enabled, name, timezone }) => (
_call('schedule.set', { id, jobId, cron, enabled, name, timezone })::tap(
subscribeSchedules.forceRefresh
)
)

View File

@@ -0,0 +1,58 @@
import BaseComponent from 'base-component'
import React from 'react'
import _ from '../../intl'
import SingleLineRow from '../../single-line-row'
import { Col } from '../../grid'
import getEventValue from '../../get-event-value'
export default class NewSshKeyModalBody extends BaseComponent {
get value () {
return this.state
}
_onKeyChange = event => {
const key = getEventValue(event)
const splitKey = key.split(' ')
if (!this.state.title && splitKey.length === 3) {
this.setState({ title: splitKey[2].split('\n')[0] })
}
this.setState({ key })
}
render () {
const {
key,
title
} = this.state
return <div>
<div className='p-b-1'>
<SingleLineRow>
<Col size={4}>{_('title')}</Col>
<Col size={8}>
<input
className='form-control'
onChange={this.linkState('title')}
type='text'
value={title || ''}
/>
</Col>
</SingleLineRow>
</div>
<div className='p-b-1'>
<SingleLineRow>
<Col size={4}>{_('key')}</Col>
<Col size={8}>
<textarea
className='form-control'
onChange={this._onKeyChange}
rows={10}
value={key || ''}
/>
</Col>
</SingleLineRow>
</div>
</div>
}
}

View File

@@ -112,6 +112,10 @@
@extend .fa;
@extend .fa-play;
}
&-ssh-key {
@extend .fa;
@extend .fa-key;
}
&-shown {
@extend .fa;
@@ -537,6 +541,10 @@
@extend .fa;
@extend .fa-clock-o;
}
&-time {
@extend .fa;
@extend .fa-clock-o;
}
&-database {
@extend .fa;
@extend .fa-database;

View File

@@ -21,6 +21,8 @@ html.no-js(
//- .visible-js to display content only when JavaScript is ENABLED.
//- .hidden-js to display content only when JavaScript is DISABLED.
script !function(d){d.className=d.className.replace(/\bno-js\b/,'js')}(document.documentElement)
script(src = 'https://cdn.polyfill.io/v2/polyfill.min.js?features=Intl.~locale.en')
style .no-js .visible-js,.js .hidden-js{display:none}
//- (TODO: confirm) For smartphones and tablets: sets the page

View File

@@ -18,7 +18,7 @@ import {
createJob,
createSchedule,
setJob,
setSchedule
updateSchedule
} from 'xo'
import { getJobValues } from '../helpers'
@@ -189,13 +189,13 @@ const BACKUP_METHOD_TO_INFO = {
// ===================================================================
const DEFAULT_CRON_PATTERN = '0 0 * * *'
@injectIntl
export default class New extends Component {
constructor (props) {
super(props)
const { state } = this
state.cronPattern = '* * * * *'
this.state.cronPattern = DEFAULT_CRON_PATTERN
}
componentWillMount () {
@@ -208,7 +208,8 @@ export default class New extends Component {
}
this.setState({
backupInfo: BACKUP_METHOD_TO_INFO[job.method],
cronPattern: schedule.cron
cronPattern: schedule.cron,
timezone: schedule.timezone || null
}, () => delay(this._populateForm, 250, job)) // Work around.
// Without the delay, some selects are not always ready to load a value
// Values are displayed, but html5 compliant browsers say the value is required and empty on submit
@@ -240,7 +241,8 @@ export default class New extends Component {
...callArgs
} = backup
const { backupInfo } = this.state
const { backupInfo, timezone } = this.state
const job = {
type: 'call',
key: backupInfo.jobKey,
@@ -262,33 +264,33 @@ export default class New extends Component {
if (oldJob && oldSchedule) {
job.id = oldJob.id
oldSchedule.cron = this.state.cronPattern
return setJob(job).then(() => setSchedule(oldSchedule))
return setJob(job).then(() => updateSchedule({
...oldSchedule,
cron: this.state.cronPattern,
timezone
}))
}
// Create backup schedule.
return createJob(job).then(jobId => {
createSchedule(jobId, this.state.cronPattern, enabled)
createSchedule(jobId, { cron: this.state.cronPattern, enabled, timezone })
})
}
_handleReset = () => {
const {
backupInput,
scheduler
} = this.refs
const { backupInput } = this.refs
if (backupInput) {
backupInput.value = undefined
}
scheduler.value = '* * * * *'
this.setState({
cronPattern: DEFAULT_CRON_PATTERN
})
}
_updateCronPattern = value => {
this.setState({
cronPattern: value
})
this.setState(value)
}
_handleBackupSelection = event => {
@@ -298,7 +300,12 @@ export default class New extends Component {
}
render () {
const { backupInfo, defaultValue } = this.state
const {
backupInfo,
cronPattern,
defaultValue,
timezone
} = this.state
const { formatMessage } = this.props.intl
return process.env.XOA_PLAN > 1
@@ -334,11 +341,15 @@ export default class New extends Component {
</form>
</Section>
<Section icon='schedule' title='schedule'>
<Scheduler ref='scheduler' onChange={this._updateCronPattern} />
<Scheduler
cronPattern={cronPattern}
timezone={timezone}
onChange={this._updateCronPattern}
/>
</Section>
<Section icon='preview' title='preview' summary>
<div className='card-block'>
<SchedulePreview cron={this.state.cronPattern} />
<SchedulePreview cronPattern={cronPattern} />
{process.env.XOA_PLAN < 4 && backupInfo && process.env.XOA_PLAN < REQUIRED_XOA_PLAN[backupInfo.jobKey]
? <Upgrade place='newBackup' available={REQUIRED_XOA_PLAN[backupInfo.jobKey]} />
: <fieldset className='pull-xs-right p-t-1'>

View File

@@ -146,6 +146,7 @@ export default class Overview extends Component {
<th>{_('job')}</th>
<th>{_('jobTag')}</th>
<th className='hidden-xs-down'>{_('jobScheduling')}</th>
<th className='hidden-xs-down'>{_('jobTimezone')}</th>
<th>{_('jobState')}</th>
</tr>
</thead>
@@ -158,6 +159,7 @@ export default class Overview extends Component {
<td>{this._getJobLabel(job)}</td>
<td>{this._getScheduleTag(schedule, job)}</td>
<td className='hidden-xs-down'>{schedule.cron}</td>
<td className='hidden-xs-down'>{schedule.timezone || _('jobServerTimezone')}</td>
<td>
{this._getScheduleToggle(schedule)}
<fieldset className='pull-xs-right'>

View File

@@ -1,4 +1,5 @@
import * as ComplexMatcher from 'complex-matcher'
import * as homeFilters from 'home-filters'
import _ from 'intl'
import ActionButton from 'action-button'
import ceil from 'lodash/ceil'
@@ -19,6 +20,7 @@ import SingleLineRow from 'single-line-row'
import size from 'lodash/size'
import { Card, CardHeader, CardBlock } from 'card'
import {
addCustomFilter,
copyVms,
deleteVms,
emergencyShutdownHosts,
@@ -29,7 +31,8 @@ import {
snapshotVms,
startVms,
stopHosts,
stopVms
stopVms,
subscribeCurrentUser
} from 'xo'
import { Container, Row, Col } from 'grid'
import {
@@ -38,6 +41,7 @@ import {
SelectTag
} from 'select-objects'
import {
addSubscriptions,
connectStore,
noop
} from 'utils'
@@ -69,10 +73,7 @@ const ITEMS_PER_PAGE = 20
const OPTIONS = {
host: {
defaultFilter: 'power_state:running ',
filters: {
homeFilterRunningHosts: 'power_state:running ',
homeFilterTags: 'tags:'
},
filters: homeFilters.host,
mainActions: [
{ handler: stopHosts, icon: 'host-stop' },
{ handler: restartHostsAgents, icon: 'host-restart-agent' },
@@ -90,13 +91,7 @@ const OPTIONS = {
},
VM: {
defaultFilter: 'power_state:running ',
filters: {
homeFilterPendingVms: 'current_operations:"" ',
homeFilterNonRunningVms: '!power_state:running ',
homeFilterHvmGuests: 'virtualizationMode:hvm ',
homeFilterRunningVms: 'power_state:running ',
homeFilterTags: 'tags:'
},
filters: homeFilters.VM,
mainActions: [
{ handler: stopVms, icon: 'vm-stop' },
{ handler: startVms, icon: 'vm-start' },
@@ -135,9 +130,7 @@ const OPTIONS = {
},
pool: {
defaultFilter: '',
filters: {
homeFilterTags: 'tags:'
},
filters: homeFilters.pool,
getActions: noop,
Item: PoolItem,
sortOptions: [
@@ -154,6 +147,9 @@ const TYPES = {
const DEFAULT_TYPE = 'VM'
@addSubscriptions({
user: subscribeCurrentUser
})
@connectStore(() => {
const noServersConnected = invoke(
createGetObjectsOfType('host'),
@@ -200,14 +196,40 @@ export default class Home extends Component {
pathname,
query: { ...query, t: type, s: undefined }
})
this._focusFilterInput()
}
_getDefaultFilter (props = this.props) {
const { type, user } = props
const defaultFilter = OPTIONS[type].defaultFilter
// No user.
if (!user) {
return defaultFilter
}
const { defaultHomeFilters = {}, filters = {} } = user.preferences || {}
const filterName = defaultHomeFilters[type]
// No filter defined in preferences.
if (!filterName) {
return defaultFilter
}
// Filter defined.
return homeFilters[type][filterName] ||
filters[type][filterName] ||
defaultFilter
}
_initFilter (props) {
const filter = this._getFilter(props)
// If filter is null, set a default filter.
if (filter == null) {
const defaultFilter = OPTIONS[props.type].defaultFilter
if (filter == null || (this.props.user == null && props.user != null)) {
const defaultFilter = this._getDefaultFilter(props)
if (defaultFilter != null) {
this._setFilter(defaultFilter, props)
}
@@ -363,13 +385,35 @@ export default class Home extends Component {
this._updateMasterCheckbox()
}
_focusFilterInput = () => this.refs.filterInput.focus()
_addCustomFilter = () => {
return addCustomFilter(
this._getType(),
this._getFilter()
)
}
_getCustomFilters () {
const { preferences } = this.props.user || {}
if (!preferences) {
return
}
const customFilters = preferences.filters || {}
return customFilters[this._getType()]
}
_renderHeader () {
const { filters } = OPTIONS[this.props.type]
const { type } = this.props
const { filters } = OPTIONS[type]
const customFilters = this._getCustomFilters()
return <Container>
<Row className={styles.itemRowHeader}>
<Col mediumSize={3}>
<DropdownButton id='typeMenu' bsStyle='info' title={TYPES[this.props.type]}>
<DropdownButton id='typeMenu' bsStyle='info' title={TYPES[this._getType()]}>
<MenuItem onClick={() => this._setType('VM')}>
VM
</MenuItem>
@@ -383,15 +427,25 @@ export default class Home extends Component {
</Col>
<Col mediumSize={6}>
<div className='input-group'>
{!isEmpty(filters) && <div className='input-group-btn'>
<DropdownButton id='filter' bsStyle='info' title={_('homeFilters')}>
{map(filters, (filter, label) =>
<MenuItem key={label} onClick={() => this._setFilter(filter)}>
{_(label)}
</MenuItem>
)}
</DropdownButton>
</div>}
{!isEmpty(filters) && (
<div className='input-group-btn'>
<DropdownButton id='filter' bsStyle='info' title={_('homeFilters')}>
{!isEmpty(customFilters) && [
map(customFilters, (filter, name) =>
<MenuItem key={`custom-${name}`} onClick={() => this._setFilter(filter)}>
{name}
</MenuItem>
),
<MenuItem divider />
]}
{map(filters, (filter, label) =>
<MenuItem key={label} onClick={() => this._setFilter(filter)}>
{_(label)}
</MenuItem>
)}
</DropdownButton>
</div>
)}
<input
autoFocus
className='form-control'
@@ -401,11 +455,18 @@ export default class Home extends Component {
type='text'
/>
<div className='input-group-btn'>
<button
<a
className='btn btn-secondary'
onClick={this._clearFilter}>
<Icon icon='clear-search' />
</button>
</a>
</div>
<div className='input-group-btn'>
<ActionButton
btnStyle='primary'
handler={this._addCustomFilter}
icon='save'
/>
</div>
</div>
</Col>
@@ -624,7 +685,10 @@ export default class Home extends Component {
{' '}
<DropdownButton bsStyle='link' id='sort' title={_('homeSortBy')}>
{map(options.sortOptions, ({ labelId, sortBy: _sortBy, sortOrder }, key) => (
<MenuItem key={key} onClick={() => this.setState({ sortBy: _sortBy, sortOrder })}>
<MenuItem key={key} onClick={() => {
this.setState({ sortBy: _sortBy, sortOrder })
this._focusFilterInput()
}}>
{this._tick(_sortBy === sortBy)}
{_sortBy === sortBy
? <strong>{_(labelId)}</strong>

View File

@@ -86,16 +86,22 @@ export default class VmItem extends Component {
<span className={styles.itemActionButons}>
{this._isRunning
? <span>
<Tooltip content={_('vmConsoleLabel')}>
<Link to={`/vms/${vm.id}/console`}>
<Icon icon='vm-console' size='1' fixedWidth />
</Link>
</Tooltip>
<Tooltip content={_('stopVmLabel')}>
<a onClick={this._stop}>
<Icon icon='vm-stop' size='1' />
<Icon icon='vm-stop' size='1' fixedWidth />
</a>
</Tooltip>
</span>
: <span>
<Icon fixedWidth />
<Tooltip content={_('startVmLabel')}>
<a onClick={this._start}>
<Icon icon='vm-start' size='1' />
<Icon icon='vm-start' size='1' fixedWidth />
</a>
</Tooltip>
</span>

View File

@@ -5,6 +5,7 @@ import _, { IntlProvider } from 'intl'
import { blockXoaAccess } from 'xoa-updater'
import { connectStore, routes } from 'utils'
import { Notification } from 'notification'
import { TooltipViewer } from 'tooltip'
// import {
// keyHandler
// } from 'react-key-handler'
@@ -113,6 +114,7 @@ export default class XoApp extends Component {
{blocked ? <XoaUpdates /> : this.props.children}
</div>
</div>
<TooltipViewer />
<Modal />
<Notification />
</div>

View File

@@ -20,15 +20,18 @@ import {
const JOB_KEY = 'genericTask'
const DEFAULT_CRON_PATTERN = '0 0 * * *'
export default class Schedules extends Component {
constructor (props) {
super(props)
this.state = {
action: undefined,
actions: undefined,
cronPattern: DEFAULT_CRON_PATTERN,
job: undefined,
jobs: undefined,
cron: '* * * * *'
timezone: undefined
}
this.loaded = new Promise((resolve, reject) => {
this._resolveLoaded = resolve
@@ -79,17 +82,16 @@ export default class Schedules extends Component {
_handleSubmit = () => {
const {name, job, enabled} = this.refs
console.log(job)
const { cron, schedule } = this.state
const { cronPattern, schedule, timezone } = this.state
let save
if (schedule) {
console.log('JOB', job, job.value)
schedule.job = job.value.id
schedule.cron = cron
schedule.cron = cronPattern
schedule.name = name.value
schedule.timezone = timezone
save = updateSchedule(schedule)
} else {
save = createSchedule(job.value.id, cron, enabled.value, name.value)
save = createSchedule(job.value.id, { cron: cronPattern, enabled: enabled.value, name: name.value })
}
return save.then(this._reset).catch(err => error('Save Schedule', err.message || String(err)))
}
@@ -102,39 +104,40 @@ export default class Schedules extends Component {
return
}
const {name, job, scheduler} = this.refs
const { name, job } = this.refs
name.value = schedule.name
job.value = schedule.job
scheduler.value = schedule.cron
this.setState({
schedule
cronPattern: schedule.cron,
schedule,
timezone: schedule.timezone || null
})
}
_reset = () => {
this.setState({
schedule: undefined
cronPattern: DEFAULT_CRON_PATTERN,
schedule: undefined,
timezone: undefined
}, () => {
const {name, job, enabled, scheduler} = this.refs
const { name, job, enabled } = this.refs
name.value = ''
enabled.value = false
job.value = undefined
scheduler.value = undefined
})
}
_updateCronPattern = cron => {
this.setState({
cron
})
_updateCronPattern = value => {
this.setState(value)
}
render () {
const {
cron,
cronPattern,
jobs,
schedule,
schedules
schedules,
timezone
} = this.state
return <div>
<h1>Schedules</h1>
@@ -154,8 +157,12 @@ export default class Schedules extends Component {
}
</form>
<fieldset>
<Scheduler ref='scheduler' onChange={this._updateCronPattern} />
<SchedulePreview cron={cron} />
<Scheduler
cronPattern={cronPattern}
onChange={this._updateCronPattern}
timezone={timezone}
/>
<SchedulePreview cronPattern={cronPattern} />
</fieldset>
<br />
<div className='form-group'>
@@ -163,7 +170,7 @@ export default class Schedules extends Component {
{process.env.XOA_PLAN > 3
? <span><ActionButton form='newScheduleForm' handler={this._handleSubmit} icon='save' btnStyle='primary'>{_('saveBackupJob')}</ActionButton>
{' '}
<button type='button' className='btn btn-default' onClick={this._reset}>{_('selectTableReset')}</button></span>
<button type='button' className='btn btn-secondary' onClick={this._reset}>{_('selectTableReset')}</button></span>
: <span><Upgrade place='health' available={4} /></span>
}
</div>
@@ -172,7 +179,8 @@ export default class Schedules extends Component {
<tr>
<th>{_('jobName')}</th>
<th>{_('job')}</th>
<th>{_('jobScheduling')}</th>
<th className='hidden-xs-down'>{_('jobScheduling')}</th>
<th className='hidden-xs-down'>{_('jobTimezone')}</th>
<th></th>
</tr>
</thead>
@@ -183,7 +191,8 @@ export default class Schedules extends Component {
<span>{schedule.name} <span className='text-muted'>({schedule.id})</span></span>
</td>
<td>{jobs[schedule.job] && <span>{jobs[schedule.job].name} - {jobs[schedule.job].method} <span className='text-muted'>({schedule.job})</span></span>}</td>
<td>{schedule.cron}</td>
<td className='hidden-xs-down'>{schedule.cron}</td>
<td className='hidden-xs-down'>{schedule.timezone || _('jobServerTimezone')}</td>
<td>
<button type='button' className='btn btn-primary' onClick={() => this._edit(schedule.id)}><Icon icon='edit' /></button>
{' '}

View File

@@ -94,7 +94,11 @@ export default class Menu extends Component {
const noResourceSets = isEmpty(this.state.resourceSets)
const items = [
{ to: '/home', icon: 'menu-home', label: 'homePage' },
{ to: '/home', icon: 'menu-home', label: 'homePage', subMenu: [
{ to: '/home?t=VM', icon: 'vm', label: 'homeVmPage' },
{ to: '/home?t=host', icon: 'host', label: 'homeHostPage' },
{ to: '/home?t=pool', icon: 'pool', label: 'homePoolPage' }
]},
{ to: '/dashboard/overview', icon: 'menu-dashboard', label: 'dashboardPage', subMenu: [
{ to: '/dashboard/overview', icon: 'menu-dashboard-overview', label: 'overviewDashboardPage' },
{ to: '/dashboard/visualizations', icon: 'menu-dashboard-visualization', label: 'overviewVisualizationDashboardPage' },

View File

@@ -29,7 +29,9 @@ import {
createVms,
getCloudInitConfig,
subscribePermissions,
subscribeResourceSets
subscribeResourceSets,
XEN_DEFAULT_CPU_CAP,
XEN_DEFAULT_CPU_WEIGHT
} from 'xo'
import {
SelectNetwork,
@@ -48,6 +50,7 @@ import {
Toggle
} from 'form'
import {
buildTemplate,
connectStore,
formatSize,
noop,
@@ -85,10 +88,10 @@ const LineItem = ({ children }) => (
{children}
</div>
)
const Item = ({ label, children }) => (
const Item = ({ label, children, className }) => (
<span className={styles.item}>
{label && <span>{_(label)}&nbsp;</span>}
<span className={styles.input}>{children}</span>
<span className={classNames(styles.input, className)}>{children}</span>
</span>
)
@@ -165,17 +168,19 @@ export default class NewVm extends BaseComponent {
bootAfterCreate: true,
configDrive: false,
CPUs: '',
cpuWeight: 1,
cpuCap: '',
cpuWeight: '',
existingDisks: {},
fastClone: true,
multipleVms: false,
name_label: '',
name_description: '',
nameLabels: map(Array(NB_VMS_MIN), (_, index) => `VM_${index + 1}`),
namePattern: '{name}_%',
nbVms: NB_VMS_MIN,
VDIs: [],
VIFs: []
VIFs: [],
seqStart: 1
})
}
@@ -230,7 +235,8 @@ export default class NewVm extends BaseComponent {
// TODO: To be added in xo-server
// vm.set parameters
CPUs: state.CPUs,
cpuWeight: state.cpuWeight,
cpuWeight: state.cpuWeight === '' ? null : state.cpuWeight,
cpuCap: state.cpuCap === '' ? null : state.cpuCap,
name_description: state.name_description,
memory: state.memory,
pv_args: state.pv_args,
@@ -290,16 +296,19 @@ export default class NewVm extends BaseComponent {
})
}
const name_label = state.name_label === '' || !state.name_labelHasChanged ? template.name_label : state.name_label
const name_description = state.name_description === '' || !state.name_descriptionHasChanged ? template.name_description || '' : state.name_description
const replacer = this._buildTemplate()
this._setState({
// infos
name_label,
template,
name_description: state.name_description === '' || !state.name_descriptionHasChanged ? template.name_description || '' : state.name_description,
nameLabels: map(Array(+state.nbVms), (_, index) => `${name_label}_${index + 1}`),
name_description,
nameLabels: map(Array(+state.nbVms), (_, index) => replacer({ name_label, name_description, template }, index + 1)),
// performances
memory: template.memory.size,
CPUs: template.CPUs.number,
cpuWeight: 1,
cpuCap: '',
cpuWeight: '',
// installation
installMethod: template.install_methods && template.install_methods[0] || state.installMethod,
customConfig: '#cloud-config\n#hostname: myhostname\n#ssh_authorized_keys:\n# - ssh-rsa <myKey>\n#packages:\n# - htop\n',
@@ -398,6 +407,13 @@ export default class NewVm extends BaseComponent {
})
return network && network.id
}
_buildTemplate = createSelector(
() => this.state.state.namePattern,
namePattern => buildTemplate(namePattern, {
'{name}': state => state.name_label || '',
'%': (_, i) => i
})
)
// On change -------------------------------------------------------------------
/*
@@ -451,25 +467,28 @@ export default class NewVm extends BaseComponent {
this._setState({ [prop]: value })
}
}
_updateNbVms = () => {
const { nbVms, name_label, nameLabels } = this.state.state
const { nbVms, nameLabels, seqStart } = this.state.state
const nbVmsClamped = clamp(nbVms, NB_VMS_MIN, NB_VMS_MAX)
const newNameLabels = [ ...nameLabels ]
if (nbVmsClamped < nameLabels.length) {
this._setState({ nameLabels: slice(newNameLabels, 0, nbVmsClamped) })
} else {
for (let i = nameLabels.length + 1; i <= nbVmsClamped; i++) {
newNameLabels.push(`${name_label || 'VM'}_${i}`)
const replacer = this._buildTemplate()
for (let i = +seqStart + nameLabels.length; i <= +seqStart + nbVmsClamped - 1; i++) {
newNameLabels.push(replacer(this.state.state, i))
}
this._setState({ nameLabels: newNameLabels })
}
}
_updateNameLabels = () => {
const { name_label, nameLabels } = this.state.state
const { nameLabels, seqStart } = this.state.state
const nbVms = nameLabels.length
const newNameLabels = []
for (let i = 1; i <= nbVms; i++) {
newNameLabels.push(`${name_label || 'VM'}_${i}`)
const replacer = this._buildTemplate()
for (let i = +seqStart; i <= +seqStart + nbVms - 1; i++) {
newNameLabels.push(replacer(this.state.state, i))
}
this._setState({ nameLabels: newNameLabels })
}
@@ -599,8 +618,11 @@ export default class NewVm extends BaseComponent {
name_label,
nameLabels,
nbVms,
namePattern,
seqStart,
template
} = this.state.state
const { formatMessage } = this.props.intl
return <Section icon='new-vm-infos' title='newVmInfoPanel' done={this._isInfoDone()}>
<SectionContent>
<Item label='newVmTemplateLabel'>
@@ -636,31 +658,54 @@ export default class NewVm extends BaseComponent {
/>
</Item>
</SectionContent>
<SectionContent column>
<LineItem>
<Item>
{_('newVmMultipleVms')}
&nbsp;&nbsp;
<Toggle value={multipleVms} onChange={this._getOnChange('multipleVms')} />
<br />
<div className='input-group'>
<DebounceInput
className='form-control'
debounceTimeout={DEBOUNCE_TIMEOUT}
disabled={!multipleVms}
max={NB_VMS_MAX}
min={NB_VMS_MIN}
onChange={this._getOnChange('nbVms')}
type='number'
value={nbVms}
/>
<span className='input-group-btn'>
<Button bsStyle='secondary' disabled={!multipleVms} onClick={this._updateNbVms}><Icon icon='arrow-right' /></Button>
</span>
</div>
<a className={styles.refreshNames} onClick={this._updateNameLabels}><Icon icon='refresh' /></a>
</Item>
</LineItem>
<SectionContent>
<Item>
{_('newVmMultipleVms')}
&nbsp;&nbsp;
<Toggle value={multipleVms} onChange={this._getOnChange('multipleVms')} />
</Item>
<Item>
{_('newVmMultipleVmsPattern')}
&nbsp;&nbsp;
<DebounceInput
className='form-control'
debounceTimeout={DEBOUNCE_TIMEOUT}
disabled={!multipleVms}
onChange={this._getOnChange('namePattern')}
placeholder={formatMessage(messages.newVmMultipleVmsPatternPlaceholder)}
value={namePattern}
/>
</Item>
<Item>
{_('newVmFirstIndex')}
&nbsp;&nbsp;
<DebounceInput
className={'form-control'}
debounceTimeout={DEBOUNCE_TIMEOUT}
disabled={!multipleVms}
onChange={this._getOnChange('seqStart')}
type='number'
value={seqStart}
/>
</Item>
<Item className='input-group'>
<DebounceInput
className='form-control'
debounceTimeout={DEBOUNCE_TIMEOUT}
disabled={!multipleVms}
max={NB_VMS_MAX}
min={NB_VMS_MIN}
onChange={this._getOnChange('nbVms')}
type='number'
value={nbVms}
/>
<span className='input-group-btn'>
<Button bsStyle='secondary' disabled={!multipleVms} onClick={this._updateNbVms}><Icon icon='arrow-right' /></Button>
</span>
</Item>
<Item>
<a className={styles.refreshNames} onClick={this._updateNameLabels}><Icon icon='refresh' /></a>
</Item>
{multipleVms && <LineItem>
{map(nameLabels, (nameLabel, index) =>
<Item key={`nameLabel_${index}`}>
@@ -677,7 +722,8 @@ export default class NewVm extends BaseComponent {
}
_renderPerformances = () => {
const { CPUs, cpuWeight, memory } = this.state.state
const { CPUs, cpuCap, cpuWeight, memory } = this.state.state
const { formatMessage } = this.props.intl
return <Section icon='new-vm-perf' title='newVmPerfPanel' done={this._isPerformancesDone()}>
<SectionContent>
<Item label='newVmVcpusLabel'>
@@ -694,16 +740,27 @@ export default class NewVm extends BaseComponent {
<SizeInput value={memory} onChange={this._getOnChange('memory')} className={styles.sizeInput} />
</Item>
<Item label='newVmCpuWeightLabel'>
<select
<DebounceInput
className='form-control'
debounceTimeout={DEBOUNCE_TIMEOUT}
min={0}
max={65535}
onChange={this._getOnChange('cpuWeight')}
placeholder={formatMessage(messages.newVmDefaultCpuWeight, { value: XEN_DEFAULT_CPU_WEIGHT })}
type='number'
value={cpuWeight}
>
{_('newVmCpuWeightQuarter', message => <option value={0.25}>{message}</option>)}
{_('newVmCpuWeightHalf', message => <option value={0.5}>{message}</option>)}
{_('newVmCpuWeightNormal', message => <option value={1}>{message}</option>)}
{_('newVmCpuWeightDouble', message => <option value={2}>{message}</option>)}
</select>
/>
</Item>
<Item label='newVmCpuCapLabel'>
<DebounceInput
className='form-control'
debounceTimeout={DEBOUNCE_TIMEOUT}
min={0}
onChange={this._getOnChange('cpuCap')}
placeholder={formatMessage(messages.newVmDefaultCpuCap, { value: XEN_DEFAULT_CPU_CAP })}
type='number'
value={cpuCap}
/>
</Item>
</SectionContent>
</Section>

View File

@@ -1,5 +1,6 @@
import _, { messages } from 'intl'
import ActionButton from 'action-button'
import Component from 'base-component'
import filter from 'lodash/filter'
import Icon from 'icon'
import includes from 'lodash/includes'
@@ -7,49 +8,23 @@ import info, { error } from 'notification'
import isEmpty from 'lodash/isEmpty'
import map from 'lodash/map'
import Page from '../../page'
import React, { Component } from 'react'
import propTypes from 'prop-types'
import React from 'react'
import store from 'store'
import trim from 'lodash/trim'
import Wizard, { Section } from 'wizard'
import { Container, Row, Col } from 'grid'
import { confirm } from 'modal'
import { connectStore, formatSize } from 'utils'
import { GenericSelect, SelectHost } from 'select-objects'
import { Container, Row, Col } from 'grid'
import { injectIntl } from 'react-intl'
import { Password } from 'form'
import { Password, Select } from 'form'
import { SelectHost } from 'select-objects'
import {
createFilter,
createGetObjectsOfType,
createSelector,
getObject
} from 'selectors'
class SelectIqn extends GenericSelect {
_computeOptions (props) {
return map(props.options, iqn => ({
value: iqn,
label: `${iqn.iqn} (${iqn.ip})`
}))
}
get value () {
const value = this.state.value
return value && value.value || value
}
}
class SelectLun extends GenericSelect {
_computeOptions (props) {
return map(props.options, lun => ({
value: lun,
label: `LUN ${lun.id}: ${lun.serial} - ${formatSize(+lun.size)} - (${lun.vendor})`
}))
}
get value () {
const value = this.state.value
return value && value.value || value
}
}
import {
createSrIso,
createSrIscsi,
@@ -66,6 +41,98 @@ import {
// ===================================================================
@propTypes({
onChange: propTypes.func.isRequired,
options: propTypes.array.isRequired
})
class SelectIqn extends Component {
_computeOptions (props = this.props) {
this.setState({
options: map(props.options, (iqn, id) => ({
value: `${iqn.ip}$${iqn.iqn}`,
label: `${iqn.iqn} (${iqn.ip})`
}))
})
}
_handleChange = value => {
const { onChange } = this.props
value = value.value
const index = value.indexOf('$')
this.setState({
value
}, () => onChange({
ip: value.slice(0, index),
iqn: value.slice(index + 1)
}))
}
componentWillMount () {
this._computeOptions()
}
componentWillReceiveProps (props) {
this._computeOptions(props)
}
render () {
const { state } = this
return (
<Select
clearable={false}
onChange={this._handleChange}
options={state.options}
value={state.value}
/>
)
}
}
@propTypes({
onChange: propTypes.func.isRequired,
options: propTypes.array.isRequired
})
class SelectLun extends Component {
_computeOptions (props = this.props) {
this.setState({
options: map(props.options, lun => ({
value: lun.id,
label: `LUN ${lun.id}: ${lun.serial} - ${formatSize(+lun.size)} - (${lun.vendor})`
}))
})
}
_handleChange = value => {
const { onChange, options } = this.props
value = value.value
this.setState({ value }, () => onChange(options[value]))
}
componentWillMount () {
this._computeOptions()
}
componentWillReceiveProps (props) {
this._computeOptions(props)
}
render () {
const { state } = this
return (
<Select
clearable={false}
onChange={this._handleChange}
options={state.options}
value={state.value}
/>
)
}
}
// ===================================================================
const SR_TYPE_TO_LABEL = {
nfs: 'NFS',
iscsi: 'iSCSI',
@@ -160,7 +227,7 @@ export default class New extends Component {
return createSrNfs(host.id, name.value, description.value, server.value, path)
},
iscsi: async () => {
const previous = await probeSrIscsiExists(host.id, iqn.ip, iqn.iqn, lun.scsiId, port.value, username && username.value, password && password.value)
const previous = await probeSrIscsiExists(host.id, iqn.ip, iqn.iqn, lun.scsiId, +port.value, username && username.value, password && password.value)
if (previous && previous.length > 0) {
try {
await confirm({title: _('existingLunModalTitle'),
@@ -170,7 +237,7 @@ export default class New extends Component {
return
}
}
return createSrIscsi(host.id, name.value, description.value, iqn.ip, iqn.iqn, lun.scsiId, port.value, username && username.value, password && password.value)
return createSrIscsi(host.id, name.value, description.value, iqn.ip, iqn.iqn, lun.scsiId, +port.value, username && username.value, password && password.value)
},
lvm: () => createSrLvm(host.id, name.value, description.value, device.value),
local: () => createSrIso(host.id, name.value, description.value, localPath.value, 'local'),
@@ -238,7 +305,7 @@ export default class New extends Component {
try {
this.setState({loading: true})
const list = await probeSrIscsiExists(host.id, iqn.ip, iqn.iqn, lun.scsiId, port.value, username && username.value, password && password.value)
const list = await probeSrIscsiExists(host.id, iqn.ip, iqn.iqn, lun.scsiId, +port.value, username && username.value, password && password.value)
const srIds = map(this.getHostSrs(), sr => sr.id)
const used = filter(list, item => includes(srIds, item.id))
const unused = filter(list, item => !includes(srIds, item.id))
@@ -284,7 +351,7 @@ export default class New extends Component {
paths
})
} else if (type === 'iscsi') {
const iqns = await probeSrIscsiIqns(host.id, server.value, port.value, username && username.value, password && password.value)
const iqns = await probeSrIscsiIqns(host.id, server.value, +port.value, username && username.value, password && password.value)
if (!iqns.length) {
info('iSCSI Detection', 'No IQNs found')
} else {

View File

@@ -7,7 +7,7 @@ import pick from 'lodash/pick'
import React, { cloneElement, Component } from 'react'
import { NavLink, NavTabs } from 'nav'
import { Text } from 'editable'
import { editPool, isSrWritable } from 'xo'
import { editPool } from 'xo'
import { Container, Row, Col } from 'grid'
import {
connectStore,
@@ -63,7 +63,7 @@ import TabStorage from './tab-storage'
const getPoolSrs = createGetObjectsOfType('SR').filter(
createSelector(
getPool,
({ id }) => sr => isSrWritable(sr) && sr.$pool === id
({ id }) => sr => sr.$pool === id
)
).sort()

View File

@@ -39,6 +39,7 @@ import {
createResourceSet,
deleteResourceSet,
editRessourceSet,
recomputeResourceSetsLimits,
subscribeResourceSets
} from 'xo'
@@ -488,47 +489,59 @@ export default class Administration extends Component {
</CardHeader>
<CardBlock>
{!isEmpty(resourceSets)
? map(resourceSets, (resourceSet, key) => (
<div key={key} className='p-b-1'>
<h5 className='form-inline clearfix'>
{resourceSet.name}
<div className='form-group pull-xs-right'>
<div className='btn-toolbar'>
<div className='btn-group'>
<button className='btn btn-primary' type='button' onClick={() => { this._editResourceSet(resourceSet) }}>
<Icon icon='edit' /> {_('editResourceSet')}
</button>
</div>
<div className='btn-group'>
<button className='btn btn-danger' type='button' onClick={() => { this._deleteResourceSet(resourceSet) }}>
<Icon icon='delete' /> {_('deleteResourceSet')}
</button>
? [
<div className='text-xs-center'>
<ActionButton
className='btn btn-secondary'
handler={recomputeResourceSetsLimits}
icon='refresh'
>
{_('recomputeResourceSets')}
</ActionButton>
</div>,
<br />,
map(resourceSets, (resourceSet, key) => (
<div key={key} className='p-b-1'>
<h5 className='form-inline clearfix'>
{resourceSet.name}
<div className='form-group pull-xs-right'>
<div className='btn-toolbar'>
<div className='btn-group'>
<button className='btn btn-primary' type='button' onClick={() => { this._editResourceSet(resourceSet) }}>
<Icon icon='edit' /> {_('editResourceSet')}
</button>
</div>
<div className='btn-group'>
<button className='btn btn-danger' type='button' onClick={() => { this._deleteResourceSet(resourceSet) }}>
<Icon icon='delete' /> {_('deleteResourceSet')}
</button>
</div>
</div>
</div>
</div>
</h5>
<ul key={key} className='list-group'>
<li className='list-group-item'>
<Subjects subjects={resourceSet.subjects} />
</li>
{map(resourceSet.objectsByType, (objectsSet, type) => (
<li key={type} className='list-group-item'>
{map(objectsSet, object => renderXoItem(object, { className: 'm-r-1' }))}
</h5>
<ul key={key} className='list-group'>
<li className='list-group-item'>
<Subjects subjects={resourceSet.subjects} />
</li>
))}
<li className='list-group-item'>
<Limits limits={resourceSet.limits} />
</li>
</ul>
{resourceSet.missingObjects.length > 0 &&
<div className='alert alert-danger m-t-1' role='alert'>
<strong>{_('resourceSetMissingObjects')}</strong> {resourceSet.missingObjects.join(', ')}
</div>
}
<hr />
</div>
))
: _('noResourceSets')
{map(resourceSet.objectsByType, (objectsSet, type) => (
<li key={type} className='list-group-item'>
{map(objectsSet, object => renderXoItem(object, { className: 'm-r-1' }))}
</li>
))}
<li className='list-group-item'>
<Limits limits={resourceSet.limits} />
</li>
</ul>
{resourceSet.missingObjects.length > 0 &&
<div className='alert alert-danger m-t-1' role='alert'>
<strong>{_('resourceSetMissingObjects')}</strong> {resourceSet.missingObjects.join(', ')}
</div>
}
<hr />
</div>
))
]
: _('noResourceSets')
}
</CardBlock>
</Card>

View File

@@ -159,7 +159,7 @@ export default class Dashboard extends Component {
}
render () {
const { resourceSets } = this
const { resourceSets } = this.state
return process.env.XOA_PLAN > 3
? <Container>

View File

@@ -2,6 +2,7 @@ import _ from 'intl'
import ActionButton from 'action-button'
import ActionRowButton from 'action-row-button'
import Component from 'base-component'
import filter from 'lodash/filter'
import forEach from 'lodash/forEach'
import isEmpty from 'lodash/isEmpty'
import keyBy from 'lodash/keyBy'
@@ -10,11 +11,14 @@ import pickBy from 'lodash/pickBy'
import React from 'react'
import renderXoItem, { renderXoItemFromId } from 'render-xo-item'
import SortedTable from 'sorted-table'
import toArray from 'lodash/toArray'
import Upgrade from 'xoa-upgrade'
import store from 'store'
import { connectStore } from 'utils'
import { Container } from 'grid'
import { error } from 'notification'
import { SelectHighLevelObject, SelectRole, SelectSubject } from 'select-objects'
import { ButtonGroup } from 'react-bootstrap-4/lib'
import {
createGetObjectsOfType,
@@ -31,6 +35,14 @@ import {
subscribeUsers
} from 'xo'
const TYPES = [
'VM',
'host',
'pool',
'SR',
'network'
]
const ACL_COLUMNS = [
{
name: _('subjectName'),
@@ -125,6 +137,7 @@ export default class Acls extends Component {
constructor (props) {
super(props)
this.state = {
isAllSelected: {},
subjects: [],
objects: [],
role: undefined
@@ -135,6 +148,23 @@ export default class Acls extends Component {
_handleSelectRole = action => this.setState({action})
_handleSelectSubject = subjects => this.setState({subjects})
_toggleAll = type => {
const { isAllSelected, objects } = this.state
let newObjects
if (!isAllSelected[type]) {
newObjects = [ ...objects, ...toArray(createGetObjectsOfType(type)(store.getState())) ]
} else {
newObjects = filter(objects, object => object.type !== type)
}
this.refs.selectObject.value = newObjects
this.setState({
objects: newObjects,
isAllSelected: {
...isAllSelected,
[type]: !isAllSelected[type] }
})
}
_addAcl = async () => {
const {
subjects,
@@ -160,6 +190,7 @@ export default class Acls extends Component {
render () {
const {
isAllSelected,
objects,
action,
subjects
@@ -174,6 +205,11 @@ export default class Acls extends Component {
<div className='form-group'>
<SelectHighLevelObject ref='selectObject' multi onChange={this._handleSelectObjects} />
</div>
<ButtonGroup className='p-b-1'>
{map(TYPES, type =>
<ActionButton key={type} btnStyle={isAllSelected[type] ? 'success' : 'secondary'} size='small' icon={type.toLowerCase()} handler={this._toggleAll} handlerParam={type} />
)}
</ButtonGroup>
<div className='form-group'>
<SelectRole ref='selectAction' onChange={this._handleSelectRole} />
</div>

View File

@@ -1,14 +1,15 @@
import _ from 'intl'
import ActionButton from 'action-button'
import ActionToggle from 'action-toggle'
import GenericInput from 'json-schema-input'
import Icon from 'icon'
import React, { Component } from 'react'
import _ from 'intl'
import map from 'lodash/map'
import React, { Component } from 'react'
import size from 'lodash/size'
import { addSubscriptions } from 'utils'
import { generateUiSchema } from 'xo-json-schema-input'
import { Row, Col } from 'grid'
import { lastly } from 'promise-toolbox'
import { Row, Col } from 'grid'
import {
configurePlugin,
disablePluginAutoload,
@@ -111,13 +112,21 @@ class Plugin extends Component {
})
}
_applyPredefinedConfiguration = () => {
const configName = this.refs.selectPredefinedConfiguration.value
this.refs.pluginInput.value = this.props.configurationPresets[configName]
}
render () {
const {
props,
state
} = this
const { expanded, edit } = state
const { loaded } = props
const {
configurationPresets,
loaded
} = props
const { formId } = this
return (
@@ -149,6 +158,34 @@ class Plugin extends Component {
</Row>
{expanded &&
<form id={formId}>
{size(configurationPresets) > 0 && (
<div>
<legend>{_('pluginConfigurationPresetTitle')}</legend>
<span className='text-muted'>
<p>{_('pluginConfigurationChoosePreset')}</p>
</span>
<div className='input-group'>
<select className='form-control' disabled={!edit} ref='selectPredefinedConfiguration'>
{map(configurationPresets, (_, name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
<span className='input-group-btn'>
<button
className='btn btn-primary'
disabled={!edit}
onClick={this._applyPredefinedConfiguration}
type='button'
>
{_('applyPluginPreset')}
</button>
</span>
</div>
<hr />
</div>
)}
<GenericInput
disabled={!edit}
label='Configuration'

View File

@@ -1,17 +1,43 @@
import * as FormGrid from 'form-grid'
import * as homeFilters from 'home-filters'
import _, { messages } from 'intl'
import ActionButton from 'action-button'
import BaseComponent from 'base-component'
import Component from 'base-component'
import Icon from 'icon'
import isEmpty from 'lodash/isEmpty'
import map from 'lodash/map'
import propTypes from 'prop-types'
import React from 'react'
import { Text } from 'editable'
import { alert } from 'modal'
import { connectStore } from 'utils'
import { changePassword } from 'xo'
import { Container, Row, Col } from 'grid'
import { getLang, getUser } from 'selectors'
import { getLang } from 'selectors'
import { injectIntl } from 'react-intl'
import { Select } from 'form'
import {
Card,
CardBlock,
CardHeader
} from 'card'
import {
addSubscriptions,
connectStore,
noop
} from 'utils'
import {
addSshKey,
changePassword,
deleteSshKey,
editCustomFilter,
removeCustomFilter,
setDefaultHomeFilter,
subscribeCurrentUser
} from 'xo'
import Page from '../page'
// ===================================================================
const HEADER = <Container>
<Row>
<Col>
@@ -20,12 +46,185 @@ const HEADER = <Container>
</Row>
</Container>
// ===================================================================
const FILTER_TYPE_TO_LABEL_ID = {
VM: 'homeTypeVm',
host: 'homeTypeHost',
pool: 'homeTypePool'
}
const getDefaultFilter = (defaultFilters, type) => {
if (defaultFilters == null) {
return ''
}
return defaultFilters[type] || ''
}
const getUserPreferences = user => user.preferences || {}
// ===================================================================
@propTypes({
customFilters: propTypes.object,
defaultFilter: propTypes.string.isRequired,
filters: propTypes.object.isRequired,
type: propTypes.string.isRequired
})
class DefaultFilterPicker extends Component {
_computeOptions (props) {
const {
customFilters,
filters
} = props
// Custom filters.
const options = [{
label: _('customFilters'),
disabled: true
}]
options.push.apply(options, map(customFilters, (filter, name) => ({
label: name,
value: name
})))
// Default filters
options.push({
label: _('defaultFilters'),
disabled: true
})
options.push.apply(options, map(filters, (filter, labelId) => ({
label: _(labelId),
value: labelId
})))
this.setState({ options })
}
_handleDefaultFilter = value => (
setDefaultHomeFilter(
this.props.type,
value && value.value
).catch(noop)
)
componentWillMount () {
this._computeOptions(this.props)
}
componentWillReceiveProps (props) {
this._computeOptions(props)
}
render () {
return (
<Row>
<Col>
<FormGrid.Row>
<FormGrid.LabelCol>
<strong>{_('defaultFilter')}</strong>
</FormGrid.LabelCol>
<FormGrid.InputCol>
<Select
onChange={this._handleDefaultFilter}
options={this.state.options}
value={this.props.defaultFilter}
/>
</FormGrid.InputCol>
</FormGrid.Row>
</Col>
</Row>
)
}
}
// ===================================================================
@propTypes({
user: propTypes.object.isRequired
})
class UserFilters extends Component {
_removeFilter = ({ name, type }) => removeCustomFilter(type, name)
render () {
const {
defaultHomeFilters,
filters: customFiltersByType
} = getUserPreferences(this.props.user)
return (
<Container>
<Row>
<Col>
<h4>{_('customizeFilters')}</h4>
<div>
{map(homeFilters, (filters, type) => {
const customFilters = customFiltersByType && customFiltersByType[type]
const defaultFilter = getDefaultFilter(defaultHomeFilters, type)
return (
<div key={type}>
<h5>{_(FILTER_TYPE_TO_LABEL_ID[type])}</h5>
<hr />
<DefaultFilterPicker
customFilters={customFilters}
defaultFilter={defaultFilter}
filters={filters}
type={type}
/>
{map(customFilters, (filter, name) => (
<Row key={name} className='p-b-1'>
<Col mediumSize={4}>
<div className='input-group'>
<Text
onChange={newName => editCustomFilter(type, name, { newName })}
value={name}
/>
</div>
</Col>
<Col mediumSize={7}>
<div className='input-group'>
<Text
onChange={newValue => editCustomFilter(type, name, { newValue })}
value={filter}
/>
</div>
</Col>
<Col mediumSize={1}>
<ActionButton
btnStyle='danger'
className='pull-right'
handler={this._removeFilter}
handlerParam={{ name, type }}
icon='delete'
/>
</Col>
</Row>
))}
</div>
)
})}
</div>
</Col>
</Row>
</Container>
)
}
}
// ===================================================================
@addSubscriptions({
user: subscribeCurrentUser
})
@connectStore({
lang: getLang,
user: getUser
lang: getLang
})
@injectIntl
export default class User extends BaseComponent {
export default class User extends Component {
handleSelectLang = event => {
this.props.selectLang(event.target.value)
}
@@ -47,23 +246,27 @@ export default class User extends BaseComponent {
_handleConfirmPasswordChange = event => this.setState({ confirmPassword: event.target.value })
render () {
const { lang, user } = this.props
if (!user) {
return <p>Loading</p>
}
const { formatMessage } = this.props.intl
const {
lang,
user
} = this.props
const {
confirmPassword,
newPassword,
oldPassword
} = this.state
const sshKeys = user && user.preferences && user.preferences.sshKeys
return <Page header={HEADER} title={user.email}>
<Container>
<Row>
<Col smallSize={2}><strong>{_('username')}</strong></Col>
<Col smallSize={10}>
{user && user.email}
{user.email}
</Col>
</Row>
<br />
@@ -71,11 +274,34 @@ export default class User extends BaseComponent {
<Col smallSize={2}><strong>{_('password')}</strong></Col>
<Col smallSize={10}>
<form className='form-inline' id='changePassword'>
<input type='password' onChange={this._handleOldPasswordChange} value={oldPassword} placeholder={formatMessage(messages.oldPasswordPlaceholder)} className='form-control' required />
<input
autocomplete='off'
className='form-control'
onChange={this._handleOldPasswordChange}
placeholder={formatMessage(messages.oldPasswordPlaceholder)}
required
type='password'
value={oldPassword || ''}
/>
{' '}
<input type='password' onChange={this._handleNewPasswordChange} value={newPassword} placeholder={formatMessage(messages.newPasswordPlaceholder)} className='form-control' required />
<input type='password'
autocomplete='off'
className='form-control'
onChange={this._handleNewPasswordChange}
placeholder={formatMessage(messages.newPasswordPlaceholder)}
required
value={newPassword}
/>
{' '}
<input type='password' onChange={this._handleConfirmPasswordChange} value={confirmPassword} placeholder={formatMessage(messages.confirmPasswordPlaceholder)} className='form-control' required />
<input
autocomplete='off'
className='form-control'
onChange={this._handleConfirmPasswordChange}
placeholder={formatMessage(messages.confirmPasswordPlaceholder)}
required
type='password'
value={confirmPassword}
/>
{' '}
<ActionButton icon='save' form='changePassword' btnStyle='primary' handler={this._handleSavePassword}>
{_('changePasswordOk')}
@@ -97,6 +323,49 @@ export default class User extends BaseComponent {
</Col>
</Row>
</Container>
<br />
<div>
<Card>
<CardHeader>
<Icon icon='ssh-key' /> {_('sshKeys')}
<ActionButton
className='btn-success pull-xs-right'
icon='add'
handler={addSshKey}
>
{_('newSshKey')}
</ActionButton>
</CardHeader>
<CardBlock>
{!isEmpty(sshKeys)
? <Container>
{map(sshKeys, (sshKey, key) => (
<Row key={key} className='p-b-1'>
<Col size={2}>
<strong>{sshKey.title}</strong>
</Col>
<Col size={8} style={{overflowWrap: 'break-word'}}>
{sshKey.key}
</Col>
<Col size={2}>
<ActionButton
className='btn-secondary pull-xs-right'
icon='delete'
handler={() => deleteSshKey(sshKey)}
>
{_('deleteSshKey')}
</ActionButton>
</Col>
</Row>
))}
</Container>
: _('noSshKeys')
}
</CardBlock>
</Card>
</div>
<hr />
<UserFilters user={user} />
</Page>
}
}

View File

@@ -17,7 +17,9 @@ import {
restartVm,
resumeVm,
stopVm,
suspendVm
suspendVm,
XEN_DEFAULT_CPU_CAP,
XEN_DEFAULT_CPU_WEIGHT
} from 'xo'
const forceReboot = vm => restartVm(vm, true)
@@ -136,10 +138,19 @@ export default ({
}
<tr>
<th>{_('cpuWeightLabel')}</th>
{vm.cpuWeight
? <td>{vm.cpuWeight}</td>
: <td>{_('defaultCpuWeight')}</td>
}
<td>
<Number value={vm.cpuWeight == null ? null : vm.cpuWeight} onChange={value => editVm(vm, { cpuWeight: value })} nullable>
{vm.cpuWeight == null ? _('defaultCpuWeight', { value: XEN_DEFAULT_CPU_WEIGHT }) : vm.cpuWeight}
</Number>
</td>
</tr>
<tr>
<th>{_('cpuCapLabel')}</th>
<td>
<Number value={vm.cpuCap == null ? null : vm.cpuCap} onChange={value => editVm(vm, { cpuCap: value })} nullable>
{vm.cpuCap == null ? _('defaultCpuCap', { value: XEN_DEFAULT_CPU_CAP }) : vm.cpuCap}
</Number>
</td>
</tr>
<tr>
<th>{_('autoPowerOn')}</th>