Compare commits

...

216 Commits

Author SHA1 Message Date
Julien Fontanet
f95370124b 5.7.1 2017-03-31 18:05:29 +02:00
Julien Fontanet
2564343816 fix(xo-json-schema-input/vm): controlled mode 2017-03-31 18:03:35 +02:00
Julien Fontanet
03734eb761 fix(logs): do not fail on non-string params 2017-03-31 18:03:35 +02:00
Julien Fontanet
29d63a9fdd 5.7.0 2017-03-31 16:36:40 +02:00
Julien Fontanet
ca94b236a8 feat(settings/plugins): easier edition 2017-03-31 16:35:14 +02:00
Julien Fontanet
fa1ec30ba5 chore(json-schema-input): controlled inputs (#2001) 2017-03-31 16:21:54 +02:00
Olivier Lambert
2b1423aebe fix(changelog): it seems we are in 2017. 2017-03-31 14:55:56 +02:00
Pierre Donias
373332141f fix(pool/packs): starter plan required to install packs (#2055) 2017-03-31 11:01:17 +02:00
Olivier Lambert
ecf2cf15b5 fix(changelog): typo in 5.7 release 2017-03-31 10:32:50 +02:00
Olivier Lambert
4ee0831d93 feat(changelog): updates for 5.7 2017-03-31 10:31:11 +02:00
Pierre Donias
7df2a88c13 feat(xosan/pack): check XS version requirement (#2054) 2017-03-31 10:18:43 +02:00
Olivier Lambert
3d52556c67 feat(changelog): updates for 5.6 2017-03-31 10:11:38 +02:00
badrAZ
437b160a3f feat(servers): add label property (#2051)
Fixes #1965
2017-03-29 16:23:51 +02:00
Pierre Donias
5c87b82e0c feat(new-vm,vm): select an affinity host (#2039)
See #1983
2017-03-29 14:07:55 +02:00
badrAZ
7f2bc79d5f feat(ActionButton): improve error reporting (#2050)
Fixes #2048
2017-03-29 12:03:19 +02:00
Pierre Donias
837a61acf3 fix(home): not visible items should never be selected (#2042)
Fixes #2027
Fixes #2035
2017-03-29 10:53:31 +02:00
badrAZ
5971eed72a feat(jobs): configure job timeout (#2043)
Fixes #1956
2017-03-29 10:39:29 +02:00
Pierre Donias
1b8224030b fix(ipPools): prevent creating 2 IP pools with the same name (#2041)
Fixes #1731
2017-03-24 12:26:52 +01:00
Pierre Donias
ed3ec3fa8b fix(vm/disks): do not show bootable flags for non PV VMs (#2040)
Fixes #1996
2017-03-24 11:49:46 +01:00
Pierre Donias
aa98ca49e5 feat(locales): Hungarian (hu) (#2038)
Fixes #2019
2017-03-24 10:36:03 +01:00
badrAZ
44d35c2351 feat: more uses of StateButton (#2034) 2017-03-23 17:46:26 +01:00
badrAZ
df8eb7a000 feat({backup,job}/overview): clearer state (#2023)
Fixes #1958
2017-03-23 09:42:23 +01:00
Julien Fontanet
ac061c8750 chore(backup/new): improve description of report 2017-03-22 12:13:35 +01:00
Julien Fontanet
656d3e55ac feat(backup/new): report on failure by default 2017-03-22 12:09:13 +01:00
Julien Fontanet
50641287f8 fix(XoApp): wait for signin before show pages 2017-03-17 14:48:33 +01:00
Julien Fontanet
0bc072aa65 feat(Home): add a None filter 2017-03-17 14:26:21 +01:00
Julien Fontanet
9d7d665520 chore(Home#_getDefaultFilter): cleaner code 2017-03-17 14:26:21 +01:00
Julien Fontanet
819ea94e7b fix(xo): keep user in store up to date 2017-03-17 14:26:21 +01:00
badrAZ
40753568df fix(settings/remotes): no duplicate names (#2021)
Fixes #1879
2017-03-17 14:11:15 +01:00
badrAZ
8793aed561 feat(home): improve inter-types linkage (#2015)
Fixes #2012
2017-03-16 10:11:52 +01:00
Julien Fontanet
377a50bc09 fix: minor warnings 2017-03-15 17:02:03 +01:00
Julien Fontanet
fe5a43fbdf chore: update yarn.lock 2017-03-15 16:09:17 +01:00
badrAZ
7f44220220 feat(new VM): share a VM (#2013) 2017-03-15 14:38:28 +01:00
greenkeeper[bot]
0df1610ca9 chore(package): update gulp-csso to version 3.0.0 (#2009)
https://greenkeeper.io/
2017-03-14 14:51:53 +01:00
Julien Fontanet
24c8b9e02d chore(auto-controlled-component): remove base-component dep 2017-03-14 11:43:47 +01:00
Pierre Donias
01b311f2ba fix(new-vm): remove bootable option (#2008)
Fixes #2007
2017-03-14 11:27:49 +01:00
Pierre Donias
a2bb3182f4 feat(backup/logs): show job tag in table (#2005)
Fixes #1982
2017-03-14 10:54:39 +01:00
Pierre Donias
c86e15a310 feat(xo/utils/getDefaultNetworkForVif): match network with same VLAN (#1997)
Fixes #1990
2017-03-13 18:03:14 +01:00
Julien Fontanet
862e5a95e7 fix(package): update babel-plugin-lodash 2017-03-09 17:51:47 +01:00
Julien Fontanet
73e2c7e849 chore(package): use babel-plugin-dev 2017-03-09 17:50:08 +01:00
Julien Fontanet
0b0937e233 chore(base-component): remove shallow-equal dep 2017-03-09 15:22:52 +01:00
Julien Fontanet
6bf114859f chore(base-component): remove invoke dep 2017-03-09 15:20:14 +01:00
Julien Fontanet
db6d67eeb7 feat(JsonSchemaInput/EnumInput): handle enumNames 2017-03-08 18:08:59 +01:00
Julien Fontanet
a345d89aac fix(home): changing type reset paging
Fixes #1993
2017-03-06 15:47:42 +01:00
Pierre Donias
e8f8ebb112 feat(XOSAN): select suggestion, SVG graph (#1991) 2017-03-06 12:01:38 +01:00
Julien Fontanet
1dad5b5c3a 5.6.3 2017-03-02 19:07:29 +01:00
Pierre Donias
5cc5ee4e87 fix(XOSAN): XS v7.0 required to install XOSAN (#1981) 2017-03-02 17:38:55 +01:00
Julien Fontanet
e8d2b32a14 5.6.2 2017-03-01 17:09:19 +01:00
Julien Fontanet
f492909e42 fix(linting): ignored files go into /.gitignore 2017-03-01 15:19:39 +01:00
Pierre Donias
7ea17750a1 fix(pool/patches): disable patching for free plan (#1972) 2017-03-01 10:13:09 +01:00
Julien Fontanet
663e1f1a4b fix(menu): XOSAN only displayed to admins
Fixes #1968
2017-02-28 17:54:21 +01:00
Julien Fontanet
079310c67e fix(store/reducer/object): missing part of previous fix 2017-02-28 17:01:36 +01:00
Julien Fontanet
5cf7f1f886 fix(store/reducer/object): handle type change
Fixes #1967
2017-02-28 16:14:07 +01:00
Julien Fontanet
9f64af859e chore(package): update react-select to v1.0.0-rc.3 2017-02-28 10:44:04 +01:00
Julien Fontanet
007aa776cb chore(package): update index-modules to v0.3.0 2017-02-28 10:33:12 +01:00
Julien Fontanet
66bc092edd chore(package): update husky to v0.13.1 2017-02-28 10:30:44 +01:00
Julien Fontanet
140a88ee12 chore(package): update jest to v19.0.2 2017-02-28 10:29:44 +01:00
Julien Fontanet
f42758938d fix(package): migrate ghooks→husky config 2017-02-27 11:41:31 +01:00
Julien Fontanet
e19fd81536 chore: update yarn.lock 2017-02-27 11:40:28 +01:00
Julien Fontanet
73835ded96 chore(store/actions/createAction): minor optimizations 2017-02-27 11:37:56 +01:00
Julien Fontanet
1ec1a8bd94 chore(package): update superagent to version 3.5.0 (#1962)
Closes #1947

https://greenkeeper.io/
2017-02-27 11:34:57 +01:00
greenkeeper[bot]
f0b6d57ba8 chore(package): update modular-css to version 4.1.1 (#1952)
https://greenkeeper.io/
2017-02-27 11:33:12 +01:00
Julien Fontanet
f9a3ad14d1 5.6.1 2017-02-23 18:39:04 +01:00
Pierre Donias
1b86f533f7 feat(XOSAN) (#1955) 2017-02-23 18:34:20 +01:00
greenkeeper[bot]
46416fb026 chore(package): update notifyjs to version 3.0.0 (#1940)
https://greenkeeper.io/
2017-02-15 12:41:38 +01:00
Julien Fontanet
54ed37c95d chore: update yarn.lock 2017-02-14 15:21:44 +01:00
Julien Fontanet
fd79b47d9e fix(tasks): do not break on unknown host 2017-02-14 15:13:37 +01:00
greenkeeper[bot]
be8333824b chore(package): update browserify to version 14.1.0 (#1938)
https://greenkeeper.io/
2017-02-14 09:05:21 +01:00
Julien Fontanet
55daffc791 fix(pool): predicate for host to add is now dynamic (#1923) 2017-02-07 11:17:33 +01:00
Pierre Donias
375baf7fe5 feat(home): link to VM's resource set (#1920)
Fixes #1905
2017-02-01 17:01:40 +01:00
Pierre Donias
815e74c93c feat(vm): allow snapshot with ACLs (#1916)
Fixes #1865
2017-02-01 15:35:54 +01:00
Pierre Donias
547d6fbc93 feat(pool/advanced): install supplemental pack on all pool's hosts (#1910)
See #1896
2017-02-01 13:08:16 +01:00
greenkeeper[bot]
b45a4b9e6c chore(package): update chartist-plugin-legend to version 0.6.1 (#1915)
https://greenkeeper.io/
2017-02-01 09:21:28 +01:00
greenkeeper[bot]
3436d0256a chore(package): update modular-css to version 3.0.0 (#1914)
https://greenkeeper.io/
2017-01-31 08:35:26 +01:00
greenkeeper[bot]
2627cfd426 chore(package): update react-chartist to version 0.12.0 (#1912)
https://greenkeeper.io/
2017-01-30 10:53:38 +01:00
Olivier Lambert
34b18c00a1 fix(lang): button verbiage. Fixes #1911 2017-01-30 10:07:00 +01:00
Julien Fontanet
e13af7f5f0 5.6.0 2017-01-27 16:41:29 +01:00
Nicolas Raynaud
ca08613292 fix(VM import): behave if network is not an object (#1908) 2017-01-27 13:03:30 +01:00
Pierre Donias
4ab63591a0 feat(host/advanced): install a supplemental pack (#1895)
Fixes #1460
2017-01-25 16:08:45 +01:00
Pierre Donias
5b4f98b03b fix(scheduling): improve TimePicker UI (#1898)
Fixes #1893
2017-01-25 15:59:03 +01:00
Pierre Donias
f396d61633 feat(settings/logs): easy bug reporting from errors (#1907)
See #1602
2017-01-25 15:06:18 +01:00
Julien Fontanet
9f8c0c8cdf fix(scheduling): month range is 0-11 not 1-12 (#1894)
* fix(scheduling): month range is 0-11 not 1-12

* fix(scheduling): remove unused import
2017-01-19 17:46:37 +01:00
Julien Fontanet
198777ffab fix(xo): improve plugin errors 2017-01-19 16:42:38 +01:00
Pierre Donias
29c5ca1132 feat(host/advanced): display installed supplemental packs (#1890)
Fixes #1506
2017-01-16 18:03:57 +01:00
Pierre Donias
05d6f3d1ed feat(iso-device): show DVDs in ISO selector (#1889)
Fixes #1880
2017-01-16 17:06:56 +01:00
Olivier Lambert
536e82de3d feat(readme): add live chat link and icon 2017-01-16 14:54:57 +01:00
Pierre Donias
c59be7c315 fix(backup/new): smart backup pool filter (#1888)
Leaving the pool filter empty now means no filtering on pools, it should be clearer.

See #1885
2017-01-16 14:49:50 +01:00
Pierre Donias
b327bb5bd0 feat(backup/file-restore): restore multiple files (#1886)
Fixes #1877
2017-01-16 09:33:30 +01:00
Julien Fontanet
a3103587f5 chore(package): use husky instead of ghooks 2017-01-11 10:07:16 +01:00
Julien Fontanet
1bb11b574f chore: update yarn.lock 2017-01-11 10:05:18 +01:00
greenkeeper[bot]
405efe6a31 chore(package): update react-redux to version 5.0.0 (#1825)
https://greenkeeper.io/
2017-01-11 09:22:07 +01:00
Pierre Donias
73663c3703 fix(locales/fr): multiple fixes (#1876) 2017-01-10 17:42:41 +01:00
Julien Fontanet
421ee7125b chore: update yarn.lock 2017-01-09 10:12:31 +01:00
Julien Fontanet
1a6166b63c chore: update yarn.lock 2017-01-09 10:04:37 +01:00
Julien Fontanet
3828e75b7d fix(package): use bootstrap 4.0.0-alpha5 exactly
Bootstrap 4.0.0-alpha6 is not compatible, we'll migrate with beta1.

See #1871
2017-01-09 10:02:43 +01:00
greenkeeper[bot]
154da142c7 chore(package): update gulp-sourcemaps to version 2.2.3 (#1874)
https://greenkeeper.io/
2017-01-09 09:58:50 +01:00
Pierre Donias
312cd60dd1 feat(settings/servers): error handling (#1868)
Fixes #1833
2017-01-06 16:38:24 +01:00
greenkeeper[bot]
6bf522f72f chore(package): update promise-toolbox to version 0.8.0 (#1870)
https://greenkeeper.io/
2017-01-06 11:39:58 +01:00
Pierre Donias
a844f8d459 fix(backup/new): prevent negative depth values (#1869)
Fixes #1851
2017-01-06 11:12:56 +01:00
Julien Fontanet
8ee206174b fix(vm/advanced): PV args edition for all PV VMs
Currently only available if there are already PV args.
2017-01-05 16:51:56 +01:00
Olivier Lambert
1a08e24a5c feat(pool): harmonize links to hosts and storages (#1867)
Fixes #1864
2017-01-05 14:29:06 +01:00
greenkeeper[bot]
086cd0e038 chore(package): update modular-css to version 2.0.0 (#1862)
https://greenkeeper.io/
2017-01-04 09:43:32 +01:00
Julien Fontanet
42d123318c chore(pakcage): upgrade react/react-dom to v15.4.1 2017-01-03 15:28:53 +01:00
Julien Fontanet
89f160317c 5.5.3 2017-01-03 15:19:09 +01:00
Julien Fontanet
9ccd1a0362 chore: update yarn.lock 2017-01-03 15:18:27 +01:00
Pierre Donias
d116d014bc fix(host/network): PIF deletion (#1861)
Fixes #1853
2017-01-03 15:13:44 +01:00
Pierre Donias
7956cabcf4 feat(pool): combined stats tab (#1848)
Fixes #1324
2017-01-03 14:58:22 +01:00
Pierre Donias
36c61ad357 feat(vm/snapshots): icon to indicate if a snapshot is quiesce (#1860)
Fixes #1858
2017-01-03 10:54:58 +01:00
greenkeeper[bot]
25d60360d5 chore(package): update modular-css to version 1.0.0 (#1859)
https://greenkeeper.io/
2017-01-03 09:38:00 +01:00
Olivier Lambert
1e5579e3ad feat(icons): use microchip for the CPU icon 2017-01-02 17:14:05 +01:00
Julien Fontanet
77d43b2280 fix(settings/logs): correctly refresh users 2017-01-02 16:36:09 +01:00
Julien Fontanet
33e8929e8b fix(settings/groups): avoid error when adding a user 2017-01-02 16:36:02 +01:00
Julien Fontanet
b79fa9cb9f feat(backup/overview): sort by start if not end
Avoid unfinished (broken) jobs to stay at the top.
2017-01-02 16:34:36 +01:00
Olivier Lambert
a2812a85bd feat(vm): redirect to home after converting VM to template (#1857)
Fixes #1855
2017-01-02 14:12:33 +01:00
Olivier Lambert
e8ff46a8ba fix(health): add link from SR to hosts and not only for pools. Fixes #1850 (#1856) 2017-01-02 11:29:32 +01:00
Julien Fontanet
351c01d642 fix(intl/messages): typo
Fix #1849
2016-12-24 09:15:54 +01:00
Julien Fontanet
e333b1d083 chore(package): use new shorter paths for Jest serializer 2016-12-22 16:58:26 +01:00
Julien Fontanet
5ad49de642 5.5.2 2016-12-22 12:27:03 +01:00
Fabrice Marsaud
b45bb5c144 fix(xoa-updater): use the new source info (#1846) 2016-12-22 12:26:02 +01:00
Julien Fontanet
9402596f69 5.5.1 2016-12-22 11:18:32 +01:00
Fabrice Marsaud
096687ae2c fix(xoa-updates): also refresh on plan change (#1843) 2016-12-22 11:16:45 +01:00
Julien Fontanet
210b5de992 fix(backup/file-restore-modal): fetched timestamps are in seconds
Follow up to for #1840.
2016-12-20 17:10:55 +01:00
Julien Fontanet
f742fdbf1b fix(backup/file-restore): fetched timestamps are in seconds
Fixes #1840
2016-12-20 16:49:37 +01:00
Pierre Donias
e7026c522d fix(editable/XoSelect): update value before saving (#1835) 2016-12-20 13:54:22 +01:00
Julien Fontanet
c21fc4beda 5.5.0 2016-12-20 13:39:05 +01:00
Julien Fontanet
edf6fe782e chore: update yarn.lock 2016-12-20 13:36:39 +01:00
Pierre Donias
3cbb6c4a98 feat(backup): implement file restore (#1838)
Fixes #1590
2016-12-20 13:34:59 +01:00
Olivier Lambert
568a50acc5 feat(changelog): update changelog 2016-12-19 17:59:14 +01:00
Pierre Donias
fbcb756cef fix(backup/new): remove "Only metadata" option (#1837)
Fixes #1818
2016-12-19 17:53:04 +01:00
Pierre Donias
81eb4ba4f9 fix(form/Select): make text wrap when too long (#1836)
Fixes #1832
2016-12-19 17:15:03 +01:00
Olivier Lambert
0cc14d2ab8 fix(intl): fix the place holder for NFS path, removing the initial slash 2016-12-19 13:48:49 +01:00
greenkeeper[bot]
6aedadc982 chore(package): update jest to version 18.0.0 (#1831)
https://greenkeeper.io/
2016-12-15 12:34:05 +01:00
Olivier Lambert
a8d10dab3c feat(changelog): changelog for 5.5 release 2016-12-15 12:19:35 +01:00
Pierre Donias
1ff6ff1d7a fix(getDefaultNetworkForVif): allow PIFs with no IP (#1830)
Fixes #1788
2016-12-15 10:31:03 +01:00
Pierre Donias
8afe4a85dc fix(form/select-plain-object): make it controlled (#1829) 2016-12-14 17:18:31 +01:00
greenkeeper[bot]
c57fbdce63 chore(package): update index-modules to version 0.2.1 (#1822) 2016-12-12 16:49:29 +01:00
greenkeeper[bot]
bdc0278fd1 chore(package): update gulp-sass to version 3.0.0 (#1820)
https://greenkeeper.io/
2016-12-10 15:07:20 +01:00
Fabrice Marsaud
c3ac8d0587 fix(xoa-updater): propose refresh when xo-web is not up to date (#1815)
Fixes #1801.
2016-12-08 16:24:47 +01:00
Julien Fontanet
f3a5e1e97c feat: yarn integration (#1813) 2016-12-07 14:36:51 +01:00
Julien Fontanet
919aa5fc43 feat(tests): basic tests for grid components 2016-12-07 14:06:20 +01:00
Julien Fontanet
416c98ffd2 chore(tests): use jest instead of ava 2016-12-07 14:06:20 +01:00
Pierre Donias
8094447183 fix(self-service): make it controlled and multiple fixes (#1812)
Fixes:
- VIF IP edition was not possible if the current IP's IP-pool did not exist anymore
- VM creation: removing the pool/resource set in the selector was broken
- Prevent selecting a negative number as an IP pool quantity
- Prevent deleting a resource set that hasn't been created yet
- Remove console.log
2016-12-06 16:43:07 +01:00
Julien Fontanet
575375d3e0 5.4.1 2016-12-05 14:08:56 +01:00
Julien Fontanet
4296ae02dc fix(vm/network): fix IP addresses concatenation 2016-12-05 14:04:43 +01:00
Julien Fontanet
0e40af0515 feat(patch-react): name the patched render function 2016-12-05 14:03:39 +01:00
Julien Fontanet
5d3a0e7a41 fix(patch-react): assign arguments object directly 2016-12-05 10:43:09 +01:00
Julien Fontanet
8ae2aae37a fix(package): update xo-acl-resolver to v0.2.3
It ships a workaround for an issue when a VDI snapshot $snapshot_of is itself.
2016-12-02 16:18:31 +01:00
Pierre Donias
83b3cf406a fix(lang): auto-refresh XO app when changing language (#1809)
Fixes #1800
2016-12-02 13:09:35 +01:00
Julien Fontanet
1643ced4e0 feat: do not break if a component throws 2016-12-01 16:49:36 +01:00
Pierre Donias
b2a1840da7 fix(vm-import): SR selector disable condition (#1808)
Fixes #1804
2016-12-01 15:54:58 +01:00
Pierre Donias
b9f20d1e80 fix(select-objects): allow integer IDs (#1807)
Fixes #1805
2016-12-01 15:39:47 +01:00
Pierre Donias
0c77781be8 feat(backup/smart): tags/pools select all and negation (#1802)
Fixes #1503
2016-12-01 14:56:18 +01:00
Julien Fontanet
83245af1e2 feat: improve debugging in production (#1806)
* feat(build): enable sourcemaps in production

* feat(index): let browsers handle unhandled rejections

They do a much better job to display the error and its trace with sourcemaps.
2016-12-01 14:22:07 +01:00
Julien Fontanet
7db806a461 fix(xo/subcriptions): handle unsubscription during notification 2016-12-01 12:15:12 +01:00
Olivier Lambert
92b15fb1e2 feat(consoles): update tip about console issue to point to XS ticket 2016-11-29 11:00:30 +01:00
Pierre Donias
7b5182111c fix(dashboard/health): message parsing and link to object view (#1796)
Related to #1776
2016-11-29 09:53:19 +01:00
Nicolas Raynaud
82b1b81999 fix(dashboard/overview): graphs on Safari (#1771)
There seems to be a bug in Safari with flex layout, hard coding the height of the graph seems to do the trick.

Fixes #1755
2016-11-29 09:21:04 +01:00
Pierre Donias
f0a430f350 feat(acls): filter object selector (#1791)
Fixes #1515
2016-11-28 12:05:56 +01:00
Olivier Lambert
90f95b7270 feat(i18n): added selector for simplified Chinese 2016-11-28 10:53:41 +01:00
Olivier Lambert
15e6a93fac feat(i18n): added simplified Chinese translation 2016-11-28 10:45:33 +01:00
Julien Fontanet
01541a2577 feat(@autoControlledInput): make controlled inputs able to handle uncontrolled mode
See #1628.
2016-11-24 11:34:21 +01:00
Olivier Lambert
8c70bc0a17 feat(i18n): fix wrong translate key 2016-11-24 10:16:44 +01:00
Nicolas Raynaud
9d96074604 chore(package): update react-shortcuts to v1.3.1 (#1792)
Fix #1691
2016-11-24 09:58:56 +01:00
Julien Fontanet
114a4028f4 fix: coding style fixes 2016-11-24 09:57:02 +01:00
Julien Fontanet
b342a4ba17 5.4.0 2016-11-23 11:09:24 +01:00
Olivier Lambert
fcbf037619 feat(changelog): prepare for the 5.4 release 2016-11-22 18:23:23 +01:00
Pierre Donias
a8e4ab433d feat(backup/new): warning when using delta or continuous rep with XS<6.5 (#1782) 2016-11-22 17:10:26 +01:00
Pierre Donias
6613ba02ab feat(dashboard/health): better formating of alert messages (#1781)
Fixes #1776
2016-11-22 16:41:07 +01:00
Pierre Donias
2af7fde83f fix(dashboard/health): delete orphaned VDIs (#1778)
Fixes #1622
2016-11-21 15:49:23 +01:00
Pierre Donias
19a0d4bc98 feat(timezone-picker): make server timezone less central (#1774)
Fixes #1706
2016-11-21 15:48:01 +01:00
Pierre Donias
9ed49b1f27 fix(menu): non admin users should not see backups and SRs in menu (#1777)
Fixes #1773
2016-11-21 15:45:34 +01:00
Pierre Donias
d56df30a22 feat(host): link to VMs list (#1772)
Fixes #1432
2016-11-18 12:50:43 +01:00
Julien Fontanet
64908068d9 fix: remove Intl polyfill
Safari 10 fixes this issue.

Fixes #1629
2016-11-18 12:40:24 +01:00
Pierre Donias
fe69d59aeb feat(tags): link to home page with tag filter (#1770)
Fixes #1763
2016-11-18 11:31:12 +01:00
Julien Fontanet
b65e737f84 feat(complex-matcher): support matching boolean props
Fixes #1768
2016-11-17 15:28:52 +01:00
Pierre Donias
bd274fdc3c feat(home): can display SRs (#1767)
Fixes #1764
2016-11-17 15:03:46 +01:00
greenkeeper[bot]
ac19249c63 chore(package): update xo-common to version 0.1.0 (#1765)
https://greenkeeper.io/
2016-11-17 05:28:32 +01:00
badrAZ
2abff1fec8 fix(settings/plugins): fix test when no test schema (#1766) 2016-11-16 15:38:48 +01:00
badrAZ
f1a6cfae0d feat(settings/plugins): display plugin test failures (#1754) 2016-11-16 10:53:57 +01:00
greenkeeper[bot]
e43e90ed3c chore(package): update react-chartist to version 0.11.0 (#1761)
https://greenkeeper.io/
2016-11-15 12:58:44 +01:00
greenkeeper[bot]
0ee88fe0dc Update dependencies to enable Greenkeeper 🌴 (#1757)
https://greenkeeper.io/
2016-11-15 11:02:45 +01:00
Pierre Donias
07e7f2e14d fix(menu): jobs should not be available to non-admin users (#1760)
Fixes #1759
2016-11-15 09:46:37 +01:00
Julien Fontanet
366ab95a2f feat(backup,job): ability to set the job owner (#1758)
Fixes #1733
2016-11-14 17:43:02 +01:00
Pierre Donias
ca723068a1 Fix clearable condition. 2016-11-14 17:29:29 +01:00
Pierre Donias
e424a105b3 Minor fixes. 2016-11-14 17:23:05 +01:00
Pierre Donias
32d2f92413 Multiple fixes. 2016-11-14 16:48:33 +01:00
Pierre Donias
898e2ff010 feat(job,backup): handle unexisting job creator
See #1733
2016-11-14 15:32:52 +01:00
Pierre Donias
dfa5e76870 feat(sr/disks): also display VDI snapshots (#1750)
Fixes #1723
2016-11-09 16:38:52 +01:00
Julien Fontanet
c93dd12fae feat(settings/plugins): testing
See #1749
2016-11-09 16:34:29 +01:00
Julien Fontanet
dbb1b1e582 fix(settings/plugins): unloading 2016-11-09 16:19:07 +01:00
Julien Fontanet
76388ee160 feat(utils/Debug): correctly display undefined 2016-11-09 16:18:32 +01:00
Pierre Donias
5ec2eee69a fix(settings/plugins): no plugins message condition (#1746) 2016-11-09 10:08:18 +01:00
Pierre Donias
31875a36fe fix(backup/restore): do not display duplicate tags (#1745)
Fixes #1734
2016-11-08 17:43:01 +01:00
Olivier Lambert
c50598b78e fix(jobs): last slicing fixed for job IDs 2016-11-08 15:37:44 +01:00
Pierre Donias
2f0c81d9ad feat(settings/plugins): message when no plugins (#1744)
Fixes #1670
2016-11-08 15:30:36 +01:00
Olivier Lambert
c22f89c6bb fix(jobs): better slicing of job IDs 2016-11-08 15:29:46 +01:00
Pierre Donias
568a23cd35 feat(pool/networks): change columns layout. Fixes #1696 2016-11-08 15:20:13 +01:00
Pierre Donias
eb7c4c131d fix(xo): add xo-common package (#1742) 2016-11-07 18:18:07 +01:00
Pierre Donias
f0664cd2c7 feat(xo/restartHost-s): trigger error modal if host could not be restarted (#1740)
Fixes #1717
2016-11-07 17:10:02 +01:00
Julien Fontanet
570eb7bc89 chore(package): update jsonrpc-websocket-client to v0.1.1 2016-11-07 16:48:10 +01:00
Pierre Donias
1ee91b4925 feat(backup/overview): display jobs in a SortedTable (#1741)
Fixes #1726
2016-11-07 14:43:16 +01:00
Julien Fontanet
69fee37f00 5.3.2 2016-11-04 11:43:49 +01:00
Julien Fontanet
49be66ae69 fix(xo/deleteSchedule): resolveIds() → resolveId()
Fixes #1737
2016-11-04 10:53:52 +01:00
Julien Fontanet
a0efe6895c fix(jobs/schedules/<id>/edit): correctly pass id prop
Fixes #1736
2016-11-04 10:31:32 +01:00
Julien Fontanet
8ef07e917d fix(jobs/<id>/edit): correctly set the id prop
Fixes #1728
2016-11-04 10:00:55 +01:00
Olivier Lambert
d3995b7bab feat(job): slicing some job ids 2016-11-03 18:00:49 +01:00
Olivier Lambert
c353e71ce7 feat(jobs): slice ids to be more human readable (#1735) 2016-11-03 16:48:48 +01:00
Olivier Lambert
a3570a1c9f feat(pif): use carrier to detect physical link (#1732)
Fixes #1702
2016-11-02 16:59:43 +01:00
Pierre Donias
c593c98e6d fix(settings/acls): display only valid ACLs (#1730) 2016-11-02 14:32:22 +01:00
Olivier Lambert
a4b5b532f2 feat(health): add links to some objects (#1729)
Fixes #1700
2016-11-02 12:29:50 +01:00
Julien Fontanet
6357f23aeb fix(package): use bootstrap 4.0.0-alpha5 2016-10-31 11:26:33 +01:00
Olivier Lambert
01d9b3bd0e feat(home/pools): add links to VMs or hosts and add VM number (#1720)
Fixes #1226
2016-10-28 14:35:32 +02:00
Pierre Donias
6b428f7587 fix(IP pools): behave with missing VIFs (#1716) 2016-10-28 11:57:29 +02:00
Pierre Donias
f829aa76d7 fix(dashboard/stats): requires at least Enterprise plan (#1718) 2016-10-28 11:43:36 +02:00
Fabrice Marsaud
a72051e96f fix(xoa-updater): fix double connection (#1714)
* fix double connection
* fix minified class name display
2016-10-28 10:16:56 +02:00
Pierre Donias
797622ba66 fix(settings/config): export/import should be available to Starter plan (#1715) 2016-10-28 09:52:41 +02:00
159 changed files with 19074 additions and 5330 deletions

2
.gitignore vendored
View File

@@ -1,5 +1,7 @@
/dist/
/node_modules/
/src/common/intl/locales/index.js
/src/common/themes/index.js
npm-debug.log
npm-debug.log.*

View File

@@ -4,9 +4,7 @@ node_js:
#- '4' # npm 3's flat tree is needed because some packages do not
# declare their deps correctly (e.g. chartist-plugin-tooltip)
cache:
directories:
- node_modules
cache: yarn
# Use containers.
# http://docs.travis-ci.com/user/workers/container-based-infrastructure/

View File

@@ -1,19 +1,159 @@
# ChangeLog
## **5.3.1** (2016-10-27)
## **5.7.0** (2017-03-31)
### Enhancements
- Improve backup restore view [\#1609](https://github.com/vatesfr/xo-web/issues/1609)
- Move location of NFS mount point [\#1405](https://github.com/vatesfr/xo-web/issues/1405)
- Modify VLAN of an existing network [\#1092](https://github.com/vatesfr/xo-web/issues/1092)
- Ability to export/import XO config [\#786](https://github.com/vatesfr/xo-web/issues/786)
- Improve ActionButton error reporting [\#2048](https://github.com/vatesfr/xo-web/issues/2048)
- Home view master checkbox UI issue [\#2027](https://github.com/vatesfr/xo-web/issues/2027)
- HU Translation [\#2019](https://github.com/vatesfr/xo-web/issues/2019)
- [Usage report] Add name for all objects [\#2017](https://github.com/vatesfr/xo-web/issues/2017)
- [Home] Improve inter-types linkage [\#2012](https://github.com/vatesfr/xo-web/issues/2012)
- Remove bootable checkboxes in VM creation [\#2007](https://github.com/vatesfr/xo-web/issues/2007)
- Do not display bootable toggles for disks of non-PV VMs [\#1996](https://github.com/vatesfr/xo-web/issues/1996)
- Try to match network VLAN for VM migration modal [\#1990](https://github.com/vatesfr/xo-web/issues/1990)
- [Usage reports] Add VM names in addition to UUIDs [\#1984](https://github.com/vatesfr/xo-web/issues/1984)
- Host affinity in "advanced" VM creation [\#1983](https://github.com/vatesfr/xo-web/issues/1983)
- Add job tag in backup logs [\#1982](https://github.com/vatesfr/xo-web/issues/1982)
- Possibility to add a label/description to servers [\#1965](https://github.com/vatesfr/xo-web/issues/1965)
- Possibility to create shared VM in a resource set [\#1964](https://github.com/vatesfr/xo-web/issues/1964)
- Clearer display of disabled (backup) jobs [\#1958](https://github.com/vatesfr/xo-web/issues/1958)
- Job should have a configurable timeout [\#1956](https://github.com/vatesfr/xo-web/issues/1956)
- Sort failed VMs in backup report [\#1950](https://github.com/vatesfr/xo-web/issues/1950)
- Support for UNIX socket path [\#1944](https://github.com/vatesfr/xo-web/issues/1944)
- Interface - Host Patching - Button Verbiage [\#1911](https://github.com/vatesfr/xo-web/issues/1911)
- Display if a VM is in Self Service (and which group) [\#1905](https://github.com/vatesfr/xo-web/issues/1905)
- Install supplemental pack on a whole pool [\#1896](https://github.com/vatesfr/xo-web/issues/1896)
- Allow VM snapshots with ACLs [\#1865](https://github.com/vatesfr/xo-web/issues/1886)
- Icon to indicate if a snapshot is quiesce [\#1858](https://github.com/vatesfr/xo-web/issues/1858)
- Pool Ips input too permissive [\#1731](https://github.com/vatesfr/xo-web/issues/1731)
- Select is going on top after each choice [\#1359](https://github.com/vatesfr/xo-web/issues/1359)
### Bug fixes
- Missing objects should be displayed in backup edition [\#2052](https://github.com/vatesfr/xo-web/issues/2052)
- Search bar content changes while typing [\#2035](https://github.com/vatesfr/xo-web/issues/2035)
- VM.$guest_metrics.PV_drivers_up_to_date is deprecated in XS 7.1 [\#2024](https://github.com/vatesfr/xo-web/issues/2024)
- Bootable flag selection checkbox for extra disk not fetched [\#1994](https://github.com/vatesfr/xo-web/issues/1994)
- Home view Changing type must reset paging [\#1993](https://github.com/vatesfr/xo-web/issues/1993)
- XOSAN menu item should only be displayed to admins [\#1968](https://github.com/vatesfr/xo-web/issues/1968)
- Object type change are not correctly handled in UI [\#1967](https://github.com/vatesfr/xo-web/issues/1967)
- VM creation is stuck when using ISO/DVD as install method [\#1966](https://github.com/vatesfr/xo-web/issues/1966)
- Install pack on whole pool fails [\#1957](https://github.com/vatesfr/xo-web/issues/1957)
- Consoles are broken in next-release [\#1954](https://github.com/vatesfr/xo-web/issues/1954)
- [VHD merge] Increase BAT when necessary [\#1939](https://github.com/vatesfr/xo-web/issues/1939)
- Issue on VM restore time [\#1936](https://github.com/vatesfr/xo-web/issues/1936)
- Two remotes should not be able to have the same name [\#1879](https://github.com/vatesfr/xo-web/issues/1879)
- Selfservice limits not honored after VM creation [\#1695](https://github.com/vatesfr/xo-web/issues/1695)
## **5.6.0** (2017-01-27)
Reporting, LVM File level restore.
### Enhancements
- Do not stop patches install if already applied [\#1904](https://github.com/vatesfr/xo-web/issues/1904)
- Improve scheduling UI [\#1893](https://github.com/vatesfr/xo-web/issues/1893)
- Smart backup and tag [\#1885](https://github.com/vatesfr/xo-web/issues/1885)
- Missing embeded API documention [\#1882](https://github.com/vatesfr/xo-web/issues/1882)
- Add local DVD in CD selector [\#1880](https://github.com/vatesfr/xo-web/issues/1880)
- File level restore for LVM [\#1878](https://github.com/vatesfr/xo-web/issues/1878)
- Restore multiple files from file level restore [\#1877](https://github.com/vatesfr/xo-web/issues/1877)
- Add a VM tab for host & pool views [\#1864](https://github.com/vatesfr/xo-web/issues/1864)
- Icon to indicate if a snapshot is quiesce [\#1858](https://github.com/vatesfr/xo-web/issues/1858)
- UI for disconnect hosts comp [\#1833](https://github.com/vatesfr/xo-web/issues/1833)
- Eject all xs-guest.iso in a pool [\#1798](https://github.com/vatesfr/xo-web/issues/1798)
- Display installed supplemental pack on host [\#1506](https://github.com/vatesfr/xo-web/issues/1506)
- Install supplemental pack on host comp [\#1460](https://github.com/vatesfr/xo-web/issues/1460)
- Pool-wide combined stats [\#1324](https://github.com/vatesfr/xo-web/issues/1324)
### Bug fixes
- IP-address not released when VM removed [\#1906](https://github.com/vatesfr/xo-web/issues/1906)
- Interface broken due to new Bootstrap Alpha [\#1871](https://github.com/vatesfr/xo-web/issues/1871)
- Self service recompute all limits broken [\#1866](https://github.com/vatesfr/xo-web/issues/1866)
- Patch not found error for XS 6.5 [\#1863](https://github.com/vatesfr/xo-web/issues/1863)
- Convert To Template issues [\#1855](https://github.com/vatesfr/xo-web/issues/1855)
- Removing PIF seems to fail [\#1853](https://github.com/vatesfr/xo-web/issues/1853)
- Depth should be >= 1 in backup creation [\#1851](https://github.com/vatesfr/xo-web/issues/1851)
- Wrong link in Dashboard > Health [\#1850](https://github.com/vatesfr/xo-web/issues/1850)
- Incorrect file dates shown in new File Restore feature [\#1840](https://github.com/vatesfr/xo-web/issues/1840)
- IP allocation problem [\#1747](https://github.com/vatesfr/xo-web/issues/1747)
- Selfservice limits not honored after VM creation [\#1695](https://github.com/vatesfr/xo-web/issues/1695)
## **5.5.0** (2016-12-20)
File level restore.
### Enhancements
- Better auto select network when migrate VM [\#1788](https://github.com/vatesfr/xo-web/issues/1788)
- Plugin for passive backup job reporting in Nagios [\#1664](https://github.com/vatesfr/xo-web/issues/1664)
- File level restore for delta backup [\#1590](https://github.com/vatesfr/xo-web/issues/1590)
- Better select filters for ACLs [\#1515](https://github.com/vatesfr/xo-web/issues/1515)
- All pools and "negative" filters [\#1503](https://github.com/vatesfr/xo-web/issues/1503)
- VM copy with disk selection [\#826](https://github.com/vatesfr/xo-web/issues/826)
- Disable metadata exports [\#1818](https://github.com/vatesfr/xo-web/issues/1818)
### Bug fixes
- Tool small selector [\#1832](https://github.com/vatesfr/xo-web/issues/1832)
- Replication does not work from a VM created by a CR or delta backup [\#1811](https://github.com/vatesfr/xo-web/issues/1811)
- Can't add a SSH key in VM creation [\#1805](https://github.com/vatesfr/xo-web/issues/1805)
- Issue when no default SR in a pool [\#1804](https://github.com/vatesfr/xo-web/issues/1804)
- XOA doesn't refresh after an update anymore [\#1801](https://github.com/vatesfr/xo-web/issues/1801)
- Shortcuts not inhibited on inputs on Safari [\#1691](https://github.com/vatesfr/xo-web/issues/1691)
## **5.4.0** (2016-11-23)
### Enhancements
- XML display in alerts [\#1776](https://github.com/vatesfr/xo-web/issues/1776)
- Remove some view for non admin users [\#1773](https://github.com/vatesfr/xo-web/issues/1773)
- Complex matcher should support matching boolean values [\#1768](https://github.com/vatesfr/xo-web/issues/1768)
- Home SR view [\#1764](https://github.com/vatesfr/xo-web/issues/1764)
- Filter on tag click [\#1763](https://github.com/vatesfr/xo-web/issues/1763)
- Testable plugins [\#1749](https://github.com/vatesfr/xo-web/issues/1749)
- Backup/Restore Design fix. [\#1734](https://github.com/vatesfr/xo-web/issues/1734)
- Display the owner of a \(backup\) job [\#1733](https://github.com/vatesfr/xo-web/issues/1733)
- Use paginated table for backup jobs [\#1726](https://github.com/vatesfr/xo-web/issues/1726)
- SR view / Disks: should display snapshot VDIs [\#1723](https://github.com/vatesfr/xo-web/issues/1723)
- Restored VM should have an identifiable name [\#1719](https://github.com/vatesfr/xo-web/issues/1719)
- If host reboot action returns NO\_HOSTS\_AVAILABLE, ask to force [\#1717](https://github.com/vatesfr/xo-web/issues/1717)
- Hide xo-server timezone in backups [\#1706](https://github.com/vatesfr/xo-web/issues/1706)
- Enable hyperlink for Hostname for Issues [\#1700](https://github.com/vatesfr/xo-web/issues/1700)
- Pool/network - Modify column [\#1696](https://github.com/vatesfr/xo-web/issues/1696)
- UI - Plugins - Display a message if no plugins [\#1670](https://github.com/vatesfr/xo-web/issues/1670)
- Display warning/error for delta backup on XS older than 6.5 [\#1647](https://github.com/vatesfr/xo-web/issues/1647)
- XO without internet access doesn't work [\#1629](https://github.com/vatesfr/xo-web/issues/1629)
- Improve backup restore view [\#1609](https://github.com/vatesfr/xo-web/issues/1609)
- UI Enhancement - Acronym for dummy [\#1604](https://github.com/vatesfr/xo-web/issues/1604)
- Slack XO plugin for backup report [\#1593](https://github.com/vatesfr/xo-web/issues/1593)
- Expose XAPI exceptions in the UI [\#1481](https://github.com/vatesfr/xo-web/issues/1481)
- Running VMs in the host overview, all VMs in the pool overview [\#1432](https://github.com/vatesfr/xo-web/issues/1432)
- Move location of NFS mount point [\#1405](https://github.com/vatesfr/xo-web/issues/1405)
- Home: Pool list - additionnal informations for pool [\#1226](https://github.com/vatesfr/xo-web/issues/1226)
- Modify VLAN of an existing network [\#1092](https://github.com/vatesfr/xo-web/issues/1092)
- Wrong instructions for CLI upgrade [\#787](https://github.com/vatesfr/xo-web/issues/787)
- Ability to export/import XO config [\#786](https://github.com/vatesfr/xo-web/issues/786)
- Test button for transport-email plugin [\#697](https://github.com/vatesfr/xo-web/issues/697)
- Merge `scheduler` API into `schedule` [\#664](https://github.com/vatesfr/xo-web/issues/664)
### Bug fixes
- Should jobs be accessible to non admins? [\#1759](https://github.com/vatesfr/xo-web/issues/1759)
- Schedules deletion is not working [\#1737](https://github.com/vatesfr/xo-web/issues/1737)
- Editing a job from the jobs overview page does not work [\#1736](https://github.com/vatesfr/xo-web/issues/1736)
- Editing a schedule from jobs overview does not work [\#1728](https://github.com/vatesfr/xo-web/issues/1728)
- ACLs not correctly imported [\#1722](https://github.com/vatesfr/xo-web/issues/1722)
- Some Bootstrap style broken [\#1721](https://github.com/vatesfr/xo-web/issues/1721)
- Not properly sign out on auth token expiration [\#1711](https://github.com/vatesfr/xo-web/issues/1711)
- Hosts/<UUID>/network status is incorrect [\#1702](https://github.com/vatesfr/xo-web/issues/1702)
- Patches application fails "Found : Moved Temporarily" [\#1701](https://github.com/vatesfr/xo-web/issues/1701)
- Password generation for user creation is not working [\#1678](https://github.com/vatesfr/xo-web/issues/1678)
- \#/dashboard/health Remove All Orphaned VDIs [\#1622](https://github.com/vatesfr/xo-web/issues/1622)
- Create a new SR - CIFS/SAMBA Broken [\#1615](https://github.com/vatesfr/xo-web/issues/1615)
- xo-cli --list-objects: truncated output ? 64k buffer limitation ? [\#1356](https://github.com/vatesfr/xo-web/issues/1356)
## **5.3.0** (2016-10-20)

View File

@@ -1,4 +1,4 @@
# Xen Orchestra Web [![Build Status](https://travis-ci.org/vatesfr/xo-web.png?branch=master)](https://travis-ci.org/vatesfr/xo-web)
# Xen Orchestra Web [![Chat with us](https://storage.crisp.im/plugins/images/936925df-f37b-4ba8-bab0-70cd2edcb0be/badge.svg)](https://go.crisp.im/chat/embed/?website_id=-JzqzzwddSV7bKGtEyAQ) [![Build Status](https://travis-ci.org/vatesfr/xo-web.png?branch=master)](https://travis-ci.org/vatesfr/xo-web)
![](http://i.imgur.com/tRffA5y.png)

View File

@@ -162,7 +162,7 @@ function browserify (path, opts) {
var bundler = require('browserify')(path, {
basedir: SRC_DIR,
debug: DEVELOPMENT, // TODO: enable also in production but need to make it work with gulp-uglify.
debug: true,
extensions: opts.extensions,
fullPaths: false,
paths: SRC_DIR + '/common',
@@ -257,6 +257,7 @@ gulp.task(function buildScripts () {
}]
]
}),
require('gulp-sourcemaps').init({ loadMaps: true }),
PRODUCTION && require('gulp-uglify')(),
dest()
)

View File

@@ -1,7 +1,7 @@
{
"private": false,
"name": "xo-web",
"version": "5.3.1",
"version": "5.7.1",
"license": "AGPL-3.0",
"description": "Web interface client for Xen-Orchestra",
"keywords": [
@@ -33,8 +33,9 @@
"devDependencies": {
"ansi_up": "^1.3.0",
"asap": "^2.0.4",
"ava": "^0.16.0",
"babel-eslint": "^7.0.0",
"babel-plugin-dev": "^1.0.0",
"babel-plugin-lodash": "^3.2.11",
"babel-plugin-transform-decorators-legacy": "^1.3.4",
"babel-plugin-transform-react-constant-elements": "^6.5.0",
"babel-plugin-transform-react-inline-elements": "^6.6.5",
@@ -48,67 +49,73 @@
"babel-runtime": "^6.6.1",
"babelify": "^7.2.0",
"benchmark": "^2.1.0",
"bootstrap": "github:twbs/bootstrap#v4-dev",
"browserify": "^13.0.0",
"bootstrap": "4.0.0-alpha.5",
"browserify": "^14.1.0",
"bundle-collapser": "^1.2.1",
"chartist": "^0.9.4",
"chartist-plugin-legend": "^0.5.0",
"chartist": "^0.10.1",
"chartist-plugin-legend": "^0.6.1",
"chartist-plugin-tooltip": "0.0.11",
"classnames": "^2.2.3",
"cookies-js": "^1.2.2",
"d3": "^4.2.8",
"dependency-check": "^2.5.1",
"enzyme": "^2.6.0",
"enzyme-to-json": "^1.4.4",
"event-to-promise": "^0.7.0",
"font-awesome": "^4.5.0",
"font-awesome": "^4.7.0",
"font-mfizz": "github:fizzed/font-mfizz",
"get-stream": "^2.3.0",
"ghooks": "^1.1.1",
"globby": "^6.0.0",
"gulp": "github:gulpjs/gulp#4.0",
"gulp-autoprefixer": "^3.1.0",
"gulp-csso": "^2.0.0",
"gulp-csso": "^3.0.0",
"gulp-embedlr": "^0.5.2",
"gulp-plumber": "^1.1.0",
"gulp-pug": "^3.1.0",
"gulp-refresh": "^1.1.0",
"gulp-sass": "^2.2.0",
"gulp-sass": "^3.0.0",
"gulp-sourcemaps": "^2.2.3",
"gulp-uglify": "^2.0.0",
"gulp-watch": "^4.3.5",
"human-format": "^0.6.0",
"index-modules": "0.0.0",
"human-format": "^0.7.0",
"husky": "^0.13.1",
"index-modules": "^0.3.0",
"is-ip": "^1.0.0",
"jsonrpc-websocket-client": "0.0.1-5",
"jest": "^19.0.2",
"jsonrpc-websocket-client": "^0.1.1",
"kindof": "^2.0.0",
"later": "^1.2.0",
"lodash": "^4.6.1",
"loose-envify": "^1.1.0",
"make-error": "^1.2.1",
"marked": "^0.3.5",
"modular-css": "^0.28.0",
"modular-css": "^4.1.1",
"moment": "^2.13.0",
"moment-timezone": "^0.5.4",
"notifyjs": "^2.0.1",
"notifyjs": "^3.0.0",
"novnc-node": "^0.5.3",
"promise-toolbox": "^0.7.0",
"promise-toolbox": "^0.8.0",
"random-password": "^0.1.2",
"react": "^15.0.0",
"react": "^15.4.1",
"react-addons-shallow-compare": "^15.1.0",
"react-addons-test-utils": "^15.4.1",
"react-bootstrap-4": "^0.29.1",
"react-chartist": "^0.10.1",
"react-chartist": "^0.12.0",
"react-copy-to-clipboard": "^4.0.2",
"react-debounce-input": "^2.4.0",
"react-dnd": "^2.1.4",
"react-dnd-html5-backend": "^2.1.2",
"react-document-title": "^2.0.2",
"react-dom": "^15.0.0",
"react-dom": "^15.4.1",
"react-dropzone": "^3.5.0",
"react-intl": "^2.0.1",
"react-key-handler": "^0.3.0",
"react-notify": "^2.0.1",
"react-overlays": "^0.6.0",
"react-redux": "^4.4.0",
"react-redux": "^5.0.0",
"react-router": "^3.0.0",
"react-select": "^1.0.0-beta13",
"react-shortcuts": "^1.0.7",
"react-select": "^1.0.0-rc.3",
"react-shortcuts": "^1.3.1",
"react-sparklines": "^1.5.0",
"react-virtualized": "^8.0.8",
"readable-stream": "^2.0.6",
@@ -118,13 +125,17 @@
"redux-devtools-log-monitor": "^1.0.5",
"redux-thunk": "^2.0.1",
"reselect": "^2.2.1",
"semver": "^5.3.0",
"standard": "^8.4.0",
"superagent": "^2.0.0",
"styled-components": "^1.4.4",
"superagent": "^3.5.0",
"tar-stream": "^1.5.2",
"uncontrollable-input": "^0.0.0",
"vinyl": "^2.0.0",
"watchify": "^3.7.0",
"xml2js": "^0.4.17",
"xo-acl-resolver": "^0.2.2",
"xo-acl-resolver": "^0.2.3",
"xo-common": "0.1.0",
"xo-lib": "^0.8.0",
"xo-remote-parser": "^0.3"
},
@@ -132,12 +143,13 @@
"benchmarks": "./tools/run-benchmarks.js 'src/**/*.bench.js'",
"build": "npm run build-indexes && NODE_ENV=production gulp build",
"build-indexes": "index-modules --auto src",
"commitmsg": "npm test",
"dev": "npm run build-indexes && NODE_ENV=development gulp build",
"dev-test": "ava --watch",
"dev-test": "jest --watch",
"lint": "standard",
"posttest": "npm run lint",
"prepublish": "npm run build",
"test": "ava"
"test": "jest"
},
"browserify": {
"transform": [
@@ -145,15 +157,6 @@
"loose-envify"
]
},
"ava": {
"babel": "inherit",
"files": [
"src/**/*.spec.js"
],
"require": [
"babel-register"
]
},
"babel": {
"env": {
"development": {
@@ -170,6 +173,8 @@
}
},
"plugins": [
"dev",
"lodash",
"transform-decorators-legacy",
"transform-runtime"
],
@@ -179,12 +184,15 @@
"stage-0"
]
},
"config": {
"ghooks": {
"commit-msg": "npm test"
}
"jest": {
"snapshotSerializers": [
"enzyme-to-json/serializer"
]
},
"standard": {
"globals": [
"__DEV__"
],
"ignore": [
"dist"
],

View File

@@ -27,6 +27,13 @@ $ct-series-colors: (
flex-direction: column-reverse;
}
// safari has a bug in flex computing that prevent charts from showing see #1755
// by fixing the height with a value found in Chrome it seems like it fixes the issue without breaking the layout
// elsewhere
.dashboardItem .ct-chart {
height: 150px;
}
// Line in charts with only 2px in width
.ct-line {
stroke-width: 2px;

View File

@@ -0,0 +1,19 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Col 1`] = `
<div
className="col-xs-12"
/>
`;
exports[`Container 1`] = `
<div
className="container-fluid"
/>
`;
exports[`Row 1`] = `
<div
className=" row"
/>
`;

View File

@@ -2,7 +2,6 @@ import _ from 'intl'
import ActionButton from 'action-button'
import map from 'lodash/map'
import React from 'react'
import Tooltip from 'tooltip'
import {
ButtonGroup
} from 'react-bootstrap-4/lib'
@@ -18,17 +17,16 @@ const ActionBar = ({ actions, param }) => (
}
const { handler, handlerParam = param, label, icon, redirectOnSuccess } = button
return <Tooltip key={index} content={_(label)}>
<ActionButton
key={index}
btnStyle='secondary'
handler={handler || noop}
handlerParam={handlerParam}
icon={icon}
redirectOnSuccess={redirectOnSuccess}
size='large'
/>
</Tooltip>
return <ActionButton
key={index}
btnStyle='secondary'
handler={handler || noop}
handlerParam={handlerParam}
icon={icon}
redirectOnSuccess={redirectOnSuccess}
size='large'
tooltip={_(label)}
/>
})}
</ButtonGroup>
)

View File

@@ -7,6 +7,7 @@ import Component from './base-component'
import logError from './log-error'
import propTypes from './prop-types'
import Tooltip from './tooltip'
import { error as _error } from './notification'
@propTypes({
btnStyle: propTypes.string,
@@ -36,8 +37,10 @@ export default class ActionButton extends Component {
}
const {
children,
handler,
handlerParam
handlerParam,
tooltip
} = this.props
try {
@@ -68,6 +71,7 @@ export default class ActionButton extends Component {
// ignore when undefined because it usually means that the action has been canceled
if (error !== undefined) {
logError(error)
_error(children || tooltip || error.name, error.message || String(error))
}
}
}

View File

@@ -3,11 +3,9 @@ import includes from 'lodash/includes'
import isArray from 'lodash/isArray'
import forEach from 'lodash/forEach'
import map from 'lodash/map'
import { Component } from 'react'
import { PureComponent } from 'react'
import getEventValue from './get-event-value'
import invoke from './invoke'
import shallowEqual from './shallow-equal'
// Should components logs every renders?
//
@@ -36,7 +34,7 @@ const get = (object, path, depth) => {
: get(object[prop], path, depth)
}
export default class BaseComponent extends Component {
export default class BaseComponent extends PureComponent {
constructor (props, context) {
super(props, context)
@@ -46,11 +44,11 @@ export default class BaseComponent extends Component {
this._linkedState = null
if (VERBOSE) {
this.render = invoke(this.render, render => () => {
this.render = (render => () => {
console.log('render', this.constructor.name)
return render.call(this)
})
})(this.render)
}
}
@@ -112,13 +110,6 @@ export default class BaseComponent extends Component {
})
})
}
shouldComponentUpdate (newProps, newState) {
return !(
shallowEqual(this.props, newProps) &&
shallowEqual(this.state, newState)
)
}
}
if (VERBOSE) {

View File

@@ -7,9 +7,14 @@ import propTypes from './prop-types'
@propTypes({
children: propTypes.any.isRequired,
className: propTypes.string,
buttonText: propTypes.any.isRequired
buttonText: propTypes.any.isRequired,
defaultOpen: propTypes.bool
})
export default class Collapse extends Component {
state = {
isOpened: this.props.defaultOpen
}
_onClick = () => {
this.setState({
isOpened: !this.state.isOpened

View File

@@ -15,6 +15,8 @@ import styles from './index.css'
@propTypes({
defaultValue: propTypes.any,
disabled: propTypes.bool,
max: propTypes.number,
min: propTypes.number,
options: propTypes.oneOfType([
propTypes.arrayOf(propTypes.string),
propTypes.number,
@@ -62,6 +64,8 @@ export default class Combobox extends Component {
className='form-control'
defaultValue={props.defaultValue}
disabled={props.disabled}
max={props.max}
min={props.min}
options={options}
onChange={this._handleChange}
placeholder={props.placeholder}

View File

@@ -3,10 +3,11 @@ import {
createOr,
createNot,
createProperty,
createString
createString,
createTruthyProperty
} from './'
export const pattern = 'foo !"\\\\ \\"" name:|(wonderwoman batman)'
export const pattern = 'foo !"\\\\ \\"" name:|(wonderwoman batman) hasCape?'
export const ast = createAnd([
createString('foo'),
@@ -14,5 +15,6 @@ export const ast = createAnd([
createProperty('name', createOr([
createString('wonderwoman'),
createString('batman')
]))
])),
createTruthyProperty('hasCape')
])

View File

@@ -56,19 +56,22 @@ export const createProperty = (name, child) => ({ type: 'property', name, child
export const createString = value => ({ type: 'string', value })
export const createTruthyProperty = name => ({ type: 'truthyProperty', name })
// -------------------------------------------------------------------
// *and = terms
// terms = term+
// term = ws (groupedAnd | or | not | property | string) ws
// ws = ' '*
// groupedAnd = "(" and ")"
// *or = "|" ws "(" terms ")"
// *not = "!" term
// *property = string ws ":" term
// *string = quotedString | rawString
// quotedString = "\"" ( /[^"\]/ | "\\\\" | "\\\"" )+
// rawString = /[a-z0-9-_.]+/i
// *and = terms
// terms = term+
// term = ws (groupedAnd | or | not | property | truthyProperty | string) ws
// ws = ' '*
// groupedAnd = "(" and ")"
// *or = "|" ws "(" terms ")"
// *not = "!" term
// *property = string ws ":" term
// *truthyProperty = string ws "?"
// *string = quotedString | rawString
// quotedString = "\"" ( /[^"\]/ | "\\\\" | "\\\"" )+
// rawString = /[a-z0-9-_.]+/i
export const parse = invoke(() => {
let i
let n
@@ -108,6 +111,7 @@ export const parse = invoke(() => {
parseOr() ||
parseNot() ||
parseProperty() ||
parseTruthyProperty() ||
parseString()
)
if (child) {
@@ -203,6 +207,16 @@ export const parse = invoke(() => {
return value
}
}
const parseTruthyProperty = backtrace(() => {
let name
if (
(name = parseString()) &&
parseWs() &&
input[i++] === '?'
) {
return createTruthyProperty(name.value)
}
})
return input_ => {
if (!input_) {
@@ -341,6 +355,7 @@ export const execute = invoke(() => {
property: ({ name, child }, value) => (
value != null && child::execute(value[name])
),
truthyProperty: ({ name }, value) => !!value[name],
string: invoke(() => {
const match = (pattern, value) => {
if (isString(value)) {
@@ -378,7 +393,8 @@ export const toString = invoke(() => {
property: ({ name, child }) => `${toString(createString(name))}:${toString(child)}`,
string: ({ value }) => isRawString(value)
? value
: `"${value.replace(/\\|"/g, match => `\\${match}`)}"`
: `"${value.replace(/\\|"/g, match => `\\${match}`)}"`,
truthyProperty: ({ name }) => `${toString(createString(name))}?`
}
const toString = node => visitors[node.type](node)

View File

@@ -1,4 +1,4 @@
import test from 'ava'
/* eslint-env jest */
import {
getPropertyClausesStrings,
@@ -11,43 +11,36 @@ import {
pattern
} from './index.fixtures'
test('getPropertyClausesStrings', t => {
let tmp = parse('foo bar:baz baz:|(foo bar)')::getPropertyClausesStrings()
t.deepEqual(
tmp,
{
bar: [ 'baz' ],
baz: [ 'foo', 'bar' ]
}
)
it('getPropertyClausesStrings', () => {
const tmp = parse('foo bar:baz baz:|(foo bar)')::getPropertyClausesStrings()
expect(tmp).toEqual({
bar: [ 'baz' ],
baz: [ 'foo', 'bar' ]
})
})
test('parse', t => {
t.deepEqual(parse(pattern), ast)
it('parse', () => {
expect(parse(pattern)).toEqual(ast)
})
test('setPropertyClause', t => {
t.is(
null::setPropertyClause('foo', 'bar')::toString(),
'foo:bar'
)
it('setPropertyClause', () => {
expect(
null::setPropertyClause('foo', 'bar')::toString()
).toBe('foo:bar')
t.is(
parse('baz')::setPropertyClause('foo', 'bar')::toString(),
'baz foo:bar'
)
expect(
parse('baz')::setPropertyClause('foo', 'bar')::toString()
).toBe('baz foo:bar')
t.is(
parse('plip foo:baz plop')::setPropertyClause('foo', 'bar')::toString(),
'plip plop foo:bar'
)
expect(
parse('plip foo:baz plop')::setPropertyClause('foo', 'bar')::toString()
).toBe('plip plop foo:bar')
t.is(
parse('foo:|(baz plop)')::setPropertyClause('foo', 'bar')::toString(),
'foo:bar'
)
expect(
parse('foo:|(baz plop)')::setPropertyClause('foo', 'bar')::toString()
).toBe('foo:bar')
})
test('toString', t => {
t.is(pattern, ast::toString())
it('toString', () => {
expect(pattern).toBe(ast::toString())
})

View File

@@ -1,7 +1,9 @@
import React, { Component, PropTypes } from 'react'
import { isPromise } from 'promise-toolbox'
const toString = value => JSON.stringify(value, null, 2)
const toString = value => value === undefined
? 'undefined'
: JSON.stringify(value, null, 2)
// This component does not handle changes in its `promise` property.
class DebugAsync extends Component {

View File

@@ -394,11 +394,11 @@ const MAP_TYPE_SELECT = {
value: propTypes.oneOfType([
propTypes.string,
propTypes.object
]).isRequired
])
})
export class XoSelect extends Editable {
get value () {
return this.refs.select.value
return this.state.value
}
_renderDisplay () {
@@ -406,9 +406,8 @@ export class XoSelect extends Editable {
<span>{this.props.value[this.props.labelProp]}</span>
}
_onChange = object => {
object ? this._save() : this._closeEdition()
}
_onChange = object =>
this.setState({ value: object }, object && this._save)
_renderEdition () {
const {
@@ -432,7 +431,6 @@ export class XoSelect extends Editable {
autoFocus
disabled={saving}
onChange={this._onChange}
ref='select'
/>
</a>
}

View File

@@ -1,4 +1,4 @@
import test from 'ava'
/* eslint-env jest */
import filterReduce from './filter-reduce'
@@ -6,23 +6,20 @@ const add = (a, b) => a + b
const data = [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ]
const isEven = x => !(x & 1)
test('filterReduce', t => {
it('filterReduce', () => {
// Returns all elements not matching the predicate and the result of
// a reduction over those who do.
t.deepEqual(
filterReduce(data, isEven, add),
expect(filterReduce(data, isEven, add)).toEqual(
[ 1, 3, 5, 7, 9, 20 ]
)
// The default reducer is the identity.
t.deepEqual(
filterReduce(data, isEven),
expect(filterReduce(data, isEven)).toEqual(
[ 1, 3, 5, 7, 9, 0 ]
)
// If an initial value is passed it is used.
t.deepEqual(
filterReduce(data, isEven, add, 22),
expect(filterReduce(data, isEven, add, 22)).toEqual(
[ 1, 3, 5, 7, 9, 42 ]
)
})

View File

@@ -5,12 +5,15 @@ import map from 'lodash/map'
import randomPassword from 'random-password'
import React from 'react'
import round from 'lodash/round'
import SingleLineRow from 'single-line-row'
import { Container, Col } from 'grid'
import {
DropdownButton,
MenuItem
} from 'react-bootstrap-4/lib'
import Component from '../base-component'
import getEventValue from '../get-event-value'
import propTypes from '../prop-types'
import {
firstDefined,
@@ -89,68 +92,45 @@ export class Password extends Component {
// ===================================================================
@propTypes({
defaultValue: propTypes.number,
max: propTypes.number.isRequired,
min: propTypes.number.isRequired,
onChange: propTypes.func,
step: propTypes.number,
onChange: propTypes.func
value: propTypes.number
})
export class Range extends Component {
constructor (props) {
super()
this.state = {
value: props.defaultValue || props.min
componentDidMount () {
const { min, onChange, value } = this.props
if (!value) {
onChange && onChange(min)
}
}
get value () {
return this.state.value
}
set value (value) {
this.setState({
value: +value
})
}
_handleChange = event => {
const { onChange } = this.props
const { value } = event.target
if (value === this.state.value) {
return
}
this.setState({
value
}, onChange && (() => onChange(value)))
}
_onChange = value =>
this.props.onChange(getEventValue(value))
render () {
const {
props
} = this
const step = props.step || 1
const { value } = this.state
const { max, min, step, value } = this.props
return (
<div className='form-group row'>
<label className='col-sm-2 control-label'>
{value}
</label>
<div className='col-sm-10'>
return <Container>
<SingleLineRow>
<Col size={2}>
<span className='pull-right'>{value}</span>
</Col>
<Col size={10}>
<input
className='form-control'
type='range'
min={props.min}
max={props.max}
max={max}
min={min}
onChange={this._onChange}
step={step}
type='range'
value={value}
onChange={this._handleChange}
/>
</div>
</div>
)
</Col>
</SingleLineRow>
</Container>
}
}

View File

@@ -1,6 +1,8 @@
import uncontrollableInput from 'uncontrollable-input'
import Component from 'base-component'
import find from 'lodash/find'
import map from 'lodash/map'
import React, { Component } from 'react'
import React from 'react'
import propTypes from '../prop-types'
@@ -8,7 +10,6 @@ import Select from './select'
@propTypes({
autoFocus: propTypes.bool,
defaultValue: propTypes.any,
disabled: propTypes.bool,
optionRenderer: propTypes.func,
multi: propTypes.bool,
@@ -16,13 +17,26 @@ import Select from './select'
options: propTypes.array,
placeholder: propTypes.string,
predicate: propTypes.func,
required: propTypes.bool
required: propTypes.bool,
value: propTypes.any
})
@uncontrollableInput()
export default class SelectPlainObject extends Component {
constructor (props) {
super(props)
this.state = {
value: this._computeValue(props.defaultValue, props)
componentDidMount () {
const { options, value } = this.props
this.setState({
options: this._computeOptions(options),
value: this._computeValue(value, this.props)
})
}
componentWillReceiveProps (newProps) {
if (newProps !== this.props) {
this.setState({
options: this._computeOptions(newProps.options),
value: this._computeValue(newProps.value, newProps)
})
}
}
@@ -36,25 +50,10 @@ export default class SelectPlainObject extends Component {
}
return map(value, reduceValue)
}
return reduceValue(value)
}
componentWillMount () {
const { options } = this.props
this.setState({
options: this._computeOptions(options)
})
}
componentWillReceiveProps (newProps) {
const { options } = newProps
this.setState({
options: this._computeOptions(options)
})
}
_computeOptions (options) {
const { optionKey = 'id' } = this.props
const { optionRenderer = o => o.label || o[optionKey] || o } = this.props
@@ -64,10 +63,13 @@ export default class SelectPlainObject extends Component {
}))
}
get value () {
const { optionKey = 'id' } = this.props
const { value } = this.state
const { options } = this.props
_getObject (value) {
if (value == null) {
return undefined
}
const { optionKey = 'id', options } = this.props
const pickValue = value => {
value = value.value || value
return find(options, option => option[optionKey] === value || option === value)
@@ -80,18 +82,12 @@ export default class SelectPlainObject extends Component {
return pickValue(value)
}
set value (value) {
this.setState({
value: this._computeValue(value)
})
}
_handleChange = value => {
const { onChange } = this.props
this.setState({
value: this._computeValue(value)
}, onChange && (() => { onChange(this.value) }))
if (onChange) {
onChange(this._getObject(value))
}
}
_renderOption = option => option.label
@@ -111,7 +107,8 @@ export default class SelectPlainObject extends Component {
placeholder={props.placeholder}
required={props.required}
value={state.value}
valueRenderer={this._renderOption} />
valueRenderer={this._renderOption}
/>
)
}
}

View File

@@ -18,6 +18,10 @@ const SELECT_STYLE = {
minWidth: '10em'
}
const LIST_STYLE = {
whiteSpace: 'normal'
}
const MAX_OPTIONS = 5
// See: https://github.com/bvaughn/react-virtualized-select/blob/master/source/VirtualizedSelect/VirtualizedSelect.js
@@ -75,6 +79,7 @@ export default class Select extends Component {
rowHeight={getRowHeight}
rowRenderer={wrappedRowRenderer}
scrollToIndex={focusedOptionIndex}
style={LIST_STYLE}
width={width}
/>
}}

View File

@@ -1,5 +1,6 @@
import React from 'react'
import classNames from 'classnames'
import uncontrollableInput from 'uncontrollable-input'
import Component from '../../base-component'
import Icon from '../../icon'
@@ -7,9 +8,9 @@ import propTypes from '../../prop-types'
import styles from './index.css'
@uncontrollableInput()
@propTypes({
className: propTypes.string,
defaultValue: propTypes.bool,
onChange: propTypes.func,
icon: propTypes.string,
iconOn: propTypes.string,
@@ -24,64 +25,25 @@ export default class Toggle extends Component {
iconSize: 2
}
get value () {
const { props } = this
const { value } = props
if (value != null) {
return value
}
const { input } = this.refs
if (input) {
return input.checked
}
return props.defaultValue || false
}
set value (value) {
if (
process.env.NODE_ENV !== 'production' &&
this.props.value != null
) {
throw new Error('cannot set value of controlled Toggle')
}
this.refs.input.checked = Boolean(value)
this.forceUpdate()
}
_onChange = event => {
if (this.props.value == null) {
this.forceUpdate()
}
const { onChange } = this.props
onChange && onChange(event.target.checked)
}
render () {
const { props, value } = this
const { props } = this
return (
<label
className={classNames(
props.disabled ? 'text-muted' : value ? 'text-success' : null,
props.disabled ? 'text-muted' : props.value ? 'text-success' : null,
props.className
)}
>
<Icon
icon={props.icon || (value ? props.iconOn : props.iconOff)}
icon={props.icon || (props.value ? props.iconOn : props.iconOff)}
size={props.iconSize}
/>
<input
checked={props.value}
checked={props.value || false}
className={styles.checkbox}
defaultChecked={props.defaultValue}
disabled={props.disabled}
onChange={this._onChange}
ref='input'
onChange={props.onChange}
type='checkbox'
/>
</label>

13
src/common/grid.spec.js Normal file
View File

@@ -0,0 +1,13 @@
/* eslint-env jest */
import React from 'react'
import { forEach } from 'lodash'
import { shallow } from 'enzyme'
import * as grid from './grid'
forEach(grid, (Component, name) => {
it(name, () => {
expect(shallow(<Component />)).toMatchSnapshot()
})
})

View File

@@ -1,4 +1,9 @@
const common = {
homeFilterNone: ''
}
export const VM = {
...common,
homeFilterPendingVms: 'current_operations:"" ',
homeFilterNonRunningVms: '!power_state:running ',
homeFilterHvmGuests: 'virtualizationMode:hvm ',
@@ -7,14 +12,22 @@ export const VM = {
}
export const host = {
...common,
homeFilterRunningHosts: 'power_state:running ',
homeFilterTags: 'tags:'
}
export const pool = {
...common,
homeFilterTags: 'tags:'
}
export const vmTemplate = {
...common,
homeFilterTags: 'tags:'
}
export const SR = {
...common,
homeFilterTags: 'tags:'
}

36
src/common/home-tags.js Normal file
View File

@@ -0,0 +1,36 @@
import React from 'react'
import Component from './base-component'
import propTypes from './prop-types'
import Tags from './tags'
import { createString, createProperty, toString } from './complex-matcher'
@propTypes({
labels: propTypes.arrayOf(React.PropTypes.string).isRequired,
onAdd: propTypes.func,
onChange: propTypes.func,
onDelete: propTypes.func,
type: propTypes.string
})
export default class HomeTags extends Component {
static contextTypes = {
router: React.PropTypes.object
}
_onClick = label => {
const s = encodeURIComponent(createProperty('tags', createString(label))::toString())
const t = encodeURIComponent(this.props.type)
this.context.router.push(`/home?t=${t}&s=${s}`)
}
render () {
return <Tags
labels={this.props.labels}
onAdd={this.props.onAdd}
onChange={this.props.onChange}
onClick={this._onClick}
onDelete={this.props.onDelete}
/>
}
}

View File

@@ -62,7 +62,13 @@ export class IntlProvider extends Component {
render () {
const { lang, children } = this.props
// Adding a key prop is a work-around suggested by react-intl documentation
// to make sure changes to the locale trigger a re-render of the child components
// https://github.com/yahoo/react-intl/wiki/Components#dynamic-language-selection
//
// FIXME: remove the key prop when React context propagation is fixed (https://github.com/facebook/react/issues/2517)
return <IntlProvider_
key={lang}
locale={lang}
messages={locales[lang]}
>

View File

@@ -1 +0,0 @@
/index.js

View File

@@ -1172,7 +1172,7 @@ export default {
// Original text: "Reboot"
rebootHostLabel: 'Reiniciar',
// Original text: 'Reboot for applying updates'
// Original text: 'Reboot to apply updates'
rebootUpdateHostLabel: undefined,
// Original text: "Emergency mode"

File diff suppressed because it is too large Load Diff

View File

@@ -1172,7 +1172,7 @@ export default {
// Original text: 'Reboot'
rebootHostLabel: undefined,
// Original text: 'Reboot for applying updates'
// Original text: 'Reboot to apply updates'
rebootUpdateHostLabel: undefined,
// Original text: 'Emergency mode'

File diff suppressed because it is too large Load Diff

View File

@@ -1172,7 +1172,7 @@ export default {
// Original text: "Reboot"
rebootHostLabel: 'Reinicializar',
// Original text: 'Reboot for applying updates'
// Original text: 'Reboot to apply updates'
rebootUpdateHostLabel: undefined,
// Original text: "Emergency mode"

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@ var messages = {
editableLongClickPlaceholder: 'Long click to edit',
editableClickPlaceholder: 'Click to edit',
browseFiles: 'Browse files',
// ----- Modals -----
alertOk: 'OK',
@@ -35,6 +36,7 @@ var messages = {
homeHostPage: 'Hosts',
homePoolPage: 'Pools',
homeTemplatePage: 'Templates',
homeSrPage: 'Storages',
dashboardPage: 'Dashboard',
overviewDashboardPage: 'Overview',
overviewVisualizationDashboardPage: 'Visualizations',
@@ -54,6 +56,7 @@ var messages = {
settingsIpsPage: 'IPs',
settingsConfigPage: 'Config',
aboutPage: 'About',
aboutXoaPlan: 'About XO {xoaPlan}',
newMenu: 'New',
taskMenu: 'Tasks',
taskPage: 'Tasks',
@@ -61,10 +64,12 @@ var messages = {
newSrPage: 'Storage',
newServerPage: 'Server',
newImport: 'Import',
xosan: 'XOSAN',
backupOverviewPage: 'Overview',
backupNewPage: 'New',
backupRemotesPage: 'Remotes',
backupRestorePage: 'Restore',
backupFileRestorePage: 'File restore',
schedule: 'Schedule',
newVmBackup: 'New VM backup',
editVmBackup: 'Edit VM backup',
@@ -93,8 +98,10 @@ var messages = {
homeFetchingData: 'Fetching data…',
homeWelcome: 'Welcome on Xen Orchestra!',
homeWelcomeText: 'Add your XenServer hosts or pools',
homeConnectServerText: 'Some XenServers have been registered but are not connected',
homeHelp: 'Want some help?',
homeAddServer: 'Add server',
homeConnectServer: 'Connect servers',
homeOnlineDoc: 'Online Doc',
homeProSupport: 'Pro Support',
homeNoVms: 'There are no VMs!',
@@ -116,6 +123,7 @@ var messages = {
homeAllHosts: 'Hosts',
homeAllTags: 'Tags',
homeNewVm: 'New VM',
homeFilterNone: 'None',
homeFilterRunningHosts: 'Running hosts',
homeFilterDisabledHosts: 'Disabled hosts',
homeFilterRunningVms: 'Running VMs',
@@ -129,16 +137,24 @@ var messages = {
homeSortByRAM: 'RAM',
homeSortByvCPUs: 'vCPUs',
homeSortByCpus: 'CPUs',
homeSortByShared: 'Shared/Not shared',
homeSortBySize: 'Size',
homeSortByUsage: 'Usage',
homeSortByType: 'Type',
homeDisplayedItems: '{displayed, number}x {icon} (on {total, number})',
homeSelectedItems: '{selected, number}x {icon} selected (on {total, number})',
homeMore: 'More',
homeMigrateTo: 'Migrate to…',
homeMissingPaths: 'Missing patches',
homePoolMaster: 'Master:',
homeResourceSet: 'Resource set: {resourceSet}',
highAvailability: 'High Availability',
srSharedType: 'Shared {type}',
srNotSharedType: 'Not shared {type}',
// ----- Forms -----
add: 'Add',
selectAll: 'Select all',
remove: 'Remove',
preview: 'Preview',
item: 'Item',
@@ -173,28 +189,34 @@ var messages = {
// --- Dates/Scheduler ---
schedulingMonth: 'Month',
schedulingEveryNMonth: 'Every N month',
schedulingEachSelectedMonth: 'Each selected month',
schedulingMonthDay: 'Day of the month',
schedulingEachSelectedMonthDay: 'Each selected day',
schedulingWeekDay: 'Day of the week',
schedulingEachSelectedWeekDay: 'Each selected day',
schedulingDay: 'Day',
schedulingEveryNDay: 'Every N day',
schedulingEachSelectedDay: 'Each selected day',
schedulingSetWeekDayMode: 'Switch to week days',
schedulingSetMonthDayMode: 'Switch to month days',
schedulingHour: 'Hour',
schedulingEveryNHour: 'Every N hour',
schedulingEachSelectedHour: 'Each selected hour',
schedulingEveryNHour: 'Every N hour',
schedulingMinute: 'Minute',
schedulingEveryNMinute: 'Every N minute',
schedulingEachSelectedMinute: 'Each selected minute',
schedulingEveryNMinute: 'Every N minute',
selectTableAllMonth: 'Every month',
selectTableAllDay: 'Every day',
selectTableAllHour: 'Every hour',
selectTableAllMinute: 'Every 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',
jobId: 'Job ID',
jobModalTitle: 'Job {job}',
jobId: 'ID',
jobType: 'Type',
jobName: 'Name',
jobNamePlaceholder: 'Name of your job (forbidden: "_")',
jobStart: 'Start',
@@ -205,8 +227,10 @@ var messages = {
jobTag: 'Tag',
jobScheduling: 'Scheduling',
jobState: 'State',
jobStateEnabled: 'Enabled',
jobStateDisabled: 'Disabled',
jobTimezone: 'Timezone',
jobServerTimezone: 'xo-server',
jobServerTimezone: 'Server',
runJob: 'Run job',
runJobVerbose: 'One shot running started. See overview for logs.',
jobStarted: 'Started',
@@ -221,9 +245,14 @@ var messages = {
noJobs: 'No jobs found.',
noSchedules: 'No schedules found',
jobActionPlaceHolder: 'Select a xo-server API command',
jobTimeoutPlaceHolder: ' Job timeout (seconds)',
jobSchedules: 'Schedules',
jobScheduleNamePlaceHolder: 'Name of your schedule',
jobScheduleJobPlaceHolder: 'Select a Job',
jobOwnerPlaceholder: 'Job owner',
jobUserNotFound: 'This job\'s creator no longer exists',
backupUserNotFound: 'This backup\'s creator no longer exists',
backupOwner: 'Backup owner',
// ------ New backup -----
newBackupSelection: 'Select your backup type:',
@@ -232,10 +261,14 @@ var messages = {
smartBackup: 'Smart backup',
localRemoteWarningTitle: 'Local remote selected',
localRemoteWarningMessage: 'Warning: local remotes will use limited XOA disk space. Only for advanced users.',
backupVersionWarning: 'Warning: this feature works only with XenServer 6.5 or newer.',
editBackupVmsTitle: 'VMs',
editBackupSmartStatusTitle: 'VMs statuses',
editBackupSmartResidentOn: 'Resident on',
editBackupSmartPools: 'Pools',
editBackupSmartTags: 'Tags',
editBackupSmartTagsTitle: 'VMs Tags',
editBackupNot: 'Reverse',
editBackupTagTitle: 'Tag',
editBackupReportTitle: 'Report',
editBackupReportEnable: 'Enable immediately after creation',
@@ -256,7 +289,10 @@ var messages = {
remoteTestError: 'Error',
remoteTestStep: 'Test Step',
remoteTestFile: 'Test file',
remoteTestName: 'Test name',
remoteTestNameFailure: 'Remote name already exists!',
remoteTestSuccessMessage: 'The remote appears to work correctly',
remoteConnectionFailed: 'Connection failed',
// ------ Remote -----
remoteName: 'Name',
@@ -264,17 +300,20 @@ var messages = {
remoteState: 'State',
remoteDevice: 'Device',
remoteShare: 'Share',
remoteAction: 'Action',
remoteAuth: 'Auth',
remoteMounted: 'Mounted',
remoteUnmounted: 'Unmounted',
remoteConnectTip: 'Connect',
remoteDisconnectTip: 'Disconnect',
remoteConnected: 'Connected',
remoteDisconnected: 'Disconnected',
remoteDeleteTip: 'Delete',
remoteNamePlaceHolder: 'remote name *',
remoteMyNamePlaceHolder: 'Name *',
remoteLocalPlaceHolderPath: '/path/to/backup',
remoteNfsPlaceHolderHost: 'host *',
remoteNfsPlaceHolderPath: '/path/to/backup',
remoteNfsPlaceHolderPath: 'path/to/backup',
remoteSmbPlaceHolderRemotePath: 'subfolder [path\\to\\backup]',
remoteSmbPlaceHolderUsername: 'Username',
remoteSmbPlaceHolderPassword: 'Password',
@@ -347,6 +386,7 @@ var messages = {
selectPermission: 'Select Permission',
// ----- Plugins ------
noPlugins: 'No plugins found',
autoloadPlugin: 'Auto-load at server start',
savePluginConfiguration: 'Save configuration',
deletePluginConfiguration: 'Delete configuration',
@@ -401,16 +441,22 @@ var messages = {
// ----- SR actions -----
srRescan: 'Rescan all disks',
srReconnectAll: 'Connect to all hosts',
srDisconnectAll: 'Disconnect to all hosts',
srDisconnectAll: 'Disconnect from all hosts',
srForget: 'Forget this SR',
srsForget: 'Forget SRs',
srRemoveButton: 'Remove this SR',
srNoVdis: 'No VDIs in this storage',
// ----- Pool general -----
poolTitleRamUsage: 'Pool RAM usage:',
poolRamUsage: '{used} used on {total}',
poolMaster: 'Master:',
displayAllHosts: 'Display all hosts of this pool',
displayAllStorages: 'Display all storages of this pool',
displayAllVMs: 'Display all VMs of this pool',
// ----- Pool tabs -----
hostsTabName: 'Hosts',
vmsTabName: 'Vms',
srsTabName: 'Srs',
// ----- Pool advanced tab -----
poolHaStatus: 'High Availability',
poolHaEnabled: 'Enabled',
@@ -422,6 +468,7 @@ var messages = {
noHost: 'No hosts',
memoryLeftTooltip: '{used}% used ({free} free)',
// ----- Pool network tab -----
pif: 'PIF',
poolNetworkNameLabel: 'Name',
poolNetworkDescription: 'Description',
poolNetworkPif: 'PIFs',
@@ -431,6 +478,11 @@ var messages = {
poolNetworkPifDetached: 'Disconnected',
showPifs: 'Show PIFs',
hidePifs: 'Hide PIFs',
showDetails: 'Show details',
hideDetails: 'Hide details',
// ----- Pool stats tab -----
poolNoStats: 'No stats',
poolAllHosts: 'All hosts',
// ----- Pool actions ------
addSrLabel: 'Add SR',
addVmLabel: 'Add VM',
@@ -445,7 +497,11 @@ var messages = {
restartHostAgent: 'Restart toolstack',
forceRebootHostLabel: 'Force reboot',
rebootHostLabel: 'Reboot',
rebootUpdateHostLabel: 'Reboot for applying updates',
noHostsAvailableErrorTitle: 'Error while restarting host',
noHostsAvailableErrorMessage: 'Some VMs cannot be migrated before restarting this host. Please try force reboot.',
failHostBulkRestartTitle: 'Error while restarting hosts',
failHostBulkRestartMessage: '{failedHosts}/{totalHosts} host{failedHosts, plural, one {} other {s}} could not be restarted.',
rebootUpdateHostLabel: 'Reboot to apply updates',
emergencyModeLabel: 'Emergency mode',
// ----- Host tabs -----
storageTabName: 'Storage',
@@ -453,6 +509,7 @@ var messages = {
// ----- host stat tab -----
statLoad: 'Load average',
// ----- host advanced tab -----
memoryHostState: 'RAM Usage: {memoryUsed}',
hardwareHostSettingsLabel: 'Hardware',
hostAddress: 'Address',
hostStatus: 'Status',
@@ -472,6 +529,16 @@ var messages = {
hostLicenseType: 'Type',
hostLicenseSocket: 'Socket',
hostLicenseExpiry: 'Expiry',
supplementalPacks: 'Installed supplemental packs',
supplementalPackNew: 'Install new supplemental pack',
supplementalPackPoolNew: 'Install supplemental pack on every host',
supplementalPackTitle: '{name} (by {author})',
supplementalPackInstallStartedTitle: 'Installation started',
supplementalPackInstallStartedMessage: 'Installing new supplemental pack...',
supplementalPackInstallErrorTitle: 'Installation error',
supplementalPackInstallErrorMessage: 'The installation of the supplemental pack failed.',
supplementalPackInstallSuccessTitle: 'Installation success',
supplementalPackInstallSuccessMessage: 'Supplemental pack successfully installed.',
// ----- Host net tabs -----
networkCreateButton: 'Add a network',
networkCreateBondedButton: 'Add a bonded network',
@@ -499,6 +566,7 @@ var messages = {
addSrDeviceButton: 'Add a storage',
srNameLabel: 'Name',
srType: 'Type',
pbdAction: 'Action',
pbdStatus: 'Status',
pbdStatusConnected: 'Connected',
pbdStatusDisconnected: 'Disconnected',
@@ -577,7 +645,7 @@ var messages = {
copyToClipboardLabel: 'Copy',
ctrlAltDelButtonLabel: 'Ctrl+Alt+Del',
tipLabel: 'Tip:',
tipConsoleLabel: 'non-US keyboard could have issues with console: switch your own layout to US.',
tipConsoleLabel: 'Due to a XenServer issue, non-US keyboard layouts aren\'t well supported. Switch your own layout to US to workaround it.',
hideHeaderTooltip: 'Hide infos',
showHeaderTooltip: 'Show infos',
@@ -621,6 +689,7 @@ var messages = {
vbdDisconnect: 'Disconnect VBD',
vdbBootable: 'Bootable',
vdbReadonly: 'Readonly',
vbdAction: 'Action',
vdbCreate: 'Create',
vdbNamePlaceHolder: 'Disk name',
vdbSizePlaceHolder: 'Size',
@@ -648,6 +717,7 @@ var messages = {
vifLockedNetworkNoIps: 'Network locked and no IPs are allowed for this interface',
vifUnLockedNetwork: 'Network not locked',
vifUnknownNetwork: 'Unknown network',
vifAction: 'Action',
vifCreate: 'Create',
// ----- VM snapshot tab -----
@@ -661,6 +731,7 @@ var messages = {
snapshotDate: 'Creation date',
snapshotName: 'Name',
snapshotAction: 'Action',
snapshotQuiesce: 'Quiesced snapshot',
// ----- VM log tab -----
logRemoveAll: 'Remove all logs',
@@ -692,6 +763,8 @@ var messages = {
osKernel: 'OS kernel',
autoPowerOn: 'Auto power on',
ha: 'HA',
vmAffinityHost: 'Affinity host',
noAffinityHost: 'None',
originalTemplate: 'Original template',
unknownOsName: 'Unknown',
unknownOsKernel: 'Unknown',
@@ -721,7 +794,7 @@ var messages = {
poolPanel: 'Pool{pools, plural, one {} other {s}}',
hostPanel: 'Host{hosts, plural, one {} other {s}}',
vmPanel: 'VM{vms, plural, one {} other {s}}',
memoryStatePanel: 'RAM Usage',
memoryStatePanel: 'RAM Usage:',
cpuStatePanel: 'CPUs Usage',
vmStatePanel: 'VMs Power state',
taskStatePanel: 'Pending tasks',
@@ -801,7 +874,6 @@ var messages = {
newVmAddInterface: 'Add interface',
newVmDisksPanel: 'Disks',
newVmSrLabel: 'SR',
newVmBootableLabel: 'Bootable',
newVmSizeLabel: 'Size',
newVmAddDisk: 'Add disk',
newVmSummaryPanel: 'Summary',
@@ -827,9 +899,11 @@ var messages = {
newVmFirstIndex: 'First index:',
newVmNumberRecalculate: 'Recalculate VMs number',
newVmNameRefresh: 'Refresh VMs name',
newVmAffinityHost: 'Affinity host',
newVmAdvancedPanel: 'Advanced',
newVmShowAdvanced: 'Show advanced settings',
newVmHideAdvanced: 'Hide advanced settings',
newVmShare: 'Share this VM',
// ----- Self -----
resourceSets: 'Resource sets',
@@ -912,6 +986,23 @@ var messages = {
importBackupMessage: 'Starting your backup import',
vmsToBackup: 'VMs to backup',
// ----- Restore files view -----
listRemoteBackups: 'List remote backups',
restoreFiles: 'Restore backup files',
restoreFilesError: 'Invalid options',
restoreFilesFromBackup: 'Restore file from {name}',
restoreFilesSelectBackup: 'Select a backup…',
restoreFilesSelectDisk: 'Select a disk…',
restoreFilesSelectPartition: 'Select a partition…',
restoreFilesSelectFolderPath: 'Folder path',
restoreFilesSelectFiles: 'Select a file…',
restoreFileContentNotFound: 'Content not found',
restoreFilesNoFilesSelected: 'No files selected',
restoreFilesSelectedFiles: 'Selected files ({files}):',
restoreFilesDiskError: 'Error while scanning disk',
restoreFilesSelectAllFiles: 'Select all this folder\'s files',
restoreFilesUnselectAll: 'Unselect all files',
// ----- Modals -----
emergencyShutdownHostsModalTitle: 'Emergency shutdown Host{nHosts, plural, one {} other {s}}',
emergencyShutdownHostsModalMessage: 'Are you sure you want to shutdown {nHosts} Host{nHosts, plural, one {} other {s}}?',
@@ -982,6 +1073,7 @@ var messages = {
trialReadyModalText: 'During the trial period, XOA need to have a working internet connection. This limitation does not apply for our paid plans!',
// ----- Servers -----
serverLabel: 'Label',
serverHost: 'Host',
serverUsername: 'Username',
serverPassword: 'Password',
@@ -991,7 +1083,17 @@ var messages = {
serverPlaceHolderUser: 'username',
serverPlaceHolderPassword: 'password',
serverPlaceHolderAddress: 'address[:port]',
serverPlaceHolderLabel: 'label',
serverConnect: 'Connect',
serverError: 'Error',
serverAddFailed: 'Adding server failed',
serverStatus: 'Status',
serverConnectionFailed: 'Connection failed',
serverConnecting: 'Connecting...',
serverConnected: 'Connected',
serverDisconnected: 'Disconnected',
serverAuthFailed: 'Authentication error',
serverUnknownError: 'Unknown error',
// ----- Copy VM -----
copyVm: 'Copy VM',
@@ -1035,11 +1137,11 @@ var messages = {
// ----- About View -----
xenOrchestra: 'Xen Orchestra',
xenOrchestraServer: 'server',
xenOrchestraWeb: 'web client',
xenOrchestraServer: 'Xen Orchestra server',
xenOrchestraWeb: 'Xen Orchestra web client',
noProSupport: 'No pro support provided!',
noProductionUse: 'Use in production at your own risks',
downloadXoa: 'You can download our turnkey appliance at',
downloadXoaFromWebsite: 'You can download our turnkey appliance at {website}',
bugTracker: 'Bug Tracker',
bugTrackerText: 'Issues? Report it!',
community: 'Community',
@@ -1051,7 +1153,7 @@ var messages = {
documentation: 'Documentation',
documentationText: 'Read our official doc',
proSupportIncluded: 'Pro support included',
xoAccount: 'Acces your XO Account',
xoAccount: 'Access your XO Account',
openTicket: 'Report a problem',
openTicketText: 'Problem? Open a ticket!',
@@ -1156,6 +1258,9 @@ var messages = {
logDeleteAll: 'Delete all logs',
logDeleteAllTitle: 'Delete all logs',
logDeleteAllMessage: 'Are you sure you want to delete all the logs?',
logIndicationToEnable: 'Click to enable',
logIndicationToDisable: 'Click to disable',
reportBug: 'Report a bug',
// ----- IPs ------
ipPoolName: 'Name',
@@ -1168,6 +1273,8 @@ var messages = {
ipsDeleteAllMessage: 'Are you sure you want to delete all the IP pools?',
ipsVifs: 'VIFs',
ipsNotUsed: 'Not used',
ipPoolUnknownVif: 'unknown VIF',
ipPoolNameAlreadyExists: 'Name already exists',
// ----- Shortcuts -----
shortcutModalTitle: 'Keyboard shortcuts',
@@ -1175,6 +1282,7 @@ var messages = {
shortcut_GO_TO_HOSTS: 'Go to hosts list',
shortcut_GO_TO_POOLS: 'Go to pools list',
shortcut_GO_TO_VMS: 'Go to VMs list',
shortcut_GO_TO_SRS: 'Go to SRs list',
shortcut_CREATE_VM: 'Create a new VM',
shortcut_UNFOCUS: 'Unfocus field',
shortcut_HELP: 'Show shortcuts key bindings',
@@ -1201,7 +1309,57 @@ var messages = {
importConfigError: 'Error while importing config file',
exportConfig: 'Export',
downloadConfig: 'Download current config',
noConfigImportCommunity: 'No config import available for Community Edition'
noConfigImportCommunity: 'No config import available for Community Edition',
// ----- SR -----
srReconnectAllModalTitle: 'Reconnect all hosts',
srReconnectAllModalMessage: 'This will reconnect this SR to all its hosts.',
srsReconnectAllModalMessage: 'This will reconnect each selected SR to its host (local SR) or to every hosts of its pool (shared SR).',
srDisconnectAllModalTitle: 'Disconnect all hosts',
srDisconnectAllModalMessage: 'This will disconnect this SR from all its hosts.',
srsDisconnectAllModalMessage: 'This will disconnect each selected SR from its host (local SR) or from every hosts of its pool (shared SR).',
srForgetModalTitle: 'Forget SR',
srsForgetModalTitle: 'Forget selected SRs',
srForgetModalMessage: 'Are you sure you want to forget this SR? VDIs on this storage won\'t be removed.',
srsForgetModalMessage: 'Are you sure you want to forget all the selected SRs? VDIs on these storages won\'t be removed.',
srAllDisconnected: 'Disconnected',
srSomeConnected: 'Partially connected',
srAllConnected: 'Connected',
// ----- XOSAN -----
xosanTitle: 'XOSAN',
xosanSrTitle: 'Xen Orchestra SAN SR',
xosanAvailableSrsTitle: 'Select local SRs (lvm)',
xosanSuggestions: 'Suggestions',
xosanName: 'Name',
xosanHost: 'Host',
xosanHosts: 'Hosts',
xosanVolumeId: 'Volume ID',
xosanSize: 'Size',
xosanUsedSpace: 'Used space',
xosanNeedPack: 'XOSAN pack needs to be installed on each host of the pool.',
xosanInstallIt: 'Install it now!',
xosanInstallPackTitle: 'Install XOSAN pack on {pool}',
xosanSelect2Srs: 'Select at least 2 SRs',
xosanLayout: 'Layout',
xosanRedundancy: 'Redundancy',
xosanCapacity: 'Capacity',
xosanAvailableSpace: 'Available space',
xosanDiskLossLegend: '* Can fail without data loss',
xosanCreate: 'Create',
xosanInstalling: 'Installing XOSAN. Please wait...',
xosanCommunity: 'No XOSAN available for Community Edition',
// Pack download modal
xosanInstallCloudPlugin: 'Install cloud plugin first',
xosanLoadCloudPlugin: 'Load cloud plugin first',
xosanLoading: 'Loading...',
xosanNotAvailable: 'XOSAN is not available at the moment',
xosanRegisterBeta: 'Register for the XOSAN beta',
xosanSuccessfullyRegistered: 'You have successfully registered for the XOSAN beta. Please wait until your request has been approved.',
xosanInstallPackOnHosts: 'Install XOSAN pack on these hosts:',
xosanInstallPack: 'Install {pack} v{version}?',
xosanNoPackFound: 'No compatible XOSAN pack found for your XenServer versions.',
xosanPackRequirements: 'At least one of these version requirements must be satisfied by all the hosts in this pool:'
}
forEach(messages, function (message, id) {

View File

@@ -44,7 +44,17 @@ import {
export default class IsoDevice extends Component {
_getPredicate = createSelector(
() => this.props.vm.$pool,
poolId => sr => sr.$pool === poolId && sr.SR_type === 'iso'
() => this.props.vm.$container,
(vmPool, vmContainer) => sr => {
const vmRunning = vmContainer !== vmPool
const sameHost = vmContainer === sr.$container
const samePool = vmPool === sr.$pool
return (
samePool && (vmRunning ? sr.shared || sameHost : true) &&
sr.SR_type === 'iso' || sr.SR_type === 'udev' && sr.size
)
}
)
_handleInsert = iso => {
@@ -67,7 +77,6 @@ export default class IsoDevice extends Component {
<SelectVdi
srPredicate={this._getPredicate()}
onChange={this._handleInsert}
ref='selectIso'
value={mountedIso}
/>
<span className='input-group-btn'>

View File

@@ -1,26 +0,0 @@
import { Component } from 'react'
import propTypes from '../prop-types'
// ===================================================================
@propTypes({
disabled: propTypes.bool,
label: propTypes.any.isRequired,
onChange: propTypes.func,
placeholder: propTypes.string,
required: propTypes.bool,
schema: propTypes.object.isRequired,
uiSchema: propTypes.object,
defaultValue: propTypes.any
})
export default class AbstractInput extends Component {
set value (value) {
this.refs.input.value = value === undefined ? '' : String(value)
}
get value () {
const { value } = this.refs.input
return !value ? undefined : value
}
}

View File

@@ -1,10 +1,11 @@
import React, { Component, cloneElement } from 'react'
import map from 'lodash/map'
import filter from 'lodash/filter'
import React from 'react'
import uncontrollableInput from 'uncontrollable-input'
import { filter, map } from 'lodash'
import _ from '../intl'
import Component from '../base-component'
import propTypes from '../prop-types'
import { propsEqual } from '../utils'
import { EMPTY_ARRAY } from '../utils'
import GenericInput from './generic-input'
import {
@@ -12,171 +13,110 @@ import {
forceDisplayOptionalAttr
} from './helpers'
// ===================================================================
class ArrayItem extends Component {
get value () {
return this.refs.input.value
}
set value (value) {
this.refs.input.value = value
}
render () {
const { children } = this.props
return (
<li className='list-group-item clearfix'>
{cloneElement(children, {
ref: 'input'
})}
<button disabled={children.props.disabled} className='btn btn-danger pull-right' type='button' onClick={this.props.onDelete}>
{_('remove')}
</button>
</li>
)
}
}
// ===================================================================
@propTypes({
depth: propTypes.number,
disabled: propTypes.bool,
label: propTypes.any.isRequired,
required: propTypes.bool,
schema: propTypes.object.isRequired,
uiSchema: propTypes.object,
defaultValue: propTypes.array
uiSchema: propTypes.object
})
export default class ArrayInput extends Component {
constructor (props) {
super(props)
this._nextChildKey = 0
this.state = {
use: props.required || forceDisplayOptionalAttr(props),
children: this._makeChildren(props)
}
@uncontrollableInput()
export default class ObjectInput extends Component {
state = {
use: this.props.required || forceDisplayOptionalAttr(this.props)
}
get value () {
if (this.state.use) {
return map(this.refs, 'value')
}
_onAddItem = () => {
const { props } = this
props.onChange((props.value || EMPTY_ARRAY).concat(undefined))
}
set value (value = []) {
this.setState({
children: this._makeChildren({ ...this.props, value })
})
_onChangeItem = (value, name) => {
const key = Number(name)
const { props } = this
const newValue = (props.value || EMPTY_ARRAY).slice()
newValue[key] = value
props.onChange(newValue)
}
_handleOptionalChange = event => {
this.setState({
use: event.target.checked
})
}
_handleAdd = () => {
const { children } = this.state
this.setState({
children: children.concat(this._makeChild(this.props))
})
}
_remove (key) {
this.setState({
children: filter(this.state.children, child => child.key !== key)
})
}
_makeChild (props, defaultValue) {
const key = String(this._nextChildKey++)
const {
schema: {
items
}
} = props
return (
<ArrayItem key={key} onDelete={() => { this._remove(key) }}>
<GenericInput
depth={props.depth}
disabled={props.disabled}
label={items.title || _('item')}
required
schema={items}
uiSchema={props.uiSchema.items}
defaultValue={defaultValue}
/>
</ArrayItem>
)
}
_makeChildren (props) {
return map(props.defaultValue, defaultValue =>
this._makeChild(props, defaultValue)
)
}
componentWillReceiveProps (props) {
if (
!propsEqual(
this.props,
props,
[ 'depth', 'disabled', 'label', 'required', 'schema', 'uiSchema' ]
)
) {
this.setState({
children: this._makeChildren(props)
})
}
_onRemoveItem = key => {
const { props } = this
props.onChange(filter(props.value, (_, i) => i !== key))
}
render () {
const {
props,
state
props: {
depth = 0,
disabled,
label,
required,
schema,
uiSchema,
value = EMPTY_ARRAY
},
state: { use }
} = this
const {
disabled,
schema
} = props
const { use } = state
const depth = props.depth || 0
const childDepth = depth + 2
const itemSchema = schema.items
const itemUiSchema = uiSchema && uiSchema.items
const itemLabel = itemSchema.title || _('item')
return (
<div style={{'paddingLeft': `${depth}em`}}>
<legend>{props.label}</legend>
<legend>{label}</legend>
{descriptionRender(schema.description)}
<hr />
{!props.required &&
<div className='checkbox'>
<label>
<input
checked={use}
disabled={disabled}
onChange={this._handleOptionalChange}
type='checkbox'
/> {_('fillOptionalInformations')}
</label>
</div>
}
{use &&
<div className={'card-block'}>
<ul style={{'paddingLeft': 0}} >
{map(this.state.children, (child, index) =>
cloneElement(child, { ref: index })
)}
</ul>
<button disabled={disabled} className='btn btn-primary pull-right mt-1 mr-1' type='button' onClick={this._handleAdd}>
{_('add')}
</button>
</div>
}
{!required && <div className='checkbox'>
<label>
<input
checked={use}
disabled={disabled}
onChange={this.linkState('use')}
type='checkbox'
/> {_('fillOptionalInformations')}
</label>
</div>}
{use && <div className='card-block'>
<ul style={{'paddingLeft': 0}} >
{map(value, (value, key) =>
<li className='list-group-item clearfix' key={key}>
<GenericInput
depth={childDepth}
disabled={disabled}
label={itemLabel}
name={key}
onChange={this._onChangeItem}
required
schema={itemSchema}
uiSchema={itemUiSchema}
value={value}
/>
<button
className='btn btn-danger pull-right'
disabled={disabled}
name={key}
onClick={() => this._onRemoveItem(key)}
type='button'
>
{_('remove')}
</button>
</li>
)}
</ul>
<button
className='btn btn-primary pull-right mt-1 mr-1'
disabled={disabled}
onClick={this._onAddItem}
type='button'
>
{_('add')}
</button>
</div>}
</div>
)
}

View File

@@ -1,32 +1,30 @@
import React from 'react'
import { Toggle } from 'form'
import AbstractInput from './abstract-input'
import uncontrollableInput from 'uncontrollable-input'
import Component from '../base-component'
import { Toggle } from '../form'
import { PrimitiveInputWrapper } from './helpers'
// ===================================================================
export default class BooleanInput extends AbstractInput {
get value () {
return this.refs.input.value
}
set value (value) {
this.refs.input.value = value
}
@uncontrollableInput()
export default class BooleanInput extends Component {
render () {
const { props } = this
const {
disabled,
onChange,
value,
...props
} = this.props
return (
<PrimitiveInputWrapper {...props}>
<div className='checkbox form-control'>
<Toggle
defaultValue={props.defaultValue}
disabled={props.disabled}
onChange={props.onChange}
ref='input'
disabled={disabled}
onChange={onChange}
value={value}
/>
</div>
</PrimitiveInputWrapper>

View File

@@ -1,33 +1,54 @@
import React from 'react'
import _ from 'intl'
import map from 'lodash/map'
import uncontrollableInput from 'uncontrollable-input'
import Component from 'base-component'
import React from 'react'
import { createSelector } from 'reselect'
import { findIndex, map } from 'lodash'
import AbstractInput from './abstract-input'
import { PrimitiveInputWrapper } from './helpers'
// ===================================================================
export default class EnumInput extends AbstractInput {
@uncontrollableInput()
export default class EnumInput extends Component {
_getSelectedIndex = createSelector(
() => this.props.schema.enum,
() => {
const {
schema,
value = schema.default
} = this.props
return value
},
(enumValues, value) => {
const index = findIndex(enumValues, current => current === value)
return index === -1 ? '' : index
}
)
_onChange = event => {
this.props.onChange(this.props.schema.enum[event.target.value])
}
render () {
const { props } = this
const {
onChange,
disabled,
schema: { enum: enumValues, enumNames = enumValues },
required
} = props
} = this.props
return (
<PrimitiveInputWrapper {...props}>
<PrimitiveInputWrapper {...this.props}>
<select
className='form-control'
defaultValue={props.defaultValue || ''}
disabled={props.disabled}
onChange={onChange && (event => onChange(event.target.value))}
ref='input'
disabled={disabled}
onChange={this._onChange}
required={required}
value={this._getSelectedIndex()}
>
{_('noSelectedValue', message => <option value=''>{message}</option>)}
{map(props.schema.enum, (value, index) =>
<option value={value} key={index}>{value}</option>
{map(enumNames, (name, index) =>
<option value={index} key={index}>{name}</option>
)}
</select>
</PrimitiveInputWrapper>

View File

@@ -1,6 +1,8 @@
import React, { Component } from 'react'
import getEventValue from '../get-event-value'
import propTypes from '../prop-types'
import uncontrollableInput from 'uncontrollable-input'
import { EMPTY_OBJECT } from '../utils'
import ArrayInput from './array-input'
@@ -30,35 +32,31 @@ const InputByType = {
depth: propTypes.number,
disabled: propTypes.bool,
label: propTypes.any.isRequired,
onChange: propTypes.func,
required: propTypes.bool,
schema: propTypes.object.isRequired,
uiSchema: propTypes.object,
defaultValue: propTypes.any
uiSchema: propTypes.object
})
@uncontrollableInput()
export default class GenericInput extends Component {
get value () {
return this.refs.input.value
}
set value (value) {
this.refs.input.value = value
_onChange = event => {
const { name, onChange } = this.props
onChange && onChange(getEventValue(event), name)
}
render () {
const {
schema,
defaultValue = schema.default,
value = schema.default,
uiSchema = EMPTY_OBJECT,
...opts
} = this.props
const props = {
...opts,
defaultValue,
onChange: this._onChange,
schema,
uiSchema,
ref: 'input'
value
}
// Enum, special case.

View File

@@ -62,19 +62,19 @@ export const PrimitiveInputWrapper = ({ label, required = false, schema, childre
// ===================================================================
export const forceDisplayOptionalAttr = ({ schema, defaultValue }) => {
if (!schema || !defaultValue) {
export const forceDisplayOptionalAttr = ({ schema, value }) => {
if (!schema || !value) {
return false
}
// Array
if (schema.items && Array.isArray(defaultValue)) {
if (schema.items && Array.isArray(value)) {
return true
}
// Object
for (const key in schema.properties) {
if (defaultValue[key]) {
if (value[key]) {
return true
}
}

View File

@@ -1,38 +1,42 @@
import React from 'react'
import AbstractInput from './abstract-input'
import uncontrollableInput from 'uncontrollable-input'
import Combobox from '../combobox'
import Component from '../base-component'
import getEventValue from '../get-event-value'
import { PrimitiveInputWrapper } from './helpers'
// ===================================================================
export default class IntegerInput extends AbstractInput {
get value () {
const { value } = this.refs.input
return !value ? undefined : +value
}
set value (value) {
// Getter/Setter are always inherited together.
// `get value` is defined in the subclass, so `set value`
// must be defined too.
super.value = value
@uncontrollableInput()
export default class IntegerInput extends Component {
_onChange = event => {
const value = getEventValue(event)
this.props.onChange(value ? +value : undefined)
}
render () {
const { props } = this
const { schema } = props
const { required, schema } = this.props
const {
disabled,
onChange, // eslint-disable-line no-unused-vars
placeholder = schema.default,
value,
...props
} = this.props
return (
<PrimitiveInputWrapper {...props}>
<Combobox
defaultValue={props.defaultValue}
disabled={props.disabled}
onChange={props.onChange}
value={value === undefined ? '' : String(value)}
disabled={disabled}
max={schema.max}
min={schema.min}
onChange={this._onChange}
options={schema.defaults}
placeholder={props.placeholder || schema.default}
ref='input'
required={props.required}
placeholder={placeholder}
required={required}
step={1}
type='number'
/>

View File

@@ -1,38 +1,42 @@
import React from 'react'
import AbstractInput from './abstract-input'
import uncontrollableInput from 'uncontrollable-input'
import Combobox from '../combobox'
import Component from '../base-component'
import getEventValue from '../get-event-value'
import { PrimitiveInputWrapper } from './helpers'
// ===================================================================
export default class NumberInput extends AbstractInput {
get value () {
const { value } = this.refs.input
return !value ? undefined : +value
}
set value (value) {
// Getter/Setter are always inherited together.
// `get value` is defined in the subclass, so `set value`
// must be defined too.
super.value = value
@uncontrollableInput()
export default class NumberInput extends Component {
_onChange = event => {
const value = getEventValue(event)
this.props.onChange(value ? +value : undefined)
}
render () {
const { props } = this
const { schema } = props
const { required, schema } = this.props
const {
disabled,
onChange, // eslint-disable-line no-unused-vars
placeholder = schema.default,
value,
...props
} = this.props
return (
<PrimitiveInputWrapper {...props}>
<Combobox
defaultValue={props.defaultValue}
disabled={props.disabled}
onChange={props.onChange}
value={value === undefined ? '' : String(value)}
disabled={disabled}
max={schema.max}
min={schema.min}
onChange={this._onChange}
options={schema.defaults}
placeholder={props.placeholder || schema.default}
ref='input'
required={props.required}
placeholder={placeholder}
required={required}
step='any'
type='number'
/>

View File

@@ -1,163 +1,97 @@
import _ from 'intl'
import React, { Component, cloneElement } from 'react'
import forEach from 'lodash/forEach'
import includes from 'lodash/includes'
import map from 'lodash/map'
import React from 'react'
import uncontrollableInput from 'uncontrollable-input'
import { createSelector } from 'reselect'
import { keyBy, map } from 'lodash'
import _ from '../intl'
import Component from '../base-component'
import propTypes from '../prop-types'
import { propsEqual } from '../utils'
import { EMPTY_OBJECT } from '../utils'
import GenericInput from './generic-input'
import {
descriptionRender,
forceDisplayOptionalAttr
} from './helpers'
// ===================================================================
class ObjectItem extends Component {
get value () {
return this.refs.input.value
}
set value (value) {
this.refs.input.value = value
}
render () {
const { props } = this
return (
<div className='pb-1'>
{cloneElement(props.children, {
ref: 'input'
})}
</div>
)
}
}
// ===================================================================
@propTypes({
depth: propTypes.number,
disabled: propTypes.bool,
label: propTypes.any.isRequired,
required: propTypes.bool,
schema: propTypes.object.isRequired,
uiSchema: propTypes.object,
defaultValue: propTypes.object
uiSchema: propTypes.object
})
@uncontrollableInput()
export default class ObjectInput extends Component {
constructor (props) {
super(props)
this.state = {
use: Boolean(props.required) || forceDisplayOptionalAttr(props),
children: this._makeChildren(props)
}
state = {
use: this.props.required || forceDisplayOptionalAttr(this.props)
}
get value () {
if (!this.state.use) {
return
}
const obj = {}
forEach(this.refs, (instance, key) => {
obj[key] = instance.value
})
return obj
}
set value (value = {}) {
forEach(this.refs, (instance, id) => {
instance.value = value[id]
_onChildChange = (value, key) => {
this.props.onChange({
...this.props.value,
[key]: value
})
}
_handleOptionalChange = event => {
const { checked } = event.target
this.setState({
use: checked
})
}
_makeChildren (props) {
const {
depth = 0,
schema,
uiSchema = {},
defaultValue = {}
} = props
const obj = {}
const { properties } = uiSchema
forEach(schema.properties, (childSchema, key) => {
obj[key] = (
<ObjectItem key={key}>
<GenericInput
depth={depth + 2}
disabled={props.disabled}
label={childSchema.title || key}
required={includes(schema.required, key)}
schema={childSchema}
uiSchema={properties && properties[key]}
defaultValue={defaultValue[key]}
/>
</ObjectItem>
)
})
return obj
}
componentWillReceiveProps (props) {
if (
!propsEqual(
this.props,
props,
[ 'depth', 'disabled', 'label', 'required', 'schema', 'uiSchema' ]
)
) {
this.setState({
children: this._makeChildren(props)
})
}
}
_getRequiredProps = createSelector(
() => this.props.schema.required,
required => required
? keyBy(required)
: EMPTY_OBJECT
)
render () {
const { props, state } = this
const { use } = state
const depth = props.depth || 0
const {
props: {
depth = 0,
disabled,
label,
required,
schema,
uiSchema,
value = EMPTY_OBJECT
},
state: { use }
} = this
const childDepth = depth + 2
const properties = uiSchema && uiSchema.properties || EMPTY_OBJECT
const requiredProps = this._getRequiredProps()
return (
<div style={{'paddingLeft': `${depth}em`}}>
<legend>{props.label}</legend>
{descriptionRender(props.schema.description)}
<legend>{label}</legend>
{descriptionRender(schema.description)}
<hr />
{!props.required &&
<div className='checkbox'>
<label>
<input
checked={use}
disabled={props.disabled}
onChange={this._handleOptionalChange}
type='checkbox'
/> {_('fillOptionalInformations')}
</label>
</div>
}
{use &&
<div className='card-block'>
{map(state.children, (child, index) =>
cloneElement(child, { ref: index })
)}
</div>
}
{!required && <div className='checkbox'>
<label>
<input
checked={use}
disabled={disabled}
onChange={this.linkState('use')}
type='checkbox'
/> {_('fillOptionalInformations')}
</label>
</div>}
{use && <div className='card-block'>
{map(schema.properties, (childSchema, key) =>
<div className='pb-1' key={key}>
<GenericInput
depth={childDepth}
disabled={disabled}
label={childSchema.title || key}
name={key}
onChange={this._onChildChange}
required={Boolean(requiredProps[key])}
schema={childSchema}
uiSchema={properties[key]}
value={value[key]}
/>
</div>
)}
</div>}
</div>
)
}

View File

@@ -1,8 +1,10 @@
import React from 'react'
import AbstractInput from './abstract-input'
import uncontrollableInput from 'uncontrollable-input'
import Combobox from '../combobox'
import Component from '../base-component'
import propTypes from '../prop-types'
import { PrimitiveInputWrapper } from './helpers'
// ===================================================================
@@ -10,22 +12,29 @@ import { PrimitiveInputWrapper } from './helpers'
@propTypes({
password: propTypes.bool
})
export default class StringInput extends AbstractInput {
@uncontrollableInput()
export default class StringInput extends Component {
render () {
const { props } = this
const { schema } = props
const { required, schema } = this.props
const {
disabled,
onChange,
password,
placeholder = schema.default,
value,
...props
} = this.props
return (
<PrimitiveInputWrapper {...props}>
<Combobox
defaultValue={props.defaultValue}
disabled={props.disabled}
onChange={props.onChange}
value={value || ''}
disabled={disabled}
onChange={onChange}
options={schema.defaults}
placeholder={props.placeholder || schema.default}
ref='input'
required={props.required}
type={props.password && 'password'}
placeholder={placeholder || schema.default}
required={required}
type={password && 'password'}
/>
</PrimitiveInputWrapper>
)

View File

@@ -138,14 +138,18 @@ export default class Modal extends Component {
constructor () {
super()
this.state = { showModal: false }
}
componentDidMount () {
if (instance) {
throw new Error('Modal is a singleton!')
}
instance = this
}
componentWillMount () {
this.setState({ showModal: false })
componentWillUnmount () {
instance = undefined
}
close () {

View File

@@ -8,15 +8,17 @@ export let info
export let success
export class Notification extends Component {
constructor () {
super()
componentDidMount () {
if (instance) {
throw new Error('Notification is a singleton!')
}
instance = this
}
componentWillUnmount () {
instance = undefined
}
// This special component never have to rerender!
shouldComponentUpdate () {
return false

View File

@@ -165,7 +165,7 @@ const xoItemToRender = {
// PIF.
PIF: pif => (
<span>
<Icon icon='network' /> {pif.device}
<Icon icon='network' /> {pif.device} ({pif.deviceName})
</span>
),

View File

@@ -1,14 +1,17 @@
import includes from 'lodash/includes'
import join from 'lodash/join'
import classNames from 'classnames'
import Icon from 'icon'
import later from 'later'
import map from 'lodash/map'
import React from 'react'
import sortedIndex from 'lodash/sortedIndex'
import Tooltip from 'tooltip'
import { Toggle } from 'form'
import { FormattedDate, FormattedTime } from 'react-intl'
import {
Tab,
Tabs
} from 'react-bootstrap-4/lib'
forEach,
includes,
isArray,
map,
sortedIndex
} from 'lodash'
import _ from './intl'
import Component from './base-component'
@@ -20,13 +23,22 @@ import { Range } from './form'
// ===================================================================
// By default later use UTC but we use this line for futures versions.
// By default, later uses UTC but we use this line for future versions.
later.date.UTC()
// ===================================================================
const NAV_EACH_SELECTED = 1
const NAV_EVERY_N = 2
const CLICKABLE = { cursor: 'pointer' }
const PREVIEW_SLIDER_STYLE = { width: '400px' }
// ===================================================================
const UNITS = [ 'minute', 'hour', 'monthDay', 'month', 'weekDay' ]
const MINUTES_RANGE = [2, 30]
const HOURS_RANGE = [2, 12]
const MONTH_DAYS_RANGE = [2, 15]
const MONTHS_RANGE = [2, 6]
const MIN_PREVIEWS = 5
const MAX_PREVIEWS = 20
@@ -130,24 +142,20 @@ const getDayName = (dayNum) =>
cronPattern: propTypes.string.isRequired
})
export class SchedulePreview extends Component {
_handleChange = value => {
this.setState({
value
})
}
render () {
const { cronPattern } = this.props
const { value } = this.state
const cronSched = later.parse.cron(cronPattern)
const dates = later.schedule(cronSched).next(this.state.value || MIN_PREVIEWS)
const dates = later.schedule(cronSched).next(value)
return (
<div>
<div className='alert alert-info' role='alert'>
{_('cronPattern')} <strong>{cronPattern}</strong>
</div>
<div className='form-inline pb-1'>
<Range min={MIN_PREVIEWS} max={MAX_PREVIEWS} onChange={this._handleChange} />
<div className='mb-1' style={PREVIEW_SLIDER_STYLE}>
<Range min={MIN_PREVIEWS} max={MAX_PREVIEWS} onChange={this.linkState('value')} value={+value} />
</div>
<ul className='list-group'>
{map(dates, (date, id) => (
@@ -179,7 +187,7 @@ class ToggleTd extends Component {
render () {
const { props } = this
return (
<td style={{ cursor: 'pointer' }} className={props.value ? 'table-success' : ''} onClick={this._onClick}>
<td style={CLICKABLE} className={props.value ? 'table-success' : ''} onClick={this._onClick}>
{props.children}
</td>
)
@@ -189,14 +197,15 @@ class ToggleTd extends Component {
// ===================================================================
@propTypes({
labelId: propTypes.string.isRequired,
options: propTypes.array.isRequired,
optionsRenderer: propTypes.func,
optionRenderer: propTypes.func,
onChange: propTypes.func.isRequired,
value: propTypes.array.isRequired
})
class TableSelect extends Component {
static defaultProps = {
optionsRenderer: value => value
optionRenderer: value => value
}
_reset = () => {
@@ -226,204 +235,247 @@ class TableSelect extends Component {
render () {
const {
labelId,
options,
optionsRenderer,
optionRenderer,
value
} = this.props
const { length } = options[0]
return (
<div>
<table className='table table-bordered table-sm'>
<tbody>
{map(options, (line, i) => (
<tr key={i}>
{map(line, (tdOption, j) => {
const tdId = length * i + j
return (
<ToggleTd
children={optionsRenderer(tdOption)}
tdId={tdId}
key={tdId}
onChange={this._handleChange}
value={includes(value, tdId)}
/>
)
})}
</tr>
))}
</tbody>
</table>
<button className='btn btn-secondary pull-right' onClick={this._reset}>
{_('selectTableReset')}
</button>
</div>
)
return <div>
<table className='table table-bordered table-sm'>
<tbody>
{map(options, (line, i) => (
<tr key={i}>
{map(line, tdOption => (
<ToggleTd
children={optionRenderer(tdOption)}
tdId={tdOption}
key={tdOption}
onChange={this._handleChange}
value={includes(value, tdOption)}
/>
))}
</tr>
))}
</tbody>
</table>
<button className='btn btn-secondary pull-right' onClick={this._reset}>
{_(`selectTableAll${labelId}`)} {value && !value.length && <Icon icon='success' />}
</button>
</div>
}
}
// ===================================================================
// "2,7" => [2,7] "*/2" => 2 "*" => []
const cronToValue = (cron, range) => {
if (cron.indexOf('/') === 1) {
return +cron.split('/')[1]
}
if (cron === '*') {
return []
}
return map(cron.split(','), Number)
}
// [2,7] => "2,7" 2 => "*/2" [] => "*"
const valueToCron = value => {
if (!isArray(value)) {
return `*/${value}`
}
if (!value.length) {
return '*'
}
return value.join(',')
}
@propTypes({
optionsRenderer: propTypes.func,
headerAddon: propTypes.node,
optionRenderer: propTypes.func,
onChange: propTypes.func.isRequired,
range: propTypes.array,
labelId: propTypes.string.isRequired,
value: propTypes.any.isRequired,
valueRenderer: propTypes.func
value: propTypes.any.isRequired
})
class TimePicker extends Component {
static defaultProps = {
valueRenderer: e => +e
}
_update = cron => {
const { tableValue, rangeValue } = this.state
constructor () {
super()
this.state = {
activeKey: NAV_EACH_SELECTED,
tableValue: []
}
}
const newValue = cronToValue(cron)
const periodic = !isArray(newValue)
_update (props) {
const { value, valueRenderer } = props
if (value.indexOf('/') === 1) {
this.setState({
activeKey: NAV_EVERY_N
}, () => { this.refs.range.value = value.split('/')[1] })
} else {
this.setState({
activeKey: NAV_EACH_SELECTED,
tableValue: value === '*'
? []
: map(value.split(','), valueRenderer)
})
}
}
componentWillMount () {
this._update(this.props)
}
componentWillReceiveProps (props) {
this._update(props)
}
_selectTab = activeKey => {
this.setState({
activeKey
}, () => {
const { activeKey, tableValue } = this.state
const { onChange } = this.props
const { refs } = this
if (activeKey === NAV_EACH_SELECTED) {
onChange(tableValue)
} else {
onChange(refs.range.value)
}
periodic,
tableValue: periodic ? tableValue : newValue,
rangeValue: periodic ? newValue : rangeValue
})
}
_handleTableValue = tableValue => {
this.setState({
tableValue
}, () => this.props.onChange(tableValue))
componentWillReceiveProps (props) {
if (props.value !== this.props.value) {
this._update(props.value)
}
}
componentDidMount () {
this._update(this.props.value)
}
_onChange = value => {
this.props.onChange(valueToCron(value))
}
_tableTab = () => this._onChange(this.state.tableValue || [])
_periodicTab = () => this._onChange(this.state.rangeValue || this.props.range[0])
render () {
const {
onChange,
headerAddon,
labelId,
options,
optionsRenderer,
range,
labelId
optionRenderer,
range
} = this.props
const { tableValue } = this.state
const tableSelect = (
<TableSelect
onChange={this._handleTableValue}
options={options}
optionsRenderer={optionsRenderer}
value={tableValue}
/>
const {
periodic,
tableValue,
rangeValue
} = this.state
return <Card>
<CardHeader>
{_(`scheduling${labelId}`)}
{headerAddon}
</CardHeader>
<CardBlock>
{range && <ul className='nav nav-tabs mb-1'>
<li className='nav-item'>
<a onClick={this._tableTab} className={classNames('nav-link', !periodic && 'active')} style={CLICKABLE}>
{_(`schedulingEachSelected${labelId}`)}
</a>
</li>
<li className='nav-item'>
<a onClick={this._periodicTab} className={classNames('nav-link', periodic && 'active')} style={CLICKABLE}>
{_(`schedulingEveryN${labelId}`)}
</a>
</li>
</ul>}
{periodic
? <Range ref='range' min={range[0]} max={range[1]} onChange={this._onChange} value={rangeValue} />
: <TableSelect
labelId={labelId}
onChange={this._onChange}
options={options}
optionRenderer={optionRenderer}
value={tableValue || []}
/>
}
</CardBlock>
</Card>
}
}
const isWeekDayMode = ({ monthDayPattern, weekDayPattern }) => {
if (monthDayPattern === '*' && weekDayPattern === '*') {
return
}
return weekDayPattern !== '*'
}
@propTypes({
monthDayPattern: propTypes.string.isRequired,
weekDayPattern: propTypes.string.isRequired
})
class DayPicker extends Component {
state = {
weekDayMode: isWeekDayMode(this.props)
}
componentWillReceiveProps (props) {
const weekDayMode = isWeekDayMode(props)
if (weekDayMode !== undefined) {
this.setState({ weekDayMode })
}
}
_setWeekDayMode = weekDayMode => {
this.props.onChange([ '*', '*' ])
this.setState({ weekDayMode })
}
_onChange = cron => {
const isMonthDayPattern = !this.state.weekDayMode || includes(cron, '/')
this.props.onChange([
isMonthDayPattern ? cron : '*',
isMonthDayPattern ? '*' : cron
])
}
render () {
const { monthDayPattern, weekDayPattern } = this.props
const { weekDayMode } = this.state
const dayModeToggle = (
<Tooltip content={_(weekDayMode ? 'schedulingSetMonthDayMode' : 'schedulingSetWeekDayMode')}>
<span className='pull-right'><Toggle onChange={this._setWeekDayMode} iconSize={1} value={weekDayMode} /></span>
</Tooltip>
)
return (
<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>
</Tabs>
) : tableSelect
}
</CardBlock>
</Card>
)
return <TimePicker
headerAddon={dayModeToggle}
key={weekDayMode ? 'week' : 'month'}
labelId='Day'
optionRenderer={weekDayMode ? getDayName : undefined}
options={weekDayMode ? WEEK_DAYS : DAYS}
onChange={this._onChange}
range={MONTH_DAYS_RANGE}
setWeekDayMode={this._setWeekDayMode}
value={weekDayMode ? weekDayPattern : monthDayPattern}
/>
}
}
// ===================================================================
const HOURS_RANGE = [2, 12]
const MINUTES_RANGE = [2, 30]
const decrement = e => e - 1
@propTypes({
cronPattern: propTypes.string.isRequired,
onChange: propTypes.func,
timezone: propTypes.string
})
export default class Scheduler extends Component {
_update (type, value) {
if (Array.isArray(value)) {
if (!value.length) {
value = '*'
} else {
value = join(
(type === 'monthDay' || type === 'month')
? map(value, n => n + 1)
: value,
','
)
}
} else {
value = `*/${value}`
constructor (props) {
super(props)
this._onCronChange = newCrons => {
const cronPattern = this.props.cronPattern.split(' ')
forEach(newCrons, (cron, unit) => {
cronPattern[PICKTIME_TO_ID[unit]] = cron
})
this.props.onChange({
cronPattern: cronPattern.join(' '),
timezone: this.props.timezone
})
}
const { props } = this
const cronPattern = props.cronPattern.split(' ')
cronPattern[PICKTIME_TO_ID[type]] = value
this.props.onChange({
cronPattern: cronPattern.join(' '),
timezone: props.timezone
forEach(UNITS, unit => {
this[`_${unit}Change`] = cron => this._onCronChange({ [unit]: cron })
})
this._dayChange = ([ monthDay, weekDay ]) => this._onCronChange({ monthDay, weekDay })
}
_onHourChange = value => this._update('hour', value)
_onMinuteChange = value => this._update('minute', value)
_onMonthChange = value => this._update('month', value)
_onMonthDayChange = value => this._update('monthDay', value)
_onWeekDayChange = value => this._update('weekDay', value)
_onTimezoneChange = timezone => {
const { props } = this
props.onChange({
cronPattern: props.cronPattern,
this.props.onChange({
cronPattern: this.props.cronPattern,
timezone
})
}
@@ -441,25 +493,16 @@ export default class Scheduler extends Component {
<Col mediumSize={6}>
<TimePicker
labelId='Month'
optionsRenderer={getMonthName}
optionRenderer={getMonthName}
options={MONTHS}
onChange={this._onMonthChange}
onChange={this._monthChange}
range={MONTHS_RANGE}
value={cronPatternArr[PICKTIME_TO_ID['month']]}
valueRenderer={decrement}
/>
<TimePicker
labelId='MonthDay'
options={DAYS}
onChange={this._onMonthDayChange}
value={cronPatternArr[PICKTIME_TO_ID['monthDay']]}
valueRenderer={decrement}
/>
<TimePicker
labelId='WeekDay'
optionsRenderer={getDayName}
options={WEEK_DAYS}
onChange={this._onWeekDayChange}
value={cronPatternArr[PICKTIME_TO_ID['weekDay']]}
<DayPicker
onChange={this._dayChange}
monthDayPattern={cronPatternArr[PICKTIME_TO_ID['monthDay']]}
weekDayPattern={cronPatternArr[PICKTIME_TO_ID['weekDay']]}
/>
</Col>
<Col mediumSize={6}>
@@ -467,14 +510,14 @@ export default class Scheduler extends Component {
labelId='Hour'
options={HOURS}
range={HOURS_RANGE}
onChange={this._onHourChange}
onChange={this._hourChange}
value={cronPatternArr[PICKTIME_TO_ID['hour']]}
/>
<TimePicker
labelId='Minute'
options={MINS}
range={MINUTES_RANGE}
onChange={this._onMinuteChange}
onChange={this._minuteChange}
value={cronPatternArr[PICKTIME_TO_ID['minute']]}
/>
</Col>

View File

@@ -0,0 +1,32 @@
import _ from 'intl'
import Component from 'base-component'
import Icon from 'icon'
import propTypes from 'prop-types'
import React from 'react'
import { omit } from 'lodash'
@propTypes({
multi: propTypes.bool,
label: propTypes.node,
onChange: propTypes.func.isRequired
})
export default class SelectFiles extends Component {
_onChange = e => {
const { multi, onChange } = this.props
const { files } = e.target
onChange(multi ? files : files[0])
}
render () {
return <label className='btn btn-secondary btn-file hidden'>
<Icon icon='file' /> {this.props.label || _('browseFiles')}
<input
{...omit(this.props, [ 'hidden', 'label', 'onChange', 'multi' ])}
hidden
onChange={this._onChange}
type='file'
/>
</label>
}
}

View File

@@ -1,27 +1,38 @@
import React from 'react'
import assign from 'lodash/assign'
import classNames from 'classnames'
import filter from 'lodash/filter'
import flatten from 'lodash/flatten'
import forEach from 'lodash/forEach'
import groupBy from 'lodash/groupBy'
import includes from 'lodash/includes'
import isEmpty from 'lodash/isEmpty'
import keyBy from 'lodash/keyBy'
import keys from 'lodash/keys'
import map from 'lodash/map'
import mapValues from 'lodash/mapValues'
import pick from 'lodash/pick'
import sortBy from 'lodash/sortBy'
import Icon from 'icon'
import store from 'store'
import Tooltip from 'tooltip'
import { Button } from 'react-bootstrap-4/lib'
import { parse as parseRemote } from 'xo-remote-parser'
import {
assign,
filter,
flatten,
forEach,
groupBy,
includes,
isArray,
isEmpty,
isInteger,
isString,
keyBy,
keys,
map,
mapValues,
pick,
sortBy,
toArray
} from 'lodash'
import _ from './intl'
import uncontrollableInput from 'uncontrollable-input'
import Component from './base-component'
import propTypes from './prop-types'
import renderXoItem from './render-xo-item'
import { Select } from './form'
import {
createCollectionWrapper,
createFilter,
createGetObjectsOfType,
createGetTags,
@@ -47,6 +58,26 @@ import {
// ===================================================================
// react-select's line-height is 1.4
// https://github.com/JedWatson/react-select/blob/916ab0e62fc7394be8e24f22251c399a68de8b1c/less/multi.less#L33
// while bootstrap button's line-height is 1.25
// https://github.com/twbs/bootstrap/blob/959c4e527c6ef69623928db638267ba1c370479d/scss/_variables.scss#L342
const ADDON_BUTTON_STYLE = { lineHeight: '1.4' }
const getIds = value => value == null || isString(value) || isInteger(value)
? value
: isArray(value)
? map(value, getIds)
: value.id
const getOption = (object, container) => ({
label: container
? `${getLabel(object)} ${getLabel(container)}`
: getLabel(object),
value: object.id,
xoItem: object
})
const getLabel = object =>
object.name_label ||
object.name ||
@@ -55,6 +86,10 @@ const getLabel = object =>
object.value ||
object.label
const options = props => ({
defaultValue: props.multi ? [] : undefined
})
// ===================================================================
/*
@@ -86,12 +121,11 @@ const getLabel = object =>
@propTypes({
autoFocus: propTypes.bool,
clearable: propTypes.bool,
defaultValue: propTypes.any,
disabled: propTypes.bool,
hasSelectAll: propTypes.bool,
multi: propTypes.bool,
onChange: propTypes.func,
placeholder: propTypes.any.isRequired,
predicate: propTypes.func,
required: propTypes.bool,
value: propTypes.any,
xoContainers: propTypes.array,
@@ -101,145 +135,110 @@ const getLabel = object =>
]).isRequired
})
export class GenericSelect extends Component {
constructor (props) {
super(props)
this.state = {
value: this._setValue(props.value || props.defaultValue, props)
componentDidUpdate (prevProps) {
const { onChange, xoObjects } = this.props
if (!onChange || prevProps.xoObjects === xoObjects) {
return
}
}
_getValue (xoObjectsById = this.state.xoObjectsById, props = this.props) {
const { value } = this.state
const ids = this._getSelectValue()
const objectsById = this._getObjectsById()
if (props.multi) {
// Returns the values of the selected objects
// if they are contained in xoObjectsById.
return mapPlus(value, (value, push) => {
const o = xoObjectsById[value.value !== undefined ? value.value : value]
if (!isArray(ids)) {
ids && !objectsById[ids] && onChange(undefined)
} else {
let shouldTriggerOnChange
if (o) {
push(o)
const newValue = isArray(ids) && mapPlus(ids, (id, push) => {
const object = objectsById[id]
if (object) {
push(object)
} else {
shouldTriggerOnChange = true
}
})
}
return xoObjectsById[value.value || value] || ''
}
// Supports id strings and objects.
_setValue (value, props = this.props) {
if (props.multi) {
return map(value, object => object.id !== undefined ? object.id : object)
}
return (value != null)
? value.id !== undefined ? value.id : value
: ''
}
componentWillMount () {
const { props } = this
this.setState({
...this._computeOptions(props)
})
}
componentWillReceiveProps (newProps) {
const { props } = this
const { value, xoContainers, xoObjects } = newProps
if (
xoContainers !== props.xoContainers ||
xoObjects !== props.xoObjects
) {
const {
options,
xoObjectsById
} = this._computeOptions(newProps)
const value = this._getValue(xoObjectsById, newProps)
this.setState({
options,
value: this._setValue(value, newProps),
xoObjectsById
})
}
if (value !== props.value) {
this.setState({
value: this._setValue(value, newProps)
})
if (shouldTriggerOnChange) {
this.props.onChange(newValue)
}
}
}
_computeOptions ({ xoContainers, xoObjects }) {
if (!xoContainers) {
if (process.env.NODE_ENV !== 'production') {
if (!Array.isArray(xoObjects)) {
throw new Error('without xoContainers, xoObjects must be an array')
_getObjectsById = createSelector(
() => this.props.xoObjects,
objects => keyBy(
isArray(objects)
? objects
: flatten(toArray(objects)),
'id'
)
)
_getOptions = createSelector(
() => this.props.xoContainers,
() => this.props.xoObjects,
(containers, objects) => { // createCollectionWrapper with a depth?
const { name } = this.constructor
if (!containers) {
if (__DEV__ && !isArray(objects)) {
throw new Error(`${name}: without xoContainers, xoObjects must be an array`)
}
return map(objects, getOption)
}
return {
xoObjectsById: keyBy(xoObjects, 'id'),
options: map(xoObjects, object => ({
label: getLabel(object),
value: object.id,
xoItem: object
}))
if (__DEV__ && isArray(objects)) {
throw new Error(`${name}: with xoContainers, xoObjects must be an object`)
}
}
if (process.env.NODE_ENV !== 'production') {
if (Array.isArray(xoObjects)) {
throw new Error('with xoContainers, xoObjects must be an object')
}
}
const options = []
forEach(containers, container => {
options.push({
disabled: true,
xoItem: container
})
const options = []
const xoObjectsById = {}
forEach(xoContainers, container => {
const containerObjects = keyBy(xoObjects[container.id], 'id')
assign(xoObjectsById, containerObjects)
options.push({
disabled: true,
xoItem: container
forEach(objects[container.id], object => {
options.push(getOption(object, container))
})
})
return options
}
)
options.push.apply(options, map(containerObjects, object => ({
label: `${getLabel(object)} ${getLabel(container)}`,
value: object.id,
xoItem: object
})))
})
_getSelectValue = createSelector(
() => this.props.value,
createCollectionWrapper(getIds)
)
return { xoObjectsById, options }
}
_getNewSelectedObjects = createSelector(
this._getObjectsById,
value => value,
(objectsById, value) => value == null
? value
: isArray(value)
? map(value, value => objectsById[value.value])
: objectsById[value.value]
)
get value () {
return this._getValue()
}
set value (value) {
this.setState({
value: this._setValue(value)
})
}
_handleChange = value => {
_onChange = value => {
const { onChange } = this.props
if (onChange) {
onChange(this._getNewSelectedObjects(value))
}
}
this.setState({
value: this._setValue(value)
}, onChange && (() => onChange(this.value)))
_selectAll = () => {
this._onChange(
filter(this._getOptions(), ({ disabled }) => !disabled)
)
}
// GroupBy: Display option with margin if not disabled and containers exists.
_renderOption = option => (
_renderOption = option =>
<span
className={classNames(
!option.disabled && this.props.xoContainers && 'ml-1'
@@ -247,56 +246,68 @@ export class GenericSelect extends Component {
>
{renderXoItem(option.xoItem)}
</span>
)
render () {
const { props, state } = this
const {
autoFocus,
disabled,
hasSelectAll,
multi,
placeholder,
required,
return (
<Select
autofocus={props.autoFocus}
clearable={props.clearable}
disabled={props.disabled}
multi={props.multi}
onChange={this._handleChange}
openOnFocus
optionRenderer={this._renderOption}
options={state.options}
placeholder={props.placeholder}
required={props.required}
value={state.value}
valueRenderer={this._renderOption}
/>
)
clearable = Boolean(multi || !required)
} = this.props
const select = <Select
{...{
autofocus: autoFocus,
clearable,
disabled,
multi,
placeholder,
required
}}
onChange={this._onChange}
openOnFocus
optionRenderer={this._renderOption}
options={this._getOptions()}
value={this._getSelectValue()}
valueRenderer={this._renderOption}
/>
if (!multi || !hasSelectAll) {
return select
}
// `hasSelectAll` should be provided by react-select after this pull request has been merged:
// https://github.com/JedWatson/react-select/pull/748
// TODO: remove once it has been merged upstream.
return <div className='input-group'>
{select}
<span className='input-group-btn'>
<Tooltip content={_('selectAll')}>
<Button type='button' bsStyle='secondary' onClick={this._selectAll} style={ADDON_BUTTON_STYLE}>
<Icon icon='add' />
</Button>
</Tooltip>
</span>
</div>
}
}
const makeStoreSelect = (createSelectors, props) => connectStore(
createSelectors,
{ withRef: true }
)(
class extends Component {
get value () {
return this.refs.select.value
}
set value (value) {
this.refs.select.value = value
}
render () {
return (
<GenericSelect
ref='select'
{...props}
{...this.props}
/>
)
}
}
const makeStoreSelect = (createSelectors, defaultProps) => uncontrollableInput(options)(
connectStore(createSelectors)(
props =>
<GenericSelect
{...defaultProps}
{...props}
/>
)
)
const makeSubscriptionSelect = (subscribe, props) => (
const makeSubscriptionSelect = (subscribe, props) => uncontrollableInput(options)(
class extends Component {
constructor (props) {
super(props)
@@ -325,14 +336,6 @@ const makeSubscriptionSelect = (subscribe, props) => (
)
}
get value () {
return this.refs.select.value
}
set value (value) {
this.refs.select.value = value
}
componentWillMount () {
this.componentWillUnmount = subscribe(::this.setState)
}
@@ -340,7 +343,6 @@ const makeSubscriptionSelect = (subscribe, props) => (
render () {
return (
<GenericSelect
ref='select'
{...props}
{...this.props}
xoObjects={this._getFilteredXoObjects()}
@@ -521,11 +523,11 @@ export const SelectTag = makeStoreSelect((_, props) => ({
}), { placeholder: _('selectTags') })
export const SelectHighLevelObject = makeStoreSelect(() => {
const getHosts = createGetObjectsOfType('host')
const getNetworks = createGetObjectsOfType('network')
const getPools = createGetObjectsOfType('pool')
const getSrs = createGetObjectsOfType('SR')
const getVms = createGetObjectsOfType('VM')
const getHosts = createGetObjectsOfType('host').filter(getPredicate)
const getNetworks = createGetObjectsOfType('network').filter(getPredicate)
const getPools = createGetObjectsOfType('pool').filter(getPredicate)
const getSrs = createGetObjectsOfType('SR').filter(getPredicate)
const getVms = createGetObjectsOfType('VM').filter(getPredicate)
const getHighLevelObjects = createSelector(
getHosts,

View File

@@ -37,12 +37,16 @@ export {
// Use case: in connect, to avoid rerendering a component where the
// objects are still the same.
const _createCollectionWrapper = selector => {
let cache
let cache, previous
return (...args) => {
const value = selector(...args)
if (!shallowEqual(value, cache)) {
cache = value
if (value !== previous) {
previous = value
if (!shallowEqual(value, cache)) {
cache = value
}
}
return cache
}
@@ -142,10 +146,10 @@ export const createPicker = (object, props) =>
// - predicate == null → no filtering
// - predicate === false → everything is filtered out
export const createFilter = (collection, predicate) =>
_createCollectionWrapper(
_create2(
collection,
predicate,
_create2(
collection,
predicate,
_createCollectionWrapper(
(collection, predicate) => predicate === false
? (isArrayLike(collection) ? EMPTY_ARRAY : EMPTY_OBJECT)
: predicate
@@ -168,17 +172,18 @@ export const createGroupBy = (collection, getter) =>
groupBy
)
export const createPager = (array, page, n = 25) => _createCollectionWrapper(
export const createPager = (array, page, n = 25) =>
_create2(
array,
page,
n,
(array, page, n) => {
const start = (page - 1) * n
return slice(array, start, start + n)
}
_createCollectionWrapper(
(array, page, n) => {
const start = (page - 1) * n
return slice(array, start, start + n)
}
)
)
)
export const createSort = (
collection,
@@ -187,11 +192,11 @@ export const createSort = (
) => _create2(collection, getter, order, orderBy)
export const createTop = (collection, iteratee, n) =>
_createCollectionWrapper(
_create2(
collection,
iteratee,
n,
_create2(
collection,
iteratee,
n,
_createCollectionWrapper(
(objects, iteratee, n) => {
let results = orderBy(objects, iteratee, 'desc')
if (n < results.length) {
@@ -217,6 +222,36 @@ export const getStatus = state => state.status
export const getUser = state => state.user
export const getCheckPermissions = invoke(() => {
const getPredicate = create(
state => state.permissions,
state => state.objects,
(permissions, objects) => {
objects = objects.all
const getObject = id => (objects[id] || EMPTY_OBJECT)
return (id, permission) => checkPermissions(permissions, getObject, id, permission)
}
)
const isTrue = () => true
const isFalse = () => false
return state => {
const user = getUser(state)
if (!user) {
return isFalse
}
if (user.permission === 'admin') {
return isTrue
}
return getPredicate(state)
}
})
const _getPermissionsPredicate = invoke(() => {
const getPredicate = create(
state => state.permissions,
@@ -453,23 +488,24 @@ export const createDoesHostNeedRestart = hostSelector => {
return (state, props) => restartPoolPatch(state, props) !== undefined
}
export const createGetHostMetrics = hostSelector => _createCollectionWrapper(
export const createGetHostMetrics = hostSelector =>
create(
hostSelector,
hosts => {
const metrics = {
count: 0,
cpus: 0,
memoryTotal: 0,
memoryUsage: 0
_createCollectionWrapper(
hosts => {
const metrics = {
count: 0,
cpus: 0,
memoryTotal: 0,
memoryUsage: 0
}
forEach(hosts, host => {
metrics.count++
metrics.cpus += host.cpus.cores
metrics.memoryTotal += host.memory.size
metrics.memoryUsage += host.memory.usage
})
return metrics
}
forEach(hosts, host => {
metrics.count++
metrics.cpus += host.cpus.cores
metrics.memoryTotal += host.memory.size
metrics.memoryUsage += host.memory.usage
})
return metrics
}
)
)
)

View File

@@ -1,3 +1,5 @@
import kindOf from 'kindof'
// Tests that two collections (arrays or objects) have strictly equals
// values (items or properties)
const shallowEqual = (c1, c2) => {
@@ -5,8 +7,8 @@ const shallowEqual = (c1, c2) => {
return true
}
const type = typeof c1
if (type !== typeof c2) {
const type = kindOf(c1)
if (type !== kindOf(c2)) {
return false
}
@@ -25,6 +27,10 @@ const shallowEqual = (c1, c2) => {
return true
}
if (type !== 'object') {
return false
}
let n = 0
for (const _ in c2) { // eslint-disable-line no-unused-vars
++n

View File

@@ -93,7 +93,7 @@ class TableFilter extends Component {
@propTypes({
columnId: propTypes.number.isRequired,
name: propTypes.any.isRequired,
name: propTypes.node,
sort: propTypes.func,
sortIcon: propTypes.string
})
@@ -104,10 +104,10 @@ class ColumnHead extends Component {
}
render () {
const { name, sortIcon } = this.props
const { name, sortIcon, textAlign } = this.props
if (!this.props.sort) {
return <th>{name}</th>
return <th className={textAlign && `text-xs-${textAlign}`}>{name}</th>
}
const isSelected = sortIcon === 'asc' || sortIcon === 'desc'
@@ -115,6 +115,7 @@ class ColumnHead extends Component {
return (
<th
className={classNames(
textAlign && `text-xs-${textAlign}`,
styles.clickableColumn,
isSelected && classNames('text-white', 'bg-info')
)}
@@ -141,7 +142,7 @@ const DEFAULT_ITEMS_PER_PAGE = 10
]).isRequired,
columns: propTypes.arrayOf(propTypes.shape({
default: propTypes.bool,
name: propTypes.node.isRequired,
name: propTypes.node,
itemRenderer: propTypes.func.isRequired,
sortCriteria: propTypes.oneOfType([
propTypes.func,
@@ -302,7 +303,9 @@ export default class SortedTable extends Component {
<tr>
{map(props.columns, (column, key) => (
<ColumnHead
textAlign={column.textAlign}
columnId={key}
key={key}
name={column.name}
sort={column.sortCriteria && this._sort}
@@ -314,7 +317,7 @@ export default class SortedTable extends Component {
<tbody>
{map(this._getVisibleItems(), (item, i) => {
const columns = map(props.columns, (column, key) => (
<td key={key}>
<td key={key} className={column.textAlign && `text-xs-${column.textAlign}`}>
{column.itemRenderer(item, userData)}
</td>
))

View File

@@ -0,0 +1,38 @@
import React from 'react'
import styled from 'styled-components'
import ActionButton from './action-button'
import propTypes from './prop-types'
const Button = styled(ActionButton)`
background-color: ${p => p.theme[`${p.state ? 'enabled' : 'disabled'}StateBg`]}
border: 2px solid ${p => p.theme[`${p.state ? 'enabled' : 'disabled'}StateColor`]}
color: ${p => p.theme[`${p.state ? 'enabled' : 'disabled'}StateColor`]}
`
const StateButton = ({
disabledHandler,
disabledLabel,
disabledTooltip,
enabledLabel,
enabledTooltip,
enabledHandler,
state,
...props
}) =>
<Button
handler={state ? enabledHandler : disabledHandler}
tooltip={state ? enabledTooltip : disabledTooltip}
{...props}
icon={state ? 'running' : 'halted'}
size='small'
state={state}
>
{state ? enabledLabel : disabledLabel}
</Button>
export default propTypes({
state: propTypes.bool.isRequired
})(StateButton)

View File

@@ -1,33 +1,22 @@
import isFunction from 'lodash/isFunction'
// ===================================================================
const createAction = (() => {
const { defineProperty } = Object
const noop = function () {
if (arguments.length) {
throw new Error('this action expects no payload!')
}
}
return (type, payloadCreator = noop) => {
const createActionObject = payload => {
// Thunks
if (isFunction(payload)) {
return payload
}
return (type, payloadCreator) => defineProperty(
payloadCreator
? (...args) => ({
type,
payload: payloadCreator(...args)
})
: (action => function () {
if (arguments.length) {
throw new Error('this action expects no payload!')
}
return payload === undefined
? { type }
: { type, payload }
}
return defineProperty(
(...args) => createActionObject(payloadCreator(...args)),
'toString',
{ value: () => type }
)
}
return action
})({ type }),
'toString',
{ value: () => type }
)
})()
// ===================================================================

View File

@@ -102,16 +102,20 @@ export default {
for (const id in updates) {
const object = updates[id]
const previous = all[id]
if (object) {
const { type } = object
all[id] = object
get(object.type)[id] = object
} else {
const previous = all[id]
if (previous) {
delete all[id]
get(type)[id] = object
if (previous && previous.type !== type) {
delete get(previous.type)[id]
}
} else if (previous) {
delete all[id]
delete get(previous.type)[id]
}
}

View File

@@ -21,6 +21,9 @@ const TAG_STYLE = {
padding: '0.3em',
verticalAlign: 'middle'
}
const LINK_STYLE = {
cursor: 'pointer'
}
const ADD_TAG_STYLE = {
cursor: 'pointer',
fontSize: '0.8em',
@@ -32,9 +35,10 @@ const REMOVE_TAG_STYLE = {
@propTypes({
labels: propTypes.arrayOf(React.PropTypes.string).isRequired,
onAdd: propTypes.func,
onChange: propTypes.func,
onDelete: propTypes.func,
onAdd: propTypes.func
onClick: propTypes.func,
onDelete: propTypes.func
})
export default class Tags extends Component {
componentWillMount () {
@@ -85,6 +89,7 @@ export default class Tags extends Component {
labels,
onAdd,
onChange,
onClick,
onDelete
} = this.props
@@ -96,7 +101,7 @@ export default class Tags extends Component {
{' '}
<span>
{map(labels.sort(), (label, index) =>
<Tag label={label} onDelete={deleteTag} key={index} />
<Tag label={label} onDelete={deleteTag} key={index} onClick={onClick} />
)}
</span>
{(onAdd || onChange) && !this.state.editing
@@ -118,9 +123,12 @@ export default class Tags extends Component {
}
}
export const Tag = ({ label, onDelete }) => (
export const Tag = ({ type, label, onDelete, onClick }) => (
<span style={TAG_STYLE}>
{label}{' '}
<span onClick={onClick && (() => onClick(label))} style={onClick && LINK_STYLE}>
{label}
</span>
{' '}
{onDelete
? <span onClick={onDelete && (() => onDelete(label))} style={REMOVE_TAG_STYLE}>
<Icon icon='remove-tag' />

View File

View File

@@ -0,0 +1,6 @@
export default {
disabledStateBg: '#fff',
disabledStateColor: '#c0392b',
enabledStateBg: '#fff',
enabledStateColor: '#27ae60'
}

View File

@@ -9,89 +9,77 @@ import propTypes from './prop-types'
import { getXoServerTimezone } from './xo'
import { Select } from './form'
const XO_SERVER_TIMEZONE = 'xo-server'
const SERVER_TIMEZONE_TAG = 'server'
const LOCAL_TIMEZONE = moment.tz.guess()
@propTypes({
defaultValue: propTypes.string,
onChange: propTypes.func.isRequired,
required: propTypes.bool,
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()
}
componentDidMount () {
getXoServerTimezone.then(serverTimezone => {
this.setState({
options: [{
label: _('serverTimezoneOption', {
value: serverTimezone
}),
value: XO_SERVER_TIMEZONE
}].concat(this.state.options),
serverTimezone
timezone: this.props.value || this.props.defaultValue || SERVER_TIMEZONE_TAG,
options: [
...map(moment.tz.names(), value => ({ label: value, value })),
{
label: _('serverTimezoneOption', {
value: serverTimezone
}),
value: SERVER_TIMEZONE_TAG
}
]
})
})
}
componentWillReceiveProps (props) {
if (props.value !== this.props.value) {
this.setState({ timezone: props.value || SERVER_TIMEZONE_TAG })
}
}
get value () {
return this.state.timezone === SERVER_TIMEZONE_TAG ? null : this.state.timezone
}
set value (value) {
this.setState({ timezone: value || SERVER_TIMEZONE_TAG })
}
_onChange = option => {
if (option && option.value === this.state.timezone) {
return
}
this.setState({
timezone: option && option.value || SERVER_TIMEZONE_TAG
}, () =>
this.props.onChange(this.state.timezone === SERVER_TIMEZONE_TAG ? null : this.state.timezone)
)
}
_useLocalTime = () => {
this._onChange({ value: LOCAL_TIMEZONE })
}
render () {
const { props, state } = this
const { timezone, options } = this.state
return (
<div>
<div className='alert alert-info' role='alert'>
{_('timezonePickerServerValue')} <strong>{state.serverTimezone}</strong>
</div>
<Select
className='mb-1'
defaultValue={props.defaultValue}
onChange={this._handleChange}
options={state.options}
onChange={this._onChange}
options={options}
placeholder={_('selectTimezone')}
ref='select'
value={props.value || XO_SERVER_TIMEZONE}
required={this.props.required}
value={timezone}
/>
<div className='pull-right'>
<ActionButton
btnStyle='primary'
className='mr-1'
handler={this._useServerTime}
icon='time'
>
{_('timezonePickerUseServerTime')}
</ActionButton>
<ActionButton
btnStyle='secondary'
handler={this._useLocalTime}

View File

@@ -1,7 +1,7 @@
.tooltipEnabled {
background-color: #222;
border-radius: 3px;
border: 1px solid $fff;
border: 1px solid #fff;
color: #fff;
display: inline-block;
font-size: 13px;
@@ -12,7 +12,7 @@
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;
z-index: 9999;
}
.tooltipDisabled {

View File

@@ -17,11 +17,18 @@ export class TooltipViewer extends Component {
constructor () {
super()
this.state.place = 'top'
}
componentDidMount () {
if (instance) {
throw new Error('Tooltip viewer is a singleton!')
}
instance = this
this.state.place = 'top'
}
componentWillUnmount () {
instance = undefined
}
render () {

View File

@@ -462,3 +462,65 @@ export const htmlFileToStream = file => {
return stream
}
// ===================================================================
export const resolveId = value =>
(value != null && typeof value === 'object' && 'id' in value)
? value.id
: value
export const resolveIds = params => {
for (const key in params) {
const param = params[key]
if (param != null && typeof param === 'object' && 'id' in param) {
params[key] = param.id
}
}
return params
}
// ===================================================================
const OPs = {
'<': a => a < 0,
'<=': a => a <= 0,
'===': a => a === 0,
'>': a => a > 0,
'>=': a => a >= 0
}
const makeNiceCompare = compare => function () {
const { length } = arguments
if (length === 2) {
return compare(arguments[0], arguments[1])
}
let i = 1
let v1 = arguments[0]
let op, v2
while (i < length) {
op = arguments[i++]
v2 = arguments[i++]
if (!OPs[op](compare(v1, v2))) {
return false
}
v1 = v2
}
return true
}
export const compareVersions = makeNiceCompare((v1, v2) => {
v1 = v1.split('.')
v2 = v2.split('.')
for (let i = 0; i < Math.max(v1.length, v2.length); i++) {
const n1 = +v1[i] || 0
const n2 = +v2[i] || 0
if (n1 < n2) return -1
if (n1 > n2) return 1
}
return 0
})

View File

@@ -1,20 +1,21 @@
import map from 'lodash/map'
import AbstractInput from '../json-schema-input/abstract-input'
import { PureComponent } from 'react'
import getEventValue from '../get-event-value'
// ===================================================================
export default class XoAbstractInput extends AbstractInput {
get value () {
const value = this.refs.input.value
const getId = value => value != null && value.id || value
if (this.props.schema.type === 'array') {
return map(value, object => object.id || object)
}
export default class XoAbstractInput extends PureComponent {
_onChange = event => {
const value = getEventValue(event)
const { props } = this
return value.id || value
}
set value (value) {
this.refs.input.value = value
return props.onChange(
props.schema.type === 'array'
? map(value, getId)
: getId(value)
)
}
}

View File

@@ -14,11 +14,12 @@ export default class HighLevelObjectInput extends XoAbstractInput {
<PrimitiveInputWrapper {...props}>
<SelectHighLevelObject
disabled={props.disabled}
hasSelectAll
multi={props.multi}
onChange={props.onChange}
onChange={this._onChange}
ref='input'
required={props.required}
defaultValue={props.defaultValue}
value={props.value}
/>
</PrimitiveInputWrapper>
)

View File

@@ -14,11 +14,12 @@ export default class HostInput extends XoAbstractInput {
<PrimitiveInputWrapper {...props}>
<SelectHost
disabled={props.disabled}
hasSelectAll
multi={props.multi}
onChange={props.onChange}
onChange={this._onChange}
ref='input'
required={props.required}
defaultValue={props.defaultValue}
value={props.value}
/>
</PrimitiveInputWrapper>
)

View File

@@ -14,11 +14,12 @@ export default class PoolInput extends XoAbstractInput {
<PrimitiveInputWrapper {...props}>
<SelectPool
disabled={props.disabled}
hasSelectAll
multi={props.multi}
onChange={props.onChange}
onChange={this._onChange}
ref='input'
required={props.required}
defaultValue={props.defaultValue}
value={props.value}
/>
</PrimitiveInputWrapper>
)

View File

@@ -14,11 +14,12 @@ export default class RemoteInput extends XoAbstractInput {
<PrimitiveInputWrapper {...props}>
<SelectRemote
disabled={props.disabled}
hasSelectAll
multi={props.multi}
onChange={props.onChange}
onChange={this._onChange}
ref='input'
required={props.required}
defaultValue={props.defaultValue}
value={props.value}
/>
</PrimitiveInputWrapper>
)

View File

@@ -14,11 +14,12 @@ export default class RoleInput extends XoAbstractInput {
<PrimitiveInputWrapper {...props}>
<SelectRole
disabled={props.disabled}
hasSelectAll
multi={props.multi}
onChange={props.onChange}
onChange={this._onChange}
ref='input'
required={props.required}
defaultValue={props.defaultValue}
value={props.value}
/>
</PrimitiveInputWrapper>
)

View File

@@ -14,11 +14,12 @@ export default class SrInput extends XoAbstractInput {
<PrimitiveInputWrapper {...props}>
<SelectSr
disabled={props.disabled}
hasSelectAll
multi={props.multi}
onChange={props.onChange}
onChange={this._onChange}
ref='input'
required={props.required}
defaultValue={props.defaultValue}
value={props.value}
/>
</PrimitiveInputWrapper>
)

View File

@@ -14,11 +14,12 @@ export default class SubjectInput extends XoAbstractInput {
<PrimitiveInputWrapper {...props}>
<SelectSubject
disabled={props.disabled}
hasSelectAll
multi={props.multi}
onChange={props.onChange}
onChange={this._onChange}
ref='input'
required={props.required}
defaultValue={props.defaultValue}
value={props.value}
/>
</PrimitiveInputWrapper>
)

View File

@@ -14,11 +14,12 @@ export default class TagInput extends XoAbstractInput {
<PrimitiveInputWrapper {...props}>
<SelectTag
disabled={props.disabled}
hasSelectAll
multi={props.multi}
onChange={props.onChange}
onChange={this._onChange}
ref='input'
required={props.required}
defaultValue={props.defaultValue}
value={props.value}
/>
</PrimitiveInputWrapper>
)

View File

@@ -14,11 +14,12 @@ export default class VmInput extends XoAbstractInput {
<PrimitiveInputWrapper {...props}>
<SelectVm
disabled={props.disabled}
hasSelectAll
multi={props.multi}
onChange={props.onChange}
onChange={this._onChange}
ref='input'
required={props.required}
defaultValue={props.defaultValue}
value={props.value}
/>
</PrimitiveInputWrapper>
)

View File

@@ -1,4 +1,4 @@
.dashedLine {
stroke: black;
stroke-dasharray: 4px 5px;
stroke: black;
stroke-dasharray: 4px 2px;
}

View File

@@ -1,12 +1,19 @@
import ChartistGraph from 'react-chartist'
import ChartistLegend from 'chartist-plugin-legend'
import ChartistTooltip from 'chartist-plugin-tooltip'
import map from 'lodash/map'
import React from 'react'
import size from 'lodash/size'
import values from 'lodash/values'
import { injectIntl } from 'react-intl'
import find from 'lodash/find'
import { messages } from 'intl'
import {
find,
flatten,
floor,
map,
max,
size,
sum,
values
} from 'lodash'
import propTypes from '../prop-types'
import { computeArraysSum } from '../xo-stats'
@@ -53,7 +60,7 @@ const makeOptions = ({ intl, nValues, endTimestamp, interval, valueTransform })
// ===================================================================
const makeLabelInterpolationFnc = (intl, nValues, endTimestamp, interval) => {
const labelSpace = Math.floor(nValues / N_LABELS_X)
const labelSpace = floor(nValues / N_LABELS_X)
let format
if (interval === 3600) {
@@ -150,7 +157,7 @@ export const CpuLineChart = injectIntl(propTypes({
nValues: length,
endTimestamp: data.endTimestamp,
interval: data.interval,
valueTransform: value => `${value}%`
valueTransform: value => `${floor(value)}%`
}),
high: !addSumSeries ? 100 : stats.length * 100,
...options
@@ -159,6 +166,54 @@ export const CpuLineChart = injectIntl(propTypes({
)
}))
export const PoolCpuLineChart = injectIntl(propTypes({
addSumSeries: propTypes.bool,
data: propTypes.object.isRequired,
options: propTypes.object
})(({ addSumSeries, data, options = {}, intl }) => {
const firstHostData = data[0]
const length = getStatsLength(firstHostData.stats.cpus)
if (!length) {
return templateError
}
const series = map(data, ({ host, stats }) => ({
name: host,
data: computeArraysSum(stats.cpus)
}))
if (addSumSeries) {
series.push({
name: intl.formatMessage(messages.poolAllHosts),
data: computeArraysSum(map(series, 'data')),
className: styles.dashedLine
})
}
const nbCpusByHost = map(data, ({ stats }) => stats.cpus.length)
return (
<ChartistGraph
type='Line'
data={{
series
}}
options={{
...makeOptions({
intl,
nValues: length,
endTimestamp: firstHostData.endTimestamp,
interval: firstHostData.interval,
valueTransform: value => `${floor(value)}%`
}),
high: 100 * (addSumSeries ? sum(nbCpusByHost) : max(nbCpusByHost)),
...options
}}
/>
)
}))
export const MemoryLineChart = injectIntl(propTypes({
data: propTypes.object.isRequired,
options: propTypes.object
@@ -196,6 +251,57 @@ export const MemoryLineChart = injectIntl(propTypes({
)
}))
export const PoolMemoryLineChart = injectIntl(propTypes({
addSumSeries: propTypes.bool,
data: propTypes.object.isRequired,
options: propTypes.object
})(({ addSumSeries, data, options = {}, intl }) => {
const firstHostData = data[0]
const {
memory,
memoryUsed
} = firstHostData.stats
if (!memory || !memoryUsed) {
return templateError
}
const series = map(data, ({ host, stats }) => ({
name: host,
data: stats.memoryUsed
}))
if (addSumSeries) {
series.push({
name: intl.formatMessage(messages.poolAllHosts),
data: computeArraysSum(map(data, 'stats.memoryUsed')),
className: styles.dashedLine
})
}
const currentMemoryByHost = map(data, ({ stats }) => stats.memory[stats.memory.length - 1])
return (
<ChartistGraph
type='Line'
data={{
series
}}
options={{
...makeOptions({
intl,
nValues: firstHostData.stats.memoryUsed.length,
endTimestamp: firstHostData.endTimestamp,
interval: firstHostData.interval,
valueTransform: formatSize
}),
high: addSumSeries ? sum(currentMemoryByHost) : max(currentMemoryByHost),
...options
}}
/>
)
}))
export const XvdLineChart = injectIntl(propTypes({
addSumSeries: propTypes.bool,
data: propTypes.object.isRequired,
@@ -292,6 +398,51 @@ export const PifLineChart = injectIntl(propTypes({
)
}))
const ios = ['rx', 'tx']
export const PoolPifLineChart = injectIntl(propTypes({
addSumSeries: propTypes.bool,
data: propTypes.object.isRequired,
options: propTypes.object
})(({ addSumSeries, data, options = {}, intl }) => {
const firstHostData = data[0]
const length = firstHostData.stats && getStatsLength(firstHostData.stats.pifs.rx)
if (!length) {
return templateError
}
const series = addSumSeries
? map(ios, io => ({
name: `${intl.formatMessage(messages.poolAllHosts)} (${io})`,
data: computeArraysSum(map(data, ({ stats }) => computeArraysSum(stats.pifs[io])))
}))
: flatten(map(data, ({ stats, host }) =>
map(ios, io => ({
name: `${host} (${io})`,
data: computeArraysSum(stats.pifs[io])
}))
))
return (
<ChartistGraph
type='Line'
data={{
series
}}
options={{
...makeOptions({
intl,
nValues: length,
endTimestamp: firstHostData.endTimestamp,
interval: firstHostData.interval,
valueTransform: formatSize
}),
...options
}}
/>
)
}))
export const LoadLineChart = injectIntl(propTypes({
data: propTypes.object.isRequired,
options: propTypes.object
@@ -325,3 +476,48 @@ export const LoadLineChart = injectIntl(propTypes({
/>
)
}))
export const PoolLoadLineChart = injectIntl(propTypes({
addSumSeries: propTypes.bool,
data: propTypes.object.isRequired,
options: propTypes.object
})(({ addSumSeries, data, options = {}, intl }) => {
const firstHostData = data[0]
const length = firstHostData.stats && firstHostData.stats.load.length
if (!length) {
return templateError
}
const series = map(data, ({ host, stats }) => ({
name: host,
data: stats.load
}))
if (addSumSeries) {
series.push({
name: intl.formatMessage(messages.poolAllHosts),
data: computeArraysSum(map(data, 'stats.load')),
className: styles.dashedLine
})
}
return (
<ChartistGraph
type='Line'
data={{
series
}}
options={{
...makeOptions({
intl,
nValues: length,
endTimestamp: firstHostData.endTimestamp,
interval: firstHostData.interval,
valueTransform: value => `${value.toPrecision(3)}`
}),
...options
}}
/>
)
}))

View File

@@ -1,24 +1,46 @@
import _ from 'intl'
import BaseComponent from 'base-component'
import every from 'lodash/every'
import React from 'react'
import SingleLineRow from 'single-line-row'
import { SelectHost } from 'select-objects'
import { Col } from 'grid'
import { connectStore } from 'utils'
import { createGetObjectsOfType } from 'selectors'
import { createCollectionWrapper, createGetObjectsOfType, createSelector } from 'selectors'
import { forEach } from 'lodash'
import { SelectHost } from 'select-objects'
@connectStore(() => ({
hosts: createGetObjectsOfType('host')
singleHosts: createSelector(
(_, { pool }) => pool && pool.id,
createGetObjectsOfType('host'),
createCollectionWrapper((poolId, hosts) => {
const visitedPools = {}
const singleHosts = {}
forEach(hosts, host => {
const { $pool } = host
if ($pool !== poolId) {
const previousHost = visitedPools[$pool]
if (previousHost) {
delete singleHosts[previousHost]
} else {
const { id } = host
singleHosts[id] = true
visitedPools[$pool] = id
}
}
})
return singleHosts
})
)
}), { withRef: true })
export default class AddHostModal extends BaseComponent {
get value () {
return this.state
}
_hostPredicate = host =>
host.$pool !== this.props.pool.id &&
every(this.props.hosts, h => h.$pool !== host.$pool || h.id === host.id)
_getHostPredicate = createSelector(
() => this.props.singleHosts,
singleHosts => host => singleHosts[host.id]
)
render () {
return <div>
@@ -27,7 +49,7 @@ export default class AddHostModal extends BaseComponent {
<Col size={6}>
<SelectHost
onChange={this.linkState('host')}
predicate={this._hostPredicate}
predicate={this._getHostPredicate()}
value={this.state.host}
/>
</Col>

View File

@@ -15,14 +15,16 @@ import sortBy from 'lodash/sortBy'
import throttle from 'lodash/throttle'
import Xo from 'xo-lib'
import { createBackoff } from 'jsonrpc-websocket-client'
import { noHostsAvailable } from 'xo-common/api-errors'
import { reflect } from 'promise-toolbox'
import { resolve } from 'url'
import _ from '../intl'
import invoke from '../invoke'
import logError from '../log-error'
import { confirm } from '../modal'
import { alert, confirm } from '../modal'
import { error, info, success } from '../notification'
import { noop, rethrow, tap } from '../utils'
import { noop, rethrow, tap, resolveId, resolveIds } from '../utils'
import {
connected,
disconnected,
@@ -126,6 +128,13 @@ export const connectStore = store => {
sendUpdates()
})
subscribePermissions(permissions => store.dispatch(updatePermissions(permissions)))
// work around to keep the user in Redux store up to date
//
// FIXME: store subscriptions data directly in Redux
subscribeUsers(user => {
store.dispatch(signedIn(xo.user))
})
}
// -------------------------------------------------------------------
@@ -161,14 +170,13 @@ const createSubscription = cb => {
if (!isEqual(result, cache)) {
cache = result
/* FIXME: Edge case:
* 1) MyComponent has a subscription with subscribers[1]
* 2) subscribers[0] causes the MyComponent unmounting (and thus its unsubscription)
* When subscribers[1] will be executed, it will no longer exist,
* which will throw an error (Uncaught (in promise) TypeError: subscriber is not a function)
*/
forEach(subscribers, subscriber => {
subscriber(result)
// A subscriber might have disappeared during iteration.
//
// E.g.: if a subscriber triggers the subscription of another.
if (subscriber) {
subscriber(result)
}
})
}
}, error => {
@@ -215,9 +223,9 @@ export const subscribeAcls = createSubscription(() => _call('acl.get'))
export const subscribeJobs = createSubscription(() => _call('job.getAll'))
export const subscribeJobsLogs = createSubscription(() => _call('log.get', {namespace: 'jobs'}))
export const subscribeJobsLogs = createSubscription(() => _call('log.get', { namespace: 'jobs' }))
export const subscribeApiLogs = createSubscription(() => _call('log.get', {namespace: 'api'}))
export const subscribeApiLogs = createSubscription(() => _call('log.get', { namespace: 'api' }))
export const subscribePermissions = createSubscription(() => _call('acl.getCurrentPermissions'))
@@ -259,6 +267,19 @@ export const subscribeRoles = createSubscription(invoke(
export const subscribeIpPools = createSubscription(() => _call('ipPool.getAll'))
export const subscribeResourceCatalog = createSubscription(() => _call('cloud.getResourceCatalog'))
const xosanSubscriptions = {}
export const subscribeIsInstallingXosan = (pool, cb) => {
const poolId = resolveId(pool)
if (!xosanSubscriptions[poolId]) {
xosanSubscriptions[poolId] = createSubscription(() => _call('xosan.checkSrIsBusy', { poolId }))
}
return xosanSubscriptions[poolId](cb)
}
// System ============================================================
export const apiMethods = _call('system.getMethodsInfo')
@@ -267,23 +288,6 @@ export const serverVersion = _call('system.getServerVersion')
export const getXoServerTimezone = _call('system.getServerTimezone')
// ===================================================================
const resolveId = value =>
(value != null && typeof value === 'object' && 'id' in value)
? value.id
: value
const resolveIds = params => {
for (const key in params) {
const param = params[key]
if (param != null && typeof param === 'object' && 'id' in param) {
params[key] = param.id
}
}
return params
}
// XO --------------------------------------------------------------------------
export const importConfig = config => (
@@ -302,14 +306,14 @@ export const exportConfig = () => (
// Server ------------------------------------------------------------
export const addServer = (host, username, password) => (
_call('server.add', { host, username, password })::tap(
export const addServer = (host, username, password, label) => (
_call('server.add', { host, label, password, username })::tap(
subscribeServers.forceRefresh
)
)::rethrow(() => error(_('serverError'), _('serverAddFailed')))
)
export const editServer = (server, { host, username, password, readOnly }) => (
_call('server.set', { id: resolveId(server), host, username, password, readOnly })::tap(
export const editServer = (server, props) => (
_call('server.set', { ...props, id: resolveId(server) })::tap(
subscribeServers.forceRefresh
)
)
@@ -368,14 +372,14 @@ export const detachHost = host => (
confirm({
icon: 'host-eject',
title: _('detachHostModalTitle'),
body: _('detachHostModalMessage', {host: <strong>{host.name_label}</strong>})
body: _('detachHostModalMessage', { host: <strong>{host.name_label}</strong> })
}).then(
() => _call('host.detach', { host: host.id })
)
)
export const setDefaultSr = sr => (
_call('pool.setDefaultSr', {sr: resolveId(sr)})
_call('pool.setDefaultSr', { sr: resolveId(sr) })
)
// Host --------------------------------------------------------------
@@ -393,18 +397,31 @@ export const restartHost = (host, force = false) => (
title: _('restartHostModalTitle'),
body: _('restartHostModalMessage')
}).then(
() => _call('host.restart', { id: resolveId(host), force }),
() => _call('host.restart', { id: resolveId(host), force }).catch(error => {
if (noHostsAvailable.is(error)) {
alert(_('noHostsAvailableErrorTitle'), _('noHostsAvailableErrorMessage'))
}
}),
noop
)
)
export const restartHosts = (hosts, force) => {
export const restartHosts = (hosts, force = false) => {
const nHosts = size(hosts)
return confirm({
title: _('restartHostsModalTitle', { nHosts }),
body: _('restartHostsModalMessage', { nHosts })
}).then(
() => map(hosts, host => _call('host.restart', { id: resolveId(host), force })),
() => Promise.all(
map(hosts, host =>
_call('host.restart', { id: resolveId(host), force })::reflect()
)
).then(results => {
const nbErrors = filter(results, result => !result.isFulfilled()).length
if (nbErrors) {
return alert(_('failHostBulkRestartTitle'), _('failHostBulkRestartMessage', { failedHosts: nbErrors, totalHosts: results.length }))
}
}),
noop
)
}
@@ -488,6 +505,44 @@ export const installAllPatchesOnPool = pool => (
_call('pool.installAllPatches', { pool: resolveId(pool) })
)
export const installSupplementalPack = (host, file) => {
info(_('supplementalPackInstallStartedTitle'), _('supplementalPackInstallStartedMessage'))
return _call('host.installSupplementalPack', { host: resolveId(host) }).then(({ $sendTo: url }) => (
request.post(url)
.send(file)
.then(res => {
if (res.status !== 200) {
throw new Error('installing supplemental pack failed')
}
success(_('supplementalPackInstallSuccessTitle'), _('supplementalPackInstallSuccessMessage'))
}).catch(err => {
error(_('supplementalPackInstallErrorTitle'), _('supplementalPackInstallErrorMessage'))
throw err
})
))
}
export const installSupplementalPackOnAllHosts = (pool, file) => {
info(_('supplementalPackInstallStartedTitle'), _('supplementalPackInstallStartedMessage'))
return _call('pool.installSupplementalPack', { pool: resolveId(pool) }).then(({ $sendTo: url }) => (
request.post(url)
.send(file)
.then(res => {
if (res.status !== 200) {
throw new Error('installing supplemental pack failed')
}
success(_('supplementalPackInstallSuccessTitle'), _('supplementalPackInstallSuccessMessage'))
}).catch(err => {
error(_('supplementalPackInstallErrorTitle'), _('supplementalPackInstallErrorMessage'))
throw err
})
))
}
// Containers --------------------------------------------------------
export const pauseContainer = (vm, container) => (
@@ -742,7 +797,7 @@ export const createVm = args => (
export const createVms = (args, nameLabels) => (
confirm({
title: _('newVmCreateVms'),
body: _('newVmCreateVmsConfirm', {nbVms: nameLabels.length})
body: _('newVmCreateVmsConfirm', { nbVms: nameLabels.length })
}).then(
() => Promise.all(map(nameLabels, name_label => // eslint-disable-line camelcase
_call('vm.create', { ...args, name_label })
@@ -775,12 +830,12 @@ export const deleteVms = vms => (
)
)
export const importBackup = ({remote, file, sr}) => (
_call('vm.importBackup', resolveIds({remote, file, sr}))
export const importBackup = ({ remote, file, sr }) => (
_call('vm.importBackup', resolveIds({ remote, file, sr }))
)
export const importDeltaBackup = ({remote, file, sr}) => (
_call('vm.importDeltaBackup', resolveIds({remote, filePath: file, sr}))
export const importDeltaBackup = ({ remote, file, sr }) => (
_call('vm.importDeltaBackup', resolveIds({ remote, filePath: file, sr }))
)
import RevertSnapshotModalBody from './revert-snapshot-modal'
@@ -852,7 +907,7 @@ export const setVmBootOrder = (vm, order) => (
})
)
export const attachDiskToVm = (vdi, vm, {bootable, mode, position}) => (
export const attachDiskToVm = (vdi, vm, { bootable, mode, position }) => (
_call('vm.attachDisk', {
bootable,
mode,
@@ -888,6 +943,19 @@ export const deleteVdi = vdi => (
)
)
export const deleteOrphanedVdis = vdis => (
confirm({
title: _('removeAllOrphanedObject'),
body: <div>
<p>{_('removeAllOrphanedModalWarning')}</p>
<p>{_('definitiveMessageModal')}</p>
</div>
}).then(
() => Promise.all(map(resolveIds(vdis), id => _call('vdi.delete', { id }))),
noop
)
)
export const migrateVdi = (vdi, sr) => (
_call('vdi.migrate', { id: resolveId(vdi), sr_id: resolveId(sr) })
)
@@ -917,7 +985,7 @@ export const setBootableVbd = (vbd, bootable) => (
// VIF ---------------------------------------------------------------
export const createVmInterface = (vm, network, mac) => (
_call('vm.createInterface', resolveIds({vm, network, mac}))
_call('vm.createInterface', resolveIds({ vm, network, mac }))
)
export const connectVif = vif => (
@@ -1047,40 +1115,66 @@ export const deleteSr = sr => (
export const forgetSr = sr => (
confirm({
title: 'Forget SR',
body: <div>
<p>Are you sure you want to forget this SR?</p>
<p>VDIs on this storage wont be removed.</p>
</div>
title: _('srForgetModalTitle'),
body: _('srForgetModalMessage')
}).then(
() => _call('sr.forget', { id: resolveId(sr) }),
noop
)
)
export const forgetSrs = srs => (
confirm({
title: _('srsForgetModalTitle'),
body: _('srsForgetModalMessage')
}).then(
() => Promise.all(map(resolveIds(srs), id =>
_call('sr.forget', { id })
)),
noop
)
)
export const reconnectAllHostsSr = sr => (
confirm({
title: 'Reconnect all hosts',
body: <div>
<p>This will reconnect this SR to all its hosts</p>
</div>
title: _('srReconnectAllModalTitle'),
body: _('srReconnectAllModalMessage')
}).then(
() => _call('sr.connectAllPbds', { id: resolveId(sr) }),
noop
)
)
export const reconnectAllHostsSrs = srs => (
confirm({
title: _('srReconnectAllModalTitle'),
body: _('srReconnectAllModalMessage')
}).then(
() => Promise.all(resolveIds(srs), id =>
_call('sr.connectAllPbds', { id })
),
noop
)
)
export const disconnectAllHostsSr = sr => (
confirm({
title: 'Disconnect all hosts',
body: <div>
<p>This will disconnect this SR to all its hosts</p>
</div>
title: _('srDisconnectAllModalTitle'),
body: _('srDisconnectAllModalMessage')
}).then(
() => _call('sr.disconnectAllPbds', { id: resolveId(sr) }),
noop
)
)
export const disconnectAllHostsSrs = srs => (
confirm({
title: _('srDisconnectAllModalTitle'),
body: _('srsDisconnectAllModalMessage')
}).then(
() => Promise.all(resolveIds(srs), id =>
_call('sr.disconnectAllPbds', { id })
),
noop
)
)
export const editSr = (sr, { nameDescription, nameLabel }) => (
_call('sr.set', {
@@ -1093,6 +1187,11 @@ export const editSr = (sr, { nameDescription, nameLabel }) => (
export const rescanSr = sr => (
_call('sr.scan', { id: resolveId(sr) })
)
export const rescanSrs = srs => (
Promise.all(map(resolveIds(srs), id =>
_call('sr.scan', { id })
))
)
// PBDs --------------------------------------------------------------
@@ -1134,7 +1233,36 @@ export const destroyTask = task => (
_call('task.destroy', { id: resolveId(task) })
)
// Backups -----------------------------------------------------------
// Jobs -------------------------------------------------------------
export const createJob = job => (
_call('job.create', { job })::tap(
subscribeJobs.forceRefresh
)
)
export const deleteJob = job => (
_call('job.delete', { id: resolveId(job) })::tap(
subscribeJobs.forceRefresh
)
)
export const editJob = job => (
_call('job.set', { job })::tap(
subscribeJobs.forceRefresh
)
)
export const getJob = id => (
_call('job.get', { id })
)
export const runJob = id => {
info(_('runJob'), _('runJobVerbose'))
return _call('job.runSequence', { idSequence: [id] })
}
// Backup/Schedule ---------------------------------------------------------
export const createSchedule = (jobId, {
cron,
@@ -1147,43 +1275,6 @@ export const createSchedule = (jobId, {
)
)
export const createJob = job => (
_call('job.create', { job })::tap(
subscribeJobs.forceRefresh
)
)
export const runJob = id => {
info(_('runJob'), _('runJobVerbose'))
return _call('job.runSequence', { idSequence: [id] })
}
export const getJob = id => (
_call('job.get', { id })
)
export const setJob = job => (
_call('job.set', { job })::tap(
subscribeJobs.forceRefresh
)
)
export const getSchedule = id => (
_call('schedule.get', { id })
)
export const enableSchedule = id => (
_call('scheduler.enable', { id })::tap(
subscribeScheduleTable.forceRefresh
)
)
export const disableSchedule = id => (
_call('scheduler.disable', { id })::tap(
subscribeScheduleTable.forceRefresh
)
)
export const deleteBackupSchedule = async schedule => {
await confirm({
title: _('deleteBackupSchedule'),
@@ -1196,13 +1287,41 @@ export const deleteBackupSchedule = async schedule => {
subscribeJobs.forceRefresh()
}
export const deleteSchedule = schedule => (
_call('schedule.delete', { id: resolveId(schedule) })::tap(
subscribeSchedules.forceRefresh
)
)
export const disableSchedule = id => (
_call('scheduler.disable', { id })::tap(
subscribeScheduleTable.forceRefresh
)
)
export const editSchedule = ({ id, job: jobId, cron, enabled, name, timezone }) => (
_call('schedule.set', { id, jobId, cron, enabled, name, timezone })::tap(
subscribeSchedules.forceRefresh
)
)
export const enableSchedule = id => (
_call('scheduler.enable', { id })::tap(
subscribeScheduleTable.forceRefresh
)
)
export const getSchedule = id => (
_call('schedule.get', { id })
)
// Plugins -----------------------------------------------------------
export const loadPlugin = async id => (
_call('plugin.load', { id })::tap(
subscribePlugins.forceRefresh
)::rethrow(
err => error(_('pluginError'), JSON.stringify(err.data) || _('unknownPluginError'))
err => error(_('pluginError'), err && err.message || _('unknownPluginError'))
)
)
@@ -1210,7 +1329,7 @@ export const unloadPlugin = id => (
_call('plugin.unload', { id })::tap(
subscribePlugins.forceRefresh
)::rethrow(
err => error(_('pluginError'), JSON.stringify(err.data) || _('unknownPluginError'))
err => error(_('pluginError'), err && err.message || _('unknownPluginError'))
)
)
@@ -1247,6 +1366,9 @@ export const purgePluginConfiguration = async id => {
subscribePlugins.forceRefresh()
}
export const testPlugin = async (id, data) =>
_call('plugin.test', { id, data })
// Resource set ------------------------------------------------------
export const createResourceSet = (name, { subjects, objects, limits } = {}) => (
@@ -1278,67 +1400,89 @@ export const recomputeResourceSetsLimits = () => (
// Remote ------------------------------------------------------------
export const getRemote = remote => (
_call('remote.get', resolveIds({id: remote}))::rethrow(
_call('remote.get', resolveIds({ id: remote }))::rethrow(
err => error(_('getRemote'), err.message || String(err))
)
)
export const createRemote = (name, url) => (
_call('remote.create', {name, url})::tap(
_call('remote.create', { name, url })::tap(
subscribeRemotes.forceRefresh
)
)
export const deleteRemote = remote => (
_call('remote.delete', {id: resolveId(remote)})::tap(
_call('remote.delete', { id: resolveId(remote) })::tap(
subscribeRemotes.forceRefresh
)
)
export const enableRemote = remote => (
_call('remote.set', {id: resolveId(remote), enabled: true})::tap(
_call('remote.set', { id: resolveId(remote), enabled: true })::tap(
subscribeRemotes.forceRefresh
)
)
export const disableRemote = remote => (
_call('remote.set', {id: resolveId(remote), enabled: false})::tap(
_call('remote.set', { id: resolveId(remote), enabled: false })::tap(
subscribeRemotes.forceRefresh
)
)
export const editRemote = (remote, {name, url}) => (
_call('remote.set', resolveIds({remote, name, url}))::tap(
export const editRemote = (remote, { name, url }) => (
_call('remote.set', resolveIds({ remote, name, url }))::tap(
subscribeRemotes.forceRefresh
)
)
export const listRemote = remote => (
_call('remote.list', resolveIds({id: remote}))::tap(
_call('remote.list', resolveIds({ id: remote }))::tap(
subscribeRemotes.forceRefresh
)::rethrow(
err => error(_('listRemote'), err.message || String(err))
)
)
export const listRemoteBackups = remote => (
_call('backup.list', resolveIds({ remote }))::rethrow(
err => error(_('listRemote'), err.message || String(err))
)
)
export const testRemote = remote => (
_call('remote.test', resolveIds({id: remote}))::rethrow(
_call('remote.test', resolveIds({ id: remote }))::rethrow(
err => error(_('testRemote'), err.message || String(err))
)
)
// File restore ----------------------------------------------------
export const scanDisk = (remote, disk) => (
_call('backup.scanDisk', resolveIds({ remote, disk }))
)
export const scanFiles = (remote, disk, path, partition) => (
_call('backup.scanFiles', resolveIds({ remote, disk, path, partition }))
)
export const fetchFiles = (remote, disk, partition, paths, format) => (
_call('backup.fetchFiles', resolveIds({ remote, disk, partition, paths, format })).then(
({ $getFrom: url }) => { window.location = `.${url}` }
)
)
// -------------------------------------------------------------------
export const probeSrNfs = (host, server) => (
_call('sr.probeNfs', {host, server})
_call('sr.probeNfs', { host, server })
)
export const probeSrNfsExists = (host, server, serverPath) => (
_call('sr.probeNfsExists', {host, server, serverPath})
_call('sr.probeNfsExists', { host, server, serverPath })
)
export const probeSrIscsiIqns = (host, target, port = undefined, chapUser = undefined, chapPassword) => {
const params = {host, target}
const params = { host, target }
port && (params.port = port)
chapUser && (params.chapUser = chapUser)
chapPassword && (params.chapPassword = chapPassword)
@@ -1346,14 +1490,14 @@ export const probeSrIscsiIqns = (host, target, port = undefined, chapUser = unde
}
export const probeSrIscsiLuns = (host, target, targetIqn, chapUser = undefined, chapPassword) => {
const params = {host, target, targetIqn}
const params = { host, target, targetIqn }
chapUser && (params.chapUser = chapUser)
chapPassword && (params.chapPassword = chapPassword)
return _call('sr.probeIscsiLuns', params)
}
export const probeSrIscsiExists = (host, target, targetIqn, scsiId, port = undefined, chapUser = undefined, chapPassword) => {
const params = {host, target, targetIqn, scsiId}
const params = { host, target, targetIqn, scsiId }
port && (params.port = port)
chapUser && (params.chapUser = chapUser)
chapPassword && (params.chapPassword = chapPassword)
@@ -1361,21 +1505,21 @@ export const probeSrIscsiExists = (host, target, targetIqn, scsiId, port = undef
}
export const reattachSr = (host, uuid, nameLabel, nameDescription, type) => (
_call('sr.reattach', {host, uuid, nameLabel, nameDescription, type})
_call('sr.reattach', { host, uuid, nameLabel, nameDescription, type })
)
export const reattachSrIso = (host, uuid, nameLabel, nameDescription, type) => (
_call('sr.reattachIso', {host, uuid, nameLabel, nameDescription, type})
_call('sr.reattachIso', { host, uuid, nameLabel, nameDescription, type })
)
export const createSrNfs = (host, nameLabel, nameDescription, server, serverPath, nfsVersion = undefined) => {
const params = {host, nameLabel, nameDescription, server, serverPath}
const params = { host, nameLabel, nameDescription, server, serverPath }
nfsVersion && (params.nfsVersion = nfsVersion)
return _call('sr.createNfs', params)
}
export const createSrIscsi = (host, nameLabel, nameDescription, target, targetIqn, scsiId, port = undefined, chapUser = undefined, chapPassword = undefined) => {
const params = {host, nameLabel, nameDescription, target, targetIqn, scsiId}
const params = { host, nameLabel, nameDescription, target, targetIqn, scsiId }
port && (params.port = port)
chapUser && (params.chapUser = chapUser)
chapPassword && (params.chapPassword = chapPassword)
@@ -1383,20 +1527,20 @@ export const createSrIscsi = (host, nameLabel, nameDescription, target, targetIq
}
export const createSrIso = (host, nameLabel, nameDescription, path, type, user = undefined, password = undefined) => {
const params = {host, nameLabel, nameDescription, path, type}
const params = { host, nameLabel, nameDescription, path, type }
user && (params.user = user)
password && (params.password = password)
return _call('sr.createIso', params)
}
export const createSrLvm = (host, nameLabel, nameDescription, device) => (
_call('sr.createLvm', {host, nameLabel, nameDescription, device})
_call('sr.createLvm', { host, nameLabel, nameDescription, device })
)
// Job logs ----------------------------------------------------------
export const deleteJobsLog = id => (
_call('log.delete', {namespace: 'jobs', id})::tap(
_call('log.delete', { namespace: 'jobs', id })::tap(
subscribeJobsLogs.forceRefresh
)
)
@@ -1404,23 +1548,23 @@ export const deleteJobsLog = id => (
// Logs
export const deleteApiLog = id => (
_call('log.delete', {namespace: 'api', id})::tap(
_call('log.delete', { namespace: 'api', id })::tap(
subscribeApiLogs.forceRefresh
)
)
// Acls, users, groups ----------------------------------------------------------
export const addAcl = ({subject, object, action}) => (
_call('acl.add', resolveIds({subject, object, action}))::tap(
export const addAcl = ({ subject, object, action }) => (
_call('acl.add', resolveIds({ subject, object, action }))::tap(
subscribeAcls.forceRefresh
)::rethrow(
err => error('Add ACL', err.message || String(err))
)
)
export const removeAcl = ({subject, object, action}) => (
_call('acl.remove', resolveIds({subject, object, action}))::tap(
export const removeAcl = ({ subject, object, action }) => (
_call('acl.remove', resolveIds({ subject, object, action }))::tap(
subscribeAcls.forceRefresh
)::rethrow(
err => error('Remove ACL', err.message || String(err))
@@ -1435,14 +1579,14 @@ export const editAcl = (
action: newAction = action
}
) => (
_call('acl.remove', resolveIds({subject, object, action}))
.then(() => _call('acl.add', resolveIds({subject: newSubject, object: newObject, action: newAction})))
_call('acl.remove', resolveIds({ subject, object, action }))
.then(() => _call('acl.add', resolveIds({ subject: newSubject, object: newObject, action: newAction })))
::tap(subscribeAcls.forceRefresh)
::rethrow(err => error('Edit ACL', err.message || String(err)))
)
export const createGroup = name => (
_call('group.create', {name})::tap(
_call('group.create', { name })::tap(
subscribeGroups.forceRefresh
):: rethrow(
err => error(_('createGroup'), err.message || String(err))
@@ -1450,7 +1594,7 @@ export const createGroup = name => (
)
export const setGroupName = (group, name) => (
_call('group.set', resolveIds({group, name}))::tap(
_call('group.set', resolveIds({ group, name }))::tap(
subscribeGroups.forceRefresh
)
)
@@ -1459,13 +1603,13 @@ export const deleteGroup = group => (
confirm({
title: _('deleteGroup'),
body: <p>{_('deleteGroupConfirm')}</p>
}).then(() => _call('group.delete', resolveIds({id: group})))
}).then(() => _call('group.delete', resolveIds({ id: group })))
::tap(subscribeGroups.forceRefresh)
::rethrow(err => error(_('deleteGroup'), err.message || String(err)))
)
export const removeUserFromGroup = (user, group) => (
_call('group.removeUser', resolveIds({id: group, userId: user}))::tap(
_call('group.removeUser', resolveIds({ id: group, userId: user }))::tap(
subscribeGroups.forceRefresh
)::rethrow(
err => error(_('removeUserFromGroup'), err.message || String(err))
@@ -1473,7 +1617,7 @@ export const removeUserFromGroup = (user, group) => (
)
export const addUserToGroup = (user, group) => (
_call('group.addUser', resolveIds({id: group, userId: user}))::tap(
_call('group.addUser', resolveIds({ id: group, userId: user }))::tap(
subscribeGroups.forceRefresh
)::rethrow(
err => error('Add User', err.message || String(err))
@@ -1481,7 +1625,7 @@ export const addUserToGroup = (user, group) => (
)
export const createUser = (email, password, permission) => (
_call('user.create', {email, password, permission})::tap(
_call('user.create', { email, password, permission })::tap(
subscribeUsers.forceRefresh
)::rethrow(
err => error('Create user', err.message || String(err))
@@ -1643,32 +1787,6 @@ export const setDefaultHomeFilter = (type, name) => {
})
}
// Jobs ----------------------------------------------------------
export const deleteJob = job => (
_call('job.delete', { id: resolveId(job) })::tap(
subscribeJobs.forceRefresh
)
)
export const deleteSchedule = schedule => (
_call('schedule.delete', { id: resolveIds(schedule) })::tap(
subscribeSchedules.forceRefresh
)
)
export const updateJob = job => (
_call('job.set', {job})::tap(
subscribeJobs.forceRefresh
)
)
export const updateSchedule = ({ id, job: jobId, cron, enabled, name, timezone }) => (
_call('schedule.set', { id, jobId, cron, enabled, name, timezone })::tap(
subscribeSchedules.forceRefresh
)
)
// IP pools --------------------------------------------------------------------
export const createIpPool = ({ name, ips, networks }) => {
@@ -1690,3 +1808,30 @@ export const setIpPool = (ipPool, { name, addresses, networks }) => (
subscribeIpPools.forceRefresh
)
)
// XO SAN ----------------------------------------------------------------------
export const getVolumeInfo = (xosanSr) => _call('xosan.getVolumeInfo', { sr: xosanSr })
export const createXosanSR = ({ template, pif, vlan, srs, glusterType, redundancy }) => _call('xosan.createSR', {
template,
pif: pif.id,
vlan: String(vlan),
srs: resolveIds(srs),
glusterType,
redundancy: Number.parseInt(redundancy)
})
export const computeXosanPossibleOptions = lvmSrs => _call('xosan.computeXosanPossibleOptions', { lvmSrs })
import InstallXosanPackModal from './install-xosan-pack-modal'
export const downloadAndInstallXosanPack = pool =>
confirm({
title: _('xosanInstallPackTitle', { pool: pool.name_label }),
icon: 'export',
body: <InstallXosanPackModal pool={pool} />
}).then(
pack => _call('xosan.downloadAndInstallXosanPack', { id: pack.id, version: pack.version, pool: resolveId(pool) })
)
export const registerXosan = namespace => _call('cloud.registerResource', { namespace: 'xosan' })

View File

@@ -0,0 +1,103 @@
import _ from 'intl'
import Component from 'base-component'
import React from 'react'
import { connectStore, compareVersions } from 'utils'
import { subscribeResourceCatalog, subscribePlugins } from 'xo'
import { createGetObjectsOfType, createSelector, createCollectionWrapper } from 'selectors'
import { satisfies as versionSatisfies } from 'semver'
import {
every,
filter,
forEach,
map
} from 'lodash'
const findLatestPack = (packs, hostsVersions) => {
const checkVersion = version =>
every(hostsVersions, hostVersion => versionSatisfies(hostVersion, version))
let latestPack = { version: '0' }
forEach(packs, pack => {
const xsVersionRequirement = pack.requirements && pack.requirements.xenserver
if (
pack.type === 'iso' &&
compareVersions(pack.version, latestPack.version) > 0 &&
(!xsVersionRequirement || checkVersion(xsVersionRequirement))
) {
latestPack = pack
}
})
if (latestPack.version === '0') {
// No compatible pack was found
return
}
return latestPack
}
@connectStore({
hosts: createGetObjectsOfType('host').filter(
(_, { pool }) => host => pool && host.$pool === pool.id && !host.supplementalPacks['vates:XOSAN']
)
}, { withRef: true })
export default class InstallXosanPackModal extends Component {
componentDidMount () {
this._unsubscribePlugins = subscribePlugins(plugins => this.setState({ plugins }))
this._unsubscribeResourceCatalog = subscribeResourceCatalog(catalog => this.setState({ catalog }))
}
componentWillUnmount () {
this._unsubscribePlugins()
this._unsubscribeResourceCatalog()
}
_getXosanLatestPack = createSelector(
() => this.state.catalog && this.state.catalog.xosan,
createSelector(
() => this.props.hosts,
createCollectionWrapper(hosts => map(hosts, 'version'))
),
findLatestPack
)
_getXosanPacks = createSelector(
() => this.state.catalog && this.state.catalog.xosan,
packs => filter(packs, ({ type }) => type === 'iso')
)
get value () {
return this._getXosanLatestPack()
}
render () {
const { hosts } = this.props
const latestPack = this._getXosanLatestPack()
return <div>
{latestPack
? <div>
{_('xosanInstallPackOnHosts')}
<ul>
{map(hosts, host => <li key={host.id}>{host.name_label}</li>)}
</ul>
<div className='mt-1'>
{_('xosanInstallPack', { pack: latestPack.name, version: latestPack.version })}
</div>
</div>
: <div>
<p>{_('xosanNoPackFound')}</p>
<p>
{_('xosanPackRequirements')}
<ul>
{map(this._getXosanPacks(), ({ name, requirements }) => <li>
{name}: <strong>{requirements && requirements.xenserver ? requirements.xenserver : '/'}</strong>
</li>)}
</ul>
</p>
</div>
}
</div>
}
}

View File

@@ -1,14 +1,28 @@
import forEach from 'lodash/forEach'
import {
forEach,
includes,
map
} from 'lodash'
export const getDefaultNetworkForVif = (vif, destHost, pifs, networks) => {
const originNetwork = networks[vif.$network]
const originVlans = map(originNetwork.PIFs, pifId => pifs[pifId].vlan)
let destNetworkId = pifs[destHost.$PIFs[0]].$network
forEach(destHost.$PIFs, pifId => {
const { $network, vlan } = pifs[pifId]
if (networks[$network].name_label === originNetwork.name_label) {
destNetworkId = $network
export const getDefaultNetworkForVif = (vif, host, pifs, networks) => {
const nameLabel = networks[vif.$network].name_label
let defaultNetwork
forEach(host.$PIFs, pifId => {
const pif = pifs[pifId]
if (pif.ip && networks[pif.$network].name_label === nameLabel) {
defaultNetwork = pif.$network
return false
}
if (vlan !== -1 && includes(originVlans, vlan)) {
destNetworkId = $network
}
})
return defaultNetwork
return destNetworkId
}

View File

@@ -1,5 +1,5 @@
import assign from 'lodash/assign'
import Client from 'jsonrpc-websocket-client'
import Client, {AbortedConnection, ConnectionError} from 'jsonrpc-websocket-client'
import eventToPromise from 'event-to-promise'
import forEach from 'lodash/forEach'
import makeError from 'make-error'
@@ -52,7 +52,7 @@ function getCurrentUrl () {
}
function adaptUrl (url, port = null) {
const matches = /^http(s?):\/\/([^\/:]*(?::[^\/]*)?)(?:[^:]*)?$/.exec(url)
const matches = /^http(s?):\/\/([^/:]*(?::[^/]*)?)(?:[^:]*)?$/.exec(url)
if (!matches || !matches[2]) {
throw new Error('current URL not recognized')
}
@@ -77,7 +77,7 @@ class XoaUpdater extends EventEmitter {
state (state) {
this._state = state
this.emit(state)
this.emit(state, this._lowState && this._lowState.source)
}
async update () {
@@ -99,12 +99,21 @@ class XoaUpdater extends EventEmitter {
}
_upgradeSuccessful () {
this.emit('upgradeSuccessful')
this.emit('upgradeSuccessful', this._lowState && this._lowState.source)
}
async _open () {
const openFailure = error => {
this.log('error', error)
switch (true) {
case error instanceof AbortedConnection:
this.log('error', 'AbortedConnection')
break
case error instanceof ConnectionError:
this.log('error', 'ConnectionError')
break
default:
this.log('error', error)
}
delete this._client
this.state('disconnected')
throw error
@@ -208,7 +217,7 @@ class XoaUpdater extends EventEmitter {
return c
} else {
return eventToPromise.multi(c, ['open'], ['closed', 'error'])
.then(() => handleOpen(c), openFailure)
.then(() => c)
}
}

View File

@@ -9,20 +9,23 @@ import { connectStore, getXoaPlan } from './utils'
import { isAdmin } from 'selectors'
const Upgrade = propTypes({
available: propTypes.number.isRequired,
place: propTypes.string.isRequired
available: propTypes.number,
place: propTypes.string.isRequired,
required: propTypes.number
})(connectStore({
isAdmin
}))(({
available,
children,
isAdmin,
place
}) => (
<Card>
place,
required = available
}) => process.env.XOA_PLAN < required
? <Card>
<CardHeader>{_('upgradeNeeded')}</CardHeader>
{isAdmin
? <CardBlock className='text-xs-center'>
<p>{_('availableIn', {plan: getXoaPlan(available)})}</p>
<p>{_('availableIn', {plan: getXoaPlan(required)})}</p>
<p>
<a href={`https://xen-orchestra.com/#!/pricing?pk_campaign=xoa_${getXoaPlan()}_upgrade&pk_kwd=${place}`} className='btn btn-primary btn-lg'>
<Icon icon='plan-upgrade' /> {_('upgradeNow')}
@@ -37,6 +40,7 @@ const Upgrade = propTypes({
</CardBlock>
}
</Card>
))
: children
)
export { Upgrade as default }

View File

@@ -128,6 +128,10 @@
@extend .fa;
@extend .fa-map-marker;
}
&-file {
@extend .fa;
@extend .fa-file-o;
}
&-shown {
@extend .fa;
@@ -195,7 +199,7 @@
&-cpu {
@extend .fa;
@extend .fa-dashboard;
@extend .fa-microchip;
}
&-memory {
@extend .fa;
@@ -371,6 +375,24 @@
@extend .xo-status-busy;
}
&-all-connected {
@extend .fa;
@extend .fa-circle;
@extend .xo-status-running;
}
&-some-connected {
@extend .fa;
@extend .fa-circle;
@extend .xo-status-busy;
}
&-all-disconnected {
@extend .fa;
@extend .fa-circle;
@extend .xo-status-halted;
}
// Task
&-task {
&-cancel {
@@ -656,6 +678,10 @@
@extend .fa;
@extend .fa-upload;
}
&-file-restore {
@extend .fa;
@extend .fa-file-o;
}
}
&-menu-jobs {
@extend .fa;
@@ -729,6 +755,10 @@
@extend .fa-file-archive-o;
}
}
&-menu-xosan {
@extend .fa;
@extend .fa-database;
}
// New VM
&-new-vm {
&-infos {

View File

@@ -1,3 +1,5 @@
import './patch-react'
import DevTools from 'store/dev-tools'
import hashHistory from 'react-router/lib/hashHistory'
import React from 'react'
@@ -8,15 +10,6 @@ import { render } from 'react-dom'
import XoApp from './xo-app'
if (
typeof window !== 'undefined' &&
typeof window.addEventListener === 'function'
) {
window.addEventListener('unhandledRejection', reason => {
console.error(reason)
})
}
render(
<Provider store={store}>
<div>

View File

@@ -21,7 +21,6 @@ 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}

View File

@@ -6,6 +6,7 @@ const keymap = {
GO_TO_HOSTS: 'g h',
GO_TO_POOLS: 'g p',
GO_TO_VMS: 'g v',
GO_TO_SRS: 'g s',
CREATE_VM: 'c v',
UNFOCUS: 'esc',
HELP: ['?', 'h']

42
src/patch-react.js Normal file
View File

@@ -0,0 +1,42 @@
import logError from 'log-error'
import React from 'react'
import { assign, isFunction } from 'lodash'
// Avoid global breakage if a component fails to render.
//
// Inspired by https://gist.github.com/Aldredcz/4d63b0a9049b00f54439f8780be7f0d8
React.createElement = (createElement => {
const errorComponent = <p className='text-danger' style={{
fontWeight: 'bold'
}}>an error has occured</p>
const wrapRender = render => function patchedRender () {
try {
return render.apply(this, arguments)
} catch (error) {
logError(error)
return errorComponent
}
}
return function (Component) {
if (isFunction(Component)) {
const patched = Component._patched
if (patched) {
arguments[0] = patched
} else {
const { prototype } = Component
let render
if (prototype && isFunction(render = prototype.render)) {
prototype.render = wrapRender(render)
Component._patched = Component // itself
} else {
arguments[0] = Component._patched = assign(wrapRender(Component), Component)
}
}
}
return createElement.apply(this, arguments)
}
})(React.createElement)

View File

@@ -15,7 +15,7 @@ import pkg from '../../../package'
const HEADER = <Container>
<Row>
<Col mediumSize={12}>
<h2><Icon icon='menu-about' /> {_('aboutPage')} XO {getXoaPlan()}</h2>
<h2><Icon icon='menu-about' /> {_('aboutXoaPlan', { xoaPlan: getXoaPlan() })}</h2>
</Col>
</Row>
</Container>
@@ -41,13 +41,13 @@ export default class About extends Component {
<Copiable tagName='h4' data={`xo-server ${this.state.serverVersion}`}>
xo-server {this.state.serverVersion || 'unknown'}
</Copiable>
<p className='text-muted'>{_('xenOrchestra')} {_('xenOrchestraServer')}</p>
<p className='text-muted'>{_('xenOrchestraServer')}</p>
</Col>
<Col mediumSize={6}>
<Icon icon='vm' size={4} />
<Copiable tagName='h4' data={`xo-web ${pkg.version}`}>
xo-web {pkg.version}</Copiable>
<p className='text-muted'>{_('xenOrchestra')} {_('xenOrchestraWeb')}</p>
<p className='text-muted'>{_('xenOrchestraWeb')}</p>
</Col>
</Row>
}
@@ -57,7 +57,7 @@ export default class About extends Component {
<Col>
<h2 className='text-danger'>{_('noProSupport')}</h2>
<h4 className='text-danger'>{_('noProductionUse')}</h4>
<p className='text-muted'>{_('downloadXoa')} <a href='https://xen-orchestra.com/#!/?pk_campaign=xoa_source_upgrade&pk_kwd=about'>http://xen-orchestra.com</a></p>
<p className='text-muted'>{_('downloadXoaFromWebsite', { website: <a href='https://xen-orchestra.com/#!/?pk_campaign=xoa_source_upgrade&pk_kwd=about'>http://xen-orchestra.com</a> })}</p>
</Col>
</Row>
<Row>

View File

@@ -0,0 +1,130 @@
import _ from 'intl'
import Component from 'base-component'
import Icon from 'icon'
import React from 'react'
import SortedTable from 'sorted-table'
import Upgrade from 'xoa-upgrade'
import { confirm } from 'modal'
import { addSubscriptions, noop } from 'utils'
import { Container, Row, Col } from 'grid'
import { error } from 'notification'
import { FormattedDate } from 'react-intl'
import {
find,
filter,
forEach,
groupBy,
isEmpty,
map,
mapValues,
reduce,
uniq
} from 'lodash'
import {
fetchFiles,
listRemoteBackups,
subscribeRemotes
} from 'xo'
import RestoreFileModalBody from './restore-file-modal'
const VM_COLUMNS = [
{
name: _('backupVmNameColumn'),
itemRenderer: ({ last }) => last.name,
sortCriteria: ({ last }) => last.name
},
{
name: _('backupTags'),
itemRenderer: ({ tagsByRemote }) => <Container>
{map(tagsByRemote, ({ tags, remoteName }) => <Row>
<Col mediumSize={3}><strong>{remoteName}</strong></Col>
<Col mediumSize={9}>{tags.join(', ')}</Col>
</Row>)}
</Container>
},
{
name: _('lastBackupColumn'),
itemRenderer: ({ last }) => <FormattedDate value={last.datetime * 1e3} month='long' day='numeric' year='numeric' hour='2-digit' minute='2-digit' second='2-digit' />,
sortCriteria: ({ last }) => last.datetime,
sortOrder: 'desc'
},
{
name: _('availableBackupsColumn'),
itemRenderer: ({ count }) => <span>{count}</span>,
sortCriteria: ({ count }) => count
}
]
const openImportModal = ({ backups }) => confirm({
title: _('restoreFilesFromBackup', {name: backups[0].name}),
body: <RestoreFileModalBody vmName={backups[0].name} backups={backups} />
}).then(
({ remote, disk, partition, paths, format }) => {
if (!remote || !disk || !paths || !paths.length) {
return error(_('restoreFiles'), _('restoreFilesError'))
}
return fetchFiles(remote, disk, partition, paths, format)
},
noop
)
const _listAllBackups = async remotes => {
const remotesBackups = await Promise.all(map(remotes, remote => listRemoteBackups(remote)))
const backupsByVm = {}
forEach(remotesBackups, (backups, index) => {
forEach(backups, backup => {
if (backup.disks) {
const remote = remotes[index]
backupsByVm[backup.name] || (backupsByVm[backup.name] = [])
backupsByVm[backup.name].push({
...backup,
remoteId: remote.id,
remoteName: remote.name
})
}
})
})
const backupInfoByVm = mapValues(backupsByVm, backups => ({
backups,
count: backups.length,
last: reduce(backups, (last, b) => b.datetime > last.datetime ? b : last),
tagsByRemote: mapValues(groupBy(backups, 'remoteId'), (backups, remoteId) => ({
remoteName: find(remotes, remote => remote.id === remoteId).name,
tags: uniq(map(backups, 'tag'))
}))
}))
return backupInfoByVm
}
@addSubscriptions({
backupInfoByVm: cb => subscribeRemotes(remotes =>
_listAllBackups(filter(remotes, 'enabled')).then(cb)
)
})
export default class FileRestore extends Component {
render () {
const { backupInfoByVm } = this.props
if (!backupInfoByVm) {
return <h2>{_('statusLoading')}</h2>
}
return process.env.XOA_PLAN > 3
? <Container>
<h2>{_('restoreFiles')}</h2>
{isEmpty(backupInfoByVm)
? _('noBackup')
: <div>
<em><Icon icon='info' /> {_('restoreBackupsInfo')}</em>
<SortedTable collection={backupInfoByVm} columns={VM_COLUMNS} rowAction={openImportModal} defaultColumn={2} />
</div>
}
</Container>
: <Container><Upgrade place='restoreFiles' available={4} /></Container>
}
}

View File

@@ -0,0 +1,350 @@
import _ from 'intl'
import ActionButton from 'action-button'
import Component from 'base-component'
import endsWith from 'lodash/endsWith'
import Icon from 'icon'
import React from 'react'
import replace from 'lodash/replace'
import Tooltip from 'tooltip'
import { Container, Col, Row } from 'grid'
import { formatSize } from 'utils'
import { FormattedDate } from 'react-intl'
import { SelectPlainObject } from 'form'
import {
find,
isEmpty,
map,
filter
} from 'lodash'
import {
scanDisk,
scanFiles
} from 'xo'
const backupOptionRenderer = backup => <span>
{backup.tag} - {backup.remoteName}
{' '}
(<FormattedDate value={backup.datetime * 1e3} month='long' day='numeric' year='numeric' hour='2-digit' minute='2-digit' second='2-digit' />)
</span>
const partitionOptionRenderer = partition => <span>
{partition.name} {partition.type} {partition.size && `(${formatSize(+partition.size)})`}
</span>
const diskOptionRenderer = disk => <span>
{disk.name}
</span>
const fileOptionRenderer = file => <span>
{file.name}
</span>
const formatFilesOptions = (rawFiles, path) => {
const files = path !== '/'
? [{
name: '..',
id: '..',
path: getParentPath(path),
content: {}
}]
: []
return files.concat(map(rawFiles, (file, name) => ({
name,
id: `${path}${name}`,
path: `${path}${name}`,
content: file
})))
}
const getParentPath = path => replace(path, /^(\/+.+)*(\/+.+)/, '$1/')
// -----------------------------------------------------------------------------
export default class RestoreFileModalBody extends Component {
state = {
format: 'zip'
}
get value () {
const { state } = this
return {
disk: state.disk,
format: state.format,
partition: state.partition,
paths: state.selectedFiles && map(state.selectedFiles, 'path'),
remote: state.backup.remoteId
}
}
_scanFiles = () => {
const { backup, disk, partition, path } = this.state
this.setState({ scanningFiles: true })
return scanFiles(backup.remoteId, disk, path, partition).then(
rawFiles => this.setState({
files: formatFilesOptions(rawFiles, path),
scanningFiles: false
}),
error => {
this.setState({
scanningFiles: false,
scanFilesError: true
})
throw error
}
)
}
_onBackupChange = backup => {
this.setState({
backup,
disk: undefined,
partition: undefined,
file: undefined,
selectedFiles: undefined,
scanDiskError: false
})
}
_onDiskChange = disk => {
this.setState({
partition: undefined,
file: undefined,
selectedFiles: undefined,
scanDiskError: false
})
if (!disk) {
return
}
scanDisk(this.state.backup.remoteId, disk).then(
({ partitions }) => {
if (isEmpty(partitions)) {
this.setState({
disk,
path: '/'
}, this._scanFiles)
return
}
this.setState({
disk,
partitions
})
},
error => {
this.setState({
disk,
scanDiskError: true
})
throw error
}
)
}
_onPartitionChange = partition => {
this.setState({
partition,
path: '/',
file: undefined,
selectedFiles: undefined
}, partition && this._scanFiles)
}
_onFileChange = file => {
const { path, selectedFiles } = this.state
const isFile = file && file.id !== '..' && !endsWith(file.path, '/')
if (isFile) {
this.setState({
file,
selectedFiles: find(selectedFiles, { id: file.id })
? selectedFiles
: (selectedFiles || []).concat(file)
})
return
}
this.setState({
file: undefined
})
// Ugly workaround to keep the ReactSelect open after selecting a folder
// FIXME: Remove and use isOpen/alwaysOpen prop once one of these issues is fixed:
// https://github.com/JedWatson/react-select/issues/662 -> /pull/817
// https://github.com/JedWatson/react-select/issues/962 -> /pull/1015
const select = document.activeElement
select.blur()
select.focus()
if (file) {
this.setState({
path: file.id === '..' ? getParentPath(path) : file.path
}, this._scanFiles)
}
}
_unselectFile = file => {
this.setState({
selectedFiles: filter(this.state.selectedFiles, ({ id }) => id !== file.id)
})
}
_unselectAllFiles = () => {
this.setState({
selectedFiles: undefined
})
}
_selectAllFolderFiles = () => {
const { files, selectedFiles } = this.state
this.setState({
selectedFiles: (selectedFiles || []).concat(
filter(files, ({ path }) =>
!endsWith(path, '/') && !find(selectedFiles, file => file.path === path)
)
)
})
}
// ---------------------------------------------------------------------------
render () {
const { backups } = this.props
const {
backup,
disk,
file,
files,
format,
partition,
partitions,
path,
scanDiskError,
scanFilesError,
scanningFiles,
selectedFiles
} = this.state
const noPartitions = isEmpty(partitions)
return <div>
<SelectPlainObject
onChange={this._onBackupChange}
optionKey='id'
optionRenderer={backupOptionRenderer}
options={backups}
placeholder={_('restoreFilesSelectBackup')}
value={backup}
/>
{backup && [
<br />,
<SelectPlainObject
onChange={this._onDiskChange}
optionKey='id'
optionRenderer={diskOptionRenderer}
options={backup.disks}
placeholder={_('restoreFilesSelectDisk')}
value={disk}
/>
]}
{scanDiskError &&
<span>
<Icon icon='error' /> {_('restoreFilesDiskError')}
</span>
}
{disk && !scanDiskError && !noPartitions && [
<br />,
<SelectPlainObject
onChange={this._onPartitionChange}
optionKey='id'
optionRenderer={partitionOptionRenderer}
options={partitions}
placeholder={_('restoreFilesSelectPartition')}
value={partition}
/>
]}
{(partition || disk && !scanDiskError && noPartitions) && [
<br />,
<Container>
<Row>
<Col size={10}>
<pre>
{path} {scanningFiles && <Icon icon='loading' />}{scanFilesError && <Icon icon='error' />}
</pre>
</Col>
<Col size={2}>
<span className='pull-right'>
<Tooltip content={_('restoreFilesSelectAllFiles')}>
<ActionButton btnStyle='secondary' handler={this._selectAllFolderFiles} icon='add' size='small' />
</Tooltip>
</span>
</Col>
</Row>
</Container>,
<SelectPlainObject
onChange={this._onFileChange}
optionKey='id'
optionRenderer={fileOptionRenderer}
options={files}
placeholder={_('restoreFilesSelectFiles')}
value={file}
/>,
<br />,
<div>
<span className='mr-1'>
<input
checked={format === 'zip'}
name='format'
onChange={this.linkState('format')}
type='radio'
value='zip'
/> ZIP
</span>
<span>
<input
checked={format === 'tar'}
name='format'
onChange={this.linkState('format')}
type='radio'
value='tar'
/> TAR
</span>
</div>,
<br />,
selectedFiles && selectedFiles.length
? <Container>
<Row>
<Col className='pl-0 pb-1' size={10}>
<em>{_('restoreFilesSelectedFiles', { files: selectedFiles.length })}</em>
</Col>
<Col size={2}>
<span className='pull-right'>
<Tooltip content={_('restoreFilesUnselectAll')}>
<ActionButton btnStyle='secondary' handler={this._unselectAllFiles} icon='remove' size='small' />
</Tooltip>
</span>
</Col>
</Row>
{map(selectedFiles, file =>
<Row key={file.id}>
<Col size={10}>
<pre>{file.path}</pre>
</Col>
<Col size={2}>
<span className='pull-right'>
<ActionButton btnStyle='secondary' handler={this._unselectFile} handlerParam={file} icon='remove' size='small' />
</span>
</Col>
</Row>
)}
</Container>
: <em>{_('restoreFilesNoFilesSelected')}</em>
]}
</div>
}
}

View File

@@ -10,6 +10,7 @@ import Edit from './edit'
import New from './new'
import Overview from './overview'
import Restore from './restore'
import FileRestore from './file-restore'
const HEADER = <Container>
<Row>
@@ -21,6 +22,7 @@ const HEADER = <Container>
<NavLink to={'/backup/overview'}><Icon icon='menu-backup-overview' /> {_('backupOverviewPage')}</NavLink>
<NavLink to={'/backup/new'}><Icon icon='menu-backup-new' /> {_('backupNewPage')}</NavLink>
<NavLink to={'/backup/restore'}><Icon icon='menu-backup-restore' /> {_('backupRestorePage')}</NavLink>
<NavLink to={'/backup/file-restore'}><Icon icon='menu-backup-file-restore' /> {_('backupFileRestorePage')}</NavLink>
</NavTabs>
</Col>
</Row>
@@ -30,7 +32,8 @@ const Backup = routes('overview', {
':id/edit': Edit,
new: New,
overview: Overview,
restore: Restore
restore: Restore,
'file-restore': FileRestore
})(
({ children }) => <Page header={HEADER} title='backupPage' formatTitle>{children}</Page>
)

View File

@@ -5,23 +5,28 @@ import delay from 'lodash/delay'
import forEach from 'lodash/forEach'
import GenericInput from 'json-schema-input'
import Icon from 'icon'
import isEmpty from 'lodash/isEmpty'
import map from 'lodash/map'
import moment from 'moment-timezone'
import React from 'react'
import Scheduler, { SchedulePreview } from 'scheduling'
import startsWith from 'lodash/startsWith'
import Upgrade from 'xoa-upgrade'
import Wizard, { Section } from 'wizard'
import { Container, Row, Col } from 'grid'
import { addSubscriptions } from 'utils'
import { confirm } from 'modal'
import { error } from 'notification'
import { generateUiSchema } from 'xo-json-schema-input'
import { confirm } from 'modal'
import { SelectSubject } from 'select-objects'
import { Container, Row, Col } from 'grid'
import {
createJob,
createSchedule,
getRemote,
setJob,
updateSchedule
editJob,
editSchedule,
subscribeCurrentUser
} from 'xo'
// ===================================================================
@@ -53,25 +58,48 @@ const SMART_SCHEMA = {
title: _('editBackupSmartStatusTitle'),
description: 'The statuses of VMs to backup.' // FIXME: can't translate
},
pools: {
type: 'array',
items: {
type: 'string',
'xo:type': 'pool'
},
title: _('editBackupSmartResidentOn')
poolsOptions: {
type: 'object',
title: _('editBackupSmartPools'),
properties: {
not: {
type: 'boolean',
title: _('editBackupNot'),
description: 'Toggle on to backup VMs that are NOT resident on these pools'
},
pools: {
type: 'array',
items: {
type: 'string',
'xo:type': 'pool'
},
title: _('editBackupSmartResidentOn'),
description: 'Not used if empty.' // FIXME: can't translate
}
}
},
tags: {
type: 'array',
items: {
type: 'string',
'xo:type': 'tag'
},
title: _('editBackupSmartTagsTitle'),
description: 'VMs which contains at least one of these tags. Not used if empty.' // FIXME: can't translate
tagsOptions: {
type: 'object',
title: _('editBackupSmartTags'),
properties: {
not: {
type: 'boolean',
title: _('editBackupNot'),
description: 'Toggle on to backup VMs that do NOT contain these tags'
},
tags: {
type: 'array',
items: {
type: 'string',
'xo:type': 'tag'
},
title: _('editBackupSmartTagsTitle'),
description: 'VMs which contain at least one of these tags. Not used if empty.' // FIXME: can't translate
}
}
}
},
required: [ 'status', 'pools' ]
required: [ 'status', 'poolsOptions', 'tagsOptions' ]
}
const SMART_UI_SCHEMA = generateUiSchema(SMART_SCHEMA)
@@ -86,9 +114,14 @@ const COMMON_SCHEMA = {
description: 'Back-up tag.' // FIXME: can't translate
},
_reportWhen: {
default: 'failure',
enum: [ 'never', 'always', 'failure' ], // FIXME: can't translate
title: _('editBackupReportTitle'),
description: 'When to send reports.' // FIXME: can't translate
description: [
'When to send reports.',
'',
'Plugins *tranport-email* and *backup-reports* need to be configured.'
].join('\n')
},
enabled: {
type: 'boolean',
@@ -101,7 +134,8 @@ const COMMON_SCHEMA = {
const DEPTH_PROPERTY = {
type: 'integer',
title: _('editBackupDepthTitle'),
description: 'How many backups to rollover.' // FIXME: can't translate
description: 'How many backups to rollover.', // FIXME: can't translate
min: 1
}
const REMOTE_PROPERTY = {
@@ -116,11 +150,6 @@ const BACKUP_SCHEMA = {
...COMMON_SCHEMA.properties,
depth: DEPTH_PROPERTY,
remoteId: REMOTE_PROPERTY,
onlyMetadata: {
type: 'boolean',
title: 'Only MetaData',
description: 'No disks export.'
},
compress: {
type: 'boolean',
title: 'Enable compression',
@@ -234,23 +263,47 @@ const BACKUP_METHOD_TO_INFO = {
const DEFAULT_CRON_PATTERN = '0 0 * * *'
function negatePattern (pattern, not = true) {
return not
? { __not: pattern }
: pattern
}
@addSubscriptions({
currentUser: subscribeCurrentUser
})
export default class New extends Component {
constructor (props) {
super(props)
this.state.cronPattern = DEFAULT_CRON_PATTERN
}
componentWillReceiveProps (props) {
const { currentUser } = props
const { owner } = this.state
if (currentUser && !owner) {
this.setState({ owner: currentUser.id })
}
}
componentWillMount () {
const { job, schedule } = this.props
if (!job || !schedule) {
if (job || schedule) { // Having only one of them is unexpected incomplete information
error(_('backupEditNotFoundTitle'), _('backupEditNotFoundMessage'))
}
this.setState({
timezone: moment.tz.guess()
})
return
}
this.setState({
backupInfo: BACKUP_METHOD_TO_INFO[job.method],
cronPattern: schedule.cron,
owner: job.userId,
timeout: job.timeout && job.timeout / 1e3,
timezone: schedule.timezone || null
}, () => delay(this._populateForm, 250, job)) // Work around.
// Without the delay, some selects are not always ready to load a value
@@ -283,8 +336,8 @@ export default class New extends Component {
if (values[1].type === 'map') {
// Smart backup.
const {
$pool: { __or: pools },
tags: { __or: tags } = {},
$pool: poolsOptions = {},
tags: tagsOptions = {},
power_state: status = 'All'
} = values[1].collection.pattern
@@ -294,9 +347,15 @@ export default class New extends Component {
smartBackupMode: true
}, () => {
vmsInput.value = {
pools,
poolsOptions: {
pools: poolsOptions.__not ? poolsOptions.__not.__or : poolsOptions.__or,
not: !!poolsOptions.__not
},
status,
tags: map(tags, tag => tag[0])
tagsOptions: {
tags: map(tagsOptions.__not ? tagsOptions.__not.__or : tagsOptions.__or, tag => tag[0]),
not: !!tagsOptions.__not
}
}
})
} else {
@@ -317,9 +376,15 @@ export default class New extends Component {
const {
backupInfo,
smartBackupMode,
timezone
timeout,
timezone,
owner
} = this.state
const { pools, not: notPools } = vmsInputValue.poolsOptions || {}
const { tags, not: notTags } = vmsInputValue.tagsOptions || {}
const formattedTags = map(tags, tag => [ tag ])
const paramsVector = !smartBackupMode
? {
type: 'crossProduct',
@@ -340,9 +405,13 @@ export default class New extends Component {
collection: {
type: 'fetchObjects',
pattern: {
$pool: !vmsInputValue.pools.length ? undefined : { __or: vmsInputValue.pools },
$pool: isEmpty(pools)
? undefined
: negatePattern({ __or: pools }, notPools),
power_state: vmsInputValue.status === 'All' ? undefined : vmsInputValue.status,
tags: !vmsInputValue.tags.length ? undefined : { __or: map(vmsInputValue.tags, tag => [ tag ]) },
tags: isEmpty(tags)
? undefined
: negatePattern({ __or: formattedTags }, notTags),
type: 'VM'
}
},
@@ -357,7 +426,9 @@ export default class New extends Component {
type: 'call',
key: backupInfo.jobKey,
method: backupInfo.method,
paramsVector
paramsVector,
userId: owner,
timeout: timeout ? timeout * 1e3 : undefined
}
// Update backup schedule.
@@ -365,7 +436,7 @@ export default class New extends Component {
if (oldJob && oldSchedule) {
job.id = oldJob.id
return setJob(job).then(() => updateSchedule({
return editJob(job).then(() => editSchedule({
...oldSchedule,
cron: this.state.cronPattern,
timezone
@@ -424,8 +495,11 @@ export default class New extends Component {
}
_handleBackupSelection = event => {
const method = event.target.value
this.setState({
backupInfo: BACKUP_METHOD_TO_INFO[event.target.value]
showVersionWarning: method === 'vm.rollingDeltaBackup' || method === 'vm.deltaCopy',
backupInfo: BACKUP_METHOD_TO_INFO[method]
})
}
@@ -435,13 +509,19 @@ export default class New extends Component {
})
}
_subjectPredicate = ({ type, permission }) =>
type === 'user' && permission === 'admin'
render () {
const { state } = this
const {
backupInfo,
cronPattern,
smartBackupMode,
timezone
} = this.state
timezone,
owner,
showVersionWarning
} = state
return process.env.XOA_PLAN > 1
? (
@@ -450,6 +530,19 @@ export default class New extends Component {
<Container>
<Row>
<Col>
<fieldset className='form-group'>
<label>{_('backupOwner')}</label>
<SelectSubject
onChange={this.linkState('owner', 'id')}
predicate={this._subjectPredicate}
required
value={owner || null}
/>
</fieldset>
<fieldset className='form-group'>
<label>{_('jobTimeoutPlaceHolder')}</label>
<input type='number' onChange={this.linkState('timeout')} value={state.timeout} className='form-control' />
</fieldset>
<fieldset className='form-group'>
<label htmlFor='selectBackup'>{_('newBackupSelection')}</label>
<select
@@ -465,6 +558,9 @@ export default class New extends Component {
)}
</select>
</fieldset>
{showVersionWarning && <div className='alert alert-warning' role='alert'>
<Icon icon='error' /> {_('backupVersionWarning')}
</div>}
<form id='form-new-vm-backup'>
{backupInfo && <div>
<GenericInput

View File

@@ -1,15 +1,22 @@
import _ from 'intl'
import ActionRowButton from 'action-row-button'
import ActionToggle from 'action-toggle'
import Component from 'base-component'
import filter from 'lodash/filter'
import find from 'lodash/find'
import forEach from 'lodash/forEach'
import get from 'lodash/get'
import Icon from 'icon'
import Link from 'link'
import LogList from '../../logs'
import map from 'lodash/map'
import orderBy from 'lodash/orderBy'
import React, { Component } from 'react'
import React from 'react'
import SortedTable from 'sorted-table'
import StateButton from 'state-button'
import Tooltip from 'tooltip'
import { addSubscriptions } from 'utils'
import { ButtonGroup } from 'react-bootstrap-4/lib'
import { createSelector } from 'selectors'
import {
Card,
CardHeader,
@@ -22,7 +29,8 @@ import {
runJob,
subscribeJobs,
subscribeSchedules,
subscribeScheduleTable
subscribeScheduleTable,
subscribeUsers
} from 'xo'
// ===================================================================
@@ -35,8 +43,81 @@ const jobKeyToLabel = {
rollingSnapshot: _('rollingSnapshot')
}
const JOB_COLUMNS = [
{
name: _('jobId'),
itemRenderer: ({ jobId }) => jobId.slice(4, 8),
sortCriteria: 'jobId'
},
{
name: _('jobType'),
itemRenderer: ({ jobLabel }) => jobLabel,
sortCriteria: 'jobLabel'
},
{
name: _('jobTag'),
itemRenderer: ({ scheduleTag }) => scheduleTag,
default: true,
sortCriteria: ({ scheduleTag }) => scheduleTag
},
{
name: _('jobScheduling'),
itemRenderer: ({ schedule }) => schedule.cron,
sortCriteria: ({ schedule }) => schedule.cron
},
{
name: _('jobTimezone'),
itemRenderer: ({ schedule }) => schedule.timezone || _('jobServerTimezone'),
sortCriteria: ({ schedule }) => schedule.timezone
},
{
name: _('jobState'),
itemRenderer: ({ schedule, scheduleToggleValue }) => <StateButton
disabledLabel={_('jobStateDisabled')}
disabledHandler={enableSchedule}
disabledTooltip={_('logIndicationToEnable')}
enabledLabel={_('jobStateEnabled')}
enabledHandler={disableSchedule}
enabledTooltip={_('logIndicationToDisable')}
handlerParam={schedule.id}
state={scheduleToggleValue}
/>,
sortCriteria: 'scheduleToggleValue'
},
{
name: _('jobAction'),
itemRenderer: ({ schedule }, isScheduleUserMissing) => <fieldset>
{!isScheduleUserMissing[schedule.id] && <Tooltip content={_('backupUserNotFound')}><Icon className='mr-1' icon='error' /></Tooltip>}
<Link className='btn btn-sm btn-primary mr-1' to={`/backup/${schedule.id}/edit`}>
<Icon icon='edit' />
</Link>
<ButtonGroup>
<ActionRowButton
icon='delete'
btnStyle='danger'
handler={deleteBackupSchedule}
handlerParam={schedule}
/>
<ActionRowButton
disabled={!isScheduleUserMissing[schedule.id]}
icon='run-schedule'
btnStyle='warning'
handler={runJob}
handlerParam={schedule.job}
/>
</ButtonGroup>
</fieldset>,
textAlign: 'right'
}
]
// ===================================================================
@addSubscriptions({
users: subscribeUsers
})
export default class Overview extends Component {
constructor (props) {
super(props)
@@ -59,7 +140,7 @@ export default class Overview extends Component {
const unsubscribeSchedules = subscribeSchedules(schedules => {
// Get only backup jobs.
schedules = filter(schedules, schedule => {
const job = this._getScheduleJob(schedule)
const job = this.state.jobs && this.state.jobs[schedule.job]
return job && jobKeyToLabel[job.key]
})
@@ -81,55 +162,52 @@ export default class Overview extends Component {
}
}
_getScheduleJob (schedule) {
const { jobs } = this.state || {}
return jobs[schedule.job]
}
_getScheduleCollection = createSelector(
() => this.state.schedules,
() => this.state.scheduleTable,
() => this.state.jobs,
(schedules, scheduleTable, jobs) => {
if (!schedules || !jobs) {
return []
}
_getJobLabel (job = {}) {
return jobKeyToLabel[job.key] || _('unknownSchedule')
}
return map(schedules, schedule => {
const job = jobs[schedule.job]
const { items } = job.paramsVector
_getScheduleTag (schedule, job = {}) {
try {
const { paramsVector } = job
const values = paramsVector.items
return {
jobId: job.id,
jobLabel: jobKeyToLabel[job.key] || _('unknownSchedule'),
// Old versions of XenOrchestra use items[0]
scheduleTag: get(items, '[0].values[0].tag') || get(items, '[1].values[0].tag') || schedule.id,
schedule,
scheduleToggleValue: scheduleTable && scheduleTable[schedule.id]
}
})
}
)
// Old versions of XenOrchestra uses values[0]
return (
values[0].values[0].tag ||
values[1].values[0].tag
)
} catch (_) {}
_getIsScheduleUserMissing = createSelector(
() => this.state.schedules,
() => this.state.jobs,
() => this.props.users,
(schedules, jobs, users) => {
const isScheduleUserMissing = {}
forEach(schedules, schedule => {
isScheduleUserMissing[schedule.id] = !!(jobs && find(users, user => user.id === jobs[schedule.job].userId))
})
return schedule.id
}
_getScheduleToggle (schedule) {
const { id } = schedule
return (
<ActionToggle
value={this.state.scheduleTable[id]}
handler={this._updateScheduleState}
handlerParam={id}
size='small'
/>
)
}
_updateScheduleState = id => {
const enabled = this.state.scheduleTable[id]
const method = enabled ? disableSchedule : enableSchedule
return method(id)
}
return isScheduleUserMissing
}
)
render () {
const {
schedules
} = this.state
const isScheduleUserMissing = this._getIsScheduleUserMissing()
return (
<div>
<Card>
@@ -138,53 +216,7 @@ export default class Overview extends Component {
</CardHeader>
<CardBlock>
{schedules.length ? (
<table className='table'>
<thead className='thead-default'>
<tr>
<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>
<tbody>
{map(schedules, (schedule, key) => {
const job = this._getScheduleJob(schedule)
return (
<tr key={key}>
<td>{job.id} ({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-right'>
<Link className='btn btn-sm btn-primary mr-1' to={`/backup/${schedule.id}/edit`}>
<Icon icon='edit' />
</Link>
<ButtonGroup>
<ActionRowButton
icon='delete'
btnStyle='danger'
handler={deleteBackupSchedule}
handlerParam={schedule}
/>
<ActionRowButton
icon='run-schedule'
btnStyle='warning'
handler={runJob}
handlerParam={schedule.job}
/>
</ButtonGroup>
</fieldset>
</td>
</tr>
)
})}
</tbody>
</table>
<SortedTable columns={JOB_COLUMNS} collection={this._getScheduleCollection()} userData={isScheduleUserMissing} />
) : <p>{_('noScheduledJobs')}</p>}
</CardBlock>
</Card>

View File

@@ -12,11 +12,11 @@ import moment from 'moment'
import React from 'react'
import reduce from 'lodash/reduce'
import SortedTable from 'sorted-table'
import uniq from 'lodash/uniq'
import Upgrade from 'xoa-upgrade'
import { confirm } from 'modal'
import { connectStore, addSubscriptions, noop } from 'utils'
import { addSubscriptions, noop } from 'utils'
import { Container, Row, Col } from 'grid'
import { createGetObjectsOfType } from 'selectors'
import { FormattedDate, injectIntl } from 'react-intl'
import { info, error } from 'notification'
import { SelectPlainObject, Toggle } from 'form'
@@ -35,9 +35,9 @@ const parseDate = date => +moment(date, 'YYYYMMDDTHHmmssZ').format('x')
const backupOptionRenderer = backup => <span>
{backup.type === 'delta' && <span><span className='tag tag-info'>{_('delta')}</span>{' '}</span>}
{backup.tag}
{backup.tag} - {backup.remoteName}
{' '}
<FormattedDate value={new Date(backup.date)} month='long' day='numeric' year='numeric' hour='2-digit' minute='2-digit' second='2-digit' />
(<FormattedDate value={new Date(backup.date)} month='long' day='numeric' year='numeric' hour='2-digit' minute='2-digit' second='2-digit' />)
</span>
const VM_COLUMNS = [
@@ -98,11 +98,6 @@ const doImport = ({ backup, sr, start }) => {
}
}
@connectStore(() => ({
writableSrs: createGetObjectsOfType('SR').filter(
[ isSrWritable ]
).sort()
}), { withRef: true })
class _ModalBody extends Component {
get value () {
return this.state
@@ -129,11 +124,6 @@ class _ModalBody extends Component {
const ImportModalBody = injectIntl(_ModalBody, {withRef: true})
@connectStore(() => ({
writableSrs: createGetObjectsOfType('SR').filter(
[ isSrWritable ]
).sort()
}))
@addSubscriptions({
rawRemotes: subscribeRemotes
})
@@ -153,7 +143,7 @@ export default class Restore extends Component {
forEach(remoteFiles, file => {
let backup
const deltaInfo = /^vm_delta_(.*)_([^\/]+)\/([^_]+)_(.*)$/.exec(file)
const deltaInfo = /^vm_delta_(.*)_([^/]+)\/([^_]+)_(.*)$/.exec(file)
if (deltaInfo) {
const [ , tag, id, date, name ] = deltaInfo
backup = {
@@ -163,7 +153,8 @@ export default class Restore extends Component {
name,
path: file,
tag,
remoteId: remote.id
remoteId: remote.id,
remoteName: remote.name
}
} else {
const backupInfo = /^([^_]+)_([^_]+)_(.*)\.xva$/.exec(file)
@@ -175,7 +166,8 @@ export default class Restore extends Component {
name,
path: file,
tag,
remoteId: remote.id
remoteId: remote.id,
remoteName: remote.name
}
}
}
@@ -190,7 +182,10 @@ export default class Restore extends Component {
backups,
last: reduce(backups, (last, b) => b.date > last.date ? b : last),
tagsByRemote: mapValues(groupBy(backups, 'remoteId'), (backups, remoteId) =>
({ remoteName: find(remotes, remote => remote.id === remoteId).name, tags: map(backups, 'tag') })
({
remoteName: find(remotes, remote => remote.id === remoteId).name,
tags: uniq(map(backups, 'tag'))
})
),
simpleCount: reduce(backups, (sum, b) => b.type === 'simple' ? ++sum : sum, 0),
deltaCount: reduce(backups, (sum, b) => b.type === 'delta' ? ++sum : sum, 0)

View File

@@ -1,18 +1,23 @@
import _ from 'intl'
import ActionRowButton from 'action-row-button'
import Component from 'base-component'
import get from 'lodash/get'
import Icon from 'icon'
import isEmpty from 'lodash/isEmpty'
import Link from 'link'
import map from 'lodash/map'
import mapValues from 'lodash/mapValues'
import SortedTable from 'sorted-table'
import TabButton from 'tab-button'
import Tooltip from 'tooltip'
import Upgrade from 'xoa-upgrade'
import React, { Component } from 'react'
import React from 'react'
import xml2js from 'xml2js'
import { Card, CardHeader, CardBlock } from 'card'
import { confirm } from 'modal'
import { deleteMessage, deleteVdi, deleteVm, isSrWritable } from 'xo'
import { deleteMessage, deleteVdi, deleteOrphanedVdis, deleteVm, isSrWritable } from 'xo'
import { FormattedRelative, FormattedTime } from 'react-intl'
import { fromCallback } from 'promise-toolbox'
import { Container, Row, Col } from 'grid'
import {
createGetObject,
@@ -27,11 +32,11 @@ import {
const SrColContainer = connectStore(() => ({
container: createGetObject()
}))(({ container }) => <Link to={`pools/${container.id}`}>{container.name_label}</Link>)
}))(({ container }) => <Link to={`${container.type}s/${container.id}`}>{container.name_label}</Link>)
const VdiColSr = connectStore(() => ({
sr: createGetObject()
}))(({ sr }) => <span>{sr.name_label}</span>)
}))(({ sr }) => <Link to={`srs/${sr.id}`}>{sr.name_label}</Link>)
const VmColContainer = connectStore(() => ({
container: createGetObject()
@@ -39,11 +44,31 @@ const VmColContainer = connectStore(() => ({
const AlarmColObject = connectStore(() => ({
object: createGetObject()
}))(({ object }) => <span>{object.name_label}</span>)
}))(({ object }) => {
if (!object) {
return null
}
switch (object.type) {
case 'VM':
return <Link to={`vms/${object.id}`}>{object.name_label}</Link>
case 'VM-controller':
return <Link to={`hosts/${object.$container}`}>{object.name_label}</Link>
case 'host':
return <Link to={`hosts/${object.id}`}>{object.name_label}</Link>
default:
return null
}
})
const AlarmColPool = connectStore(() => ({
pool: createGetObject()
}))(({ pool }) => <span>{pool.name_label}</span>)
}))(({ pool }) => {
if (!pool) {
return null
}
return <Link to={`pools/${pool.id}`}>{pool.name_label}</Link>
})
const SR_COLUMNS = [
{
@@ -161,7 +186,19 @@ const ALARM_COLUMNS = [
},
{
name: _('alarmContent'),
itemRenderer: message => message.body,
itemRenderer: ({ formatted, body }) => formatted
? <div>
<Row>
<Col mediumSize={6}><strong>{formatted.name}</strong></Col>
<Col mediumSize={6}>{formatted.value}</Col>
</Row>
<br />
{map(formatted.alarmAttributes, (value, label) => <Row>
<Col mediumSize={6}>{label}</Col>
<Col mediumSize={6}>{value}</Col>
</Row>)}
</div>
: <pre style={{ whiteSpace: 'pre-wrap' }}>{body}</pre>,
sortCriteria: message => message.body
},
{
@@ -211,18 +248,57 @@ const ALARM_COLUMNS = [
}
})
export default class Health extends Component {
_deleteOrphanedVdis = () => (
confirm({
title: _('removeAllOrphanedObject'),
body: <div>
<p>{_('removeAllOrphanedModalWarning')}</p>
<p>{_('definitiveMessageModal')}</p>
</div>
}).then(
() => map(this.props.vdiOrphaned, deleteVdi),
componentWillReceiveProps (props) {
if (props.alertMessages !== this.props.alertMessages) {
this._updateAlarms(props)
}
}
componentDidMount () {
this._updateAlarms(this.props)
}
_updateAlarms = props => {
Promise.all(
map(props.alertMessages, ({ body }, id) => {
const matches = /^value:\s*([0-9.]+)\s+config:\s*([^]*)$/.exec(body)
if (!matches) {
return
}
const [ , value, xml ] = matches
return fromCallback(cb =>
xml2js.parseString(xml, cb)
).then(
result => {
const object = mapValues(result && result.variable, value => get(value, '[0].$.value'))
if (!object || !object.name) {
return
}
const { name, ...alarmAttributes } = object
return { name, value, alarmAttributes, id }
},
noop
)
})
).then(
formattedMessages => {
this.setState({
messages: map(formattedMessages, ({ ...formattedMessage, id }) => ({
formatted: formattedMessage,
...props.alertMessages[id]
}))
})
},
noop
)
)
}
_deleteOrphanedVdis = () =>
deleteOrphanedVdis(this.props.vdiOrphaned)
_deleteAllLogs = () => (
confirm({
title: _('removeAllLogsModalTitle'),
@@ -231,7 +307,7 @@ export default class Health extends Component {
<p>{_('definitiveMessageModal')}</p>
</div>
}).then(
() => map(this.props.alertMessages, deleteMessage),
() => Promise.all(map(this.props.alertMessages, deleteMessage)),
noop
)
)
@@ -332,7 +408,7 @@ export default class Health extends Component {
</Row>
<Row>
<Col>
<SortedTable collection={this.props.alertMessages} columns={ALARM_COLUMNS} />
<SortedTable collection={this.state.messages} columns={ALARM_COLUMNS} />
</Col>
</Row>
</div>

View File

@@ -203,7 +203,7 @@ export default class Overview extends Component {
<CardHeader>
<Icon icon='memory' /> {_('memoryStatePanel')}
</CardHeader>
<CardBlock>
<CardBlock className='dashboardItem'>
<ChartistGraph
data={{
labels: ['Used Memory', 'Total Memory'],
@@ -227,7 +227,7 @@ export default class Overview extends Component {
<Icon icon='cpu' /> {_('cpuStatePanel')}
</CardHeader>
<CardBlock>
<div className='ct-chart'>
<div className='ct-chart dashboardItem'>
<ChartistGraph
data={{
labels: ['vCPUs', 'CPUs'],
@@ -238,7 +238,7 @@ export default class Overview extends Component {
/>
<p className='text-xs-center'>
{_('ofUsage', {
total: `${props.vmMetrics.vcpus} vCPUS`,
total: `${props.vmMetrics.vcpus} vCPUs`,
usage: `${props.hostMetrics.cpus} CPUs`
})}
</p>
@@ -252,7 +252,7 @@ export default class Overview extends Component {
<Icon icon='disk' /> {_('srUsageStatePanel')}
</CardHeader>
<CardBlock>
<div className='ct-chart'>
<div className='ct-chart dashboardItem'>
<BlockLink to='/dashboard/health'>
<ChartistGraph
data={{
@@ -318,7 +318,7 @@ export default class Overview extends Component {
<CardHeader>
<Icon icon='vm-force-shutdown' /> {_('vmStatePanel')}
</CardHeader>
<CardBlock>
<CardBlock className='dashboardItem'>
<BlockLink to='/home?t=VM'>
<ChartistGraph
data={{
@@ -340,7 +340,7 @@ export default class Overview extends Component {
<CardHeader>
<Icon icon='disk' /> {_('srTopUsageStatePanel')}
</CardHeader>
<CardBlock>
<CardBlock className='dashboardItem'>
<BlockLink to='/dashboard/health'>
<ChartistGraph
style={{strokeWidth: '30px'}}

View File

@@ -9,6 +9,7 @@ import propTypes from 'prop-types'
import React from 'react'
import renderXoItem from 'render-xo-item'
import sortBy from 'lodash/sortBy'
import Upgrade from 'xoa-upgrade'
import XoWeekCharts from 'xo-week-charts'
import XoWeekHeatmap from 'xo-week-heatmap'
import { Container, Row, Col } from 'grid'
@@ -430,8 +431,8 @@ const weekChartsRenderer = metric => (
/>
)
const Stats = () => (
<div>
const Stats = () => process.env.XOA_PLAN > 2
? <div>
<MetricViewer
metricRenderer={weekHeatmapRenderer}
title={_('weeklyHeatmap')}
@@ -441,5 +442,6 @@ const Stats = () => (
title={_('weeklyCharts')}
/>
</div>
)
: <Container><Upgrade place='dashboardStats' available={3} /></Container>
export { Stats as default }

View File

@@ -7,7 +7,7 @@ import Link, { BlockLink } from 'link'
import map from 'lodash/map'
import React from 'react'
import SingleLineRow from 'single-line-row'
import Tags from 'tags'
import HomeTags from 'home-tags'
import Tooltip from 'tooltip'
import { Row, Col } from 'grid'
import { Text } from 'editable'
@@ -26,7 +26,9 @@ import {
} from 'utils'
import {
createDoesHostNeedRestart,
createGetObject
createGetObject,
createGetObjectsOfType,
createSelector
} from 'selectors'
import {
CpuSparkLines,
@@ -79,7 +81,13 @@ class MiniStats extends Component {
@connectStore(({
container: createGetObject((_, props) => props.item.$pool),
needsRestart: createDoesHostNeedRestart((_, props) => props.item)
needsRestart: createDoesHostNeedRestart((_, props) => props.item),
nVms: createGetObjectsOfType('VM').count(
createSelector(
(_, props) => props.item.id,
hostId => obj => obj.$container === hostId
)
)
}))
export default class HostItem extends Component {
get _isRunning () {
@@ -97,7 +105,7 @@ export default class HostItem extends Component {
_onSelect = () => this.props.onSelect(this.props.item.id)
render () {
const { item: host, container, expandAll, selected } = this.props
const { item: host, container, expandAll, selected, nVms } = this.props
return <div className={styles.item}>
<BlockLink to={`/hosts/${host.id}`}>
<SingleLineRow>
@@ -129,6 +137,15 @@ export default class HostItem extends Component {
<Col mediumSize={3} className='hidden-lg-down'>
<EllipsisContainer>
<span className={styles.itemActionButons}>
<Tooltip content={<span>{nVms}x {_('vmsTabName')}</span>}>
{(nVms > 0)
? <Link to={`/home?s=$container:${host.id}&t=VM`}>
<Icon icon='vm' size='1' fixedWidth />
</Link>
: <Icon icon='vm' size='1' fixedWidth />
}
</Tooltip>
&nbsp;
{this._isRunning
? <span>
<Tooltip content={_('stopHostLabel')}>
@@ -188,7 +205,7 @@ export default class HostItem extends Component {
</Col>
<Col mediumSize={4}>
<span style={{fontSize: '1.4em'}}>
<Tags labels={host.tags} onDelete={this._removeTag} onAdd={this._addTag} />
<HomeTags type='host' labels={host.tags} onDelete={this._removeTag} onAdd={this._addTag} />
</span>
</Col>
</Row>

View File

@@ -2,40 +2,55 @@ 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'
import CenterPanel from 'center-panel'
import Component from 'base-component'
import debounce from 'lodash/debounce'
import forEach from 'lodash/forEach'
import Icon from 'icon'
import invoke from 'invoke'
import keys from 'lodash/keys'
import includes from 'lodash/includes'
import isEmpty from 'lodash/isEmpty'
import isString from 'lodash/isString'
import Link from 'link'
import map from 'lodash/map'
import Page from '../page'
import React from 'react'
import Shortcuts from 'shortcuts'
import SingleLineRow from 'single-line-row'
import size from 'lodash/size'
import Tooltip from 'tooltip'
import { Card, CardHeader, CardBlock } from 'card'
import {
ceil,
debounce,
filter,
find,
forEach,
get,
identity,
includes,
isEmpty,
isString,
keys,
map,
pick,
pickBy,
size,
some
} from 'lodash'
import {
addCustomFilter,
copyVms,
deleteTemplates,
deleteVms,
disconnectAllHostsSrs,
emergencyShutdownHosts,
forgetSrs,
isSrShared,
migrateVms,
reconnectAllHostsSrs,
rescanSrs,
restartHosts,
restartHostsAgents,
restartVms,
snapshotVms,
startVms,
stopHosts,
stopVms
stopVms,
subscribeServers
} from 'xo'
import { Container, Row, Col } from 'grid'
import {
@@ -44,6 +59,7 @@ import {
SelectTag
} from 'select-objects'
import {
addSubscriptions,
connectStore,
firstDefined,
noop
@@ -72,6 +88,7 @@ import HostItem from './host-item'
import PoolItem from './pool-item'
import VmItem from './vm-item'
import TemplateItem from './template-item'
import SrItem from './sr-item'
const ITEMS_PER_PAGE = 20
@@ -155,6 +172,25 @@ const OPTIONS = {
{ labelId: 'homeSortByRAM', sortBy: 'memory.size', sortOrder: 'desc' },
{ labelId: 'homeSortByCpus', sortBy: 'CPUs.number', sortOrder: 'desc' }
]
},
SR: {
defaultFilter: '',
filters: homeFilters.SR,
mainActions: [
{ handler: rescanSrs, icon: 'refresh', tooltip: _('srRescan') },
{ handler: reconnectAllHostsSrs, icon: 'sr-reconnect-all', tooltip: _('srReconnectAll') },
{ handler: disconnectAllHostsSrs, icon: 'sr-disconnect-all', tooltip: _('srDisconnectAll') },
{ handler: forgetSrs, icon: 'sr-forget', tooltip: _('srsForget') }
],
Item: SrItem,
showPoolsSelector: true,
sortOptions: [
{ labelId: 'homeSortByName', sortBy: 'name_label', sortOrder: 'asc' },
{ labelId: 'homeSortBySize', sortBy: 'size', sortOrder: 'desc', default: true },
{ labelId: 'homeSortByShared', sortBy: isSrShared, sortOrder: 'desc' },
{ labelId: 'homeSortByUsage', sortBy: 'physical_usage', sortOrder: 'desc' },
{ labelId: 'homeSortByType', sortBy: 'SR_type', sortOrder: 'asc' }
]
}
}
@@ -162,11 +198,15 @@ const TYPES = {
VM: _('homeTypeVm'),
'VM-template': _('homeTypeVmTemplate'),
host: _('homeTypeHost'),
pool: _('homeTypePool')
pool: _('homeTypePool'),
SR: _('homeSrPage')
}
const DEFAULT_TYPE = 'VM'
@addSubscriptions({
servers: subscribeServers
})
@connectStore(() => {
const noServersConnected = invoke(
createGetObjectsOfType('host'),
@@ -187,6 +227,10 @@ export default class Home extends Component {
router: React.PropTypes.object
}
state = {
selectedItems: {}
}
get page () {
return this.state.page
}
@@ -195,17 +239,35 @@ export default class Home extends Component {
}
componentWillMount () {
this._initFilter(this.props)
this._initFilterAndSortBy(this.props)
}
componentWillReceiveProps (props) {
this._initFilter(props)
if (this._getFilter() !== this._getFilter(props)) {
this._initFilterAndSortBy(props)
}
if (props.type !== this.props.type) {
this.setState({ highlighted: undefined })
this.setState({ activePage: undefined, highlighted: undefined })
}
}
componentDidUpdate () {
const { selectedItems } = this.state
// Unselect items that are no longer visible
if ((this._visibleItemsRecomputations || 0) < (this._visibleItemsRecomputations = this._getVisibleItems.recomputations())) {
const newSelectedItems = pick(selectedItems, map(this._getVisibleItems(), 'id'))
if (size(newSelectedItems) < this._getNumberOfSelectedItems()) {
this.setState({ selectedItems: newSelectedItems })
}
}
}
_getNumberOfItems = createCounter(() => this.props.items)
_getNumberOfSelectedItems = createCounter(
() => this.state.selectedItems,
[ identity ]
)
_getType () {
return this.props.type
@@ -217,37 +279,33 @@ export default class Home extends Component {
pathname,
query: { ...query, t: type, s: undefined }
})
this.setState({ highlighted: undefined })
}
// Filter and sort -----------------------------------------------------------
_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.
let tmp
const { type } = props
const preferences = get(props, 'user.preferences')
const defaultFilterName = get(preferences, [ 'defaultHomeFilters', type ])
return firstDefined(
(tmp = homeFilters[type]) && tmp[filterName],
(tmp = filters[type]) && tmp[filterName],
defaultFilter
defaultFilterName && firstDefined(
get(homeFilters, [ type, defaultFilterName ]),
get(preferences, [ 'filters', type, defaultFilterName ])
),
OPTIONS[type].defaultFilter
)
}
_initFilter (props) {
_getDefaultSort (props = this.props) {
const sortOption = find(OPTIONS[props.type].sortOptions, 'default')
return {
sortBy: firstDefined(sortOption && sortOption.sortBy, 'name_label'),
sortOrder: firstDefined(sortOption && sortOption.sortOrder, 'asc')
}
}
_initFilterAndSortBy (props) {
const filter = this._getFilter(props)
// If filter is null, set a default filter.
@@ -268,10 +326,13 @@ export default class Home extends Component {
const parsed = ComplexMatcher.parse(filter)
const properties = parsed::ComplexMatcher.getPropertyClausesStrings()
const sort = this._getDefaultSort(props)
this.setState({
selectedHosts: properties.$container,
selectedPools: properties.$pool,
selectedTags: properties.tags
selectedTags: properties.tags,
...sort
})
const { filterInput } = this.refs
@@ -325,7 +386,7 @@ export default class Home extends Component {
() => this.props.items,
this._getFilterFunction
),
() => this.state.sortBy || 'name_label',
() => this.state.sortBy,
() => this.state.sortOrder
)
@@ -341,6 +402,11 @@ export default class Home extends Component {
_tick = isCriteria => <Icon icon={isCriteria ? 'success' : undefined} fixedWidth />
// High level filters --------------------------------------------------------
_typesDropdownItems = map(TYPES, (label, type) =>
<MenuItem key={type} onClick={() => this._setType(type)}>{label}</MenuItem>
)
_updateSelectedPools = pools => {
const filter = this._getParsedFilter()
@@ -380,42 +446,12 @@ export default class Home extends Component {
: filter::ComplexMatcher.removePropertyClause('tags')
)
}
// Checkboxes
_selectedItems = {}
_updateMasterCheckbox () {
const masterCheckbox = this.refs.masterCheckbox
if (!masterCheckbox) {
return
}
const noneChecked = isEmpty(this._selectedItems)
masterCheckbox.checked = !noneChecked
masterCheckbox.indeterminate = !noneChecked && size(this._selectedItems) !== this._getFilteredItems().length
this.setState({ displayActions: !noneChecked })
}
_selectItem = (id, checked) => {
const shouldBeChecked = checked === undefined ? !this._selectedItems[id] : checked
shouldBeChecked ? this._selectedItems[id] = true : delete this._selectedItems[id]
this.forceUpdate()
this._updateMasterCheckbox()
}
_selectAllItems = (checked) => {
const shouldBeChecked = checked === undefined ? !size(this._selectedItems) : checked
this._selectedItems = {}
forEach(this._getFilteredItems(), item => {
shouldBeChecked && (this._selectedItems[item.id] = true)
})
this.forceUpdate()
this._updateMasterCheckbox()
}
_addCustomFilter = () => {
return addCustomFilter(
this._getType(),
this._getFilter()
)
}
_getCustomFilters () {
const { preferences } = this.props.user || {}
@@ -427,6 +463,34 @@ export default class Home extends Component {
return customFilters[this._getType()]
}
// Checkboxes ----------------------------------------------------------------
_getIsAllSelected = createSelector(
() => this.state.selectedItems,
this._getVisibleItems,
(selectedItems, visibleItems) =>
size(visibleItems) > 0 && size(filter(selectedItems)) === size(visibleItems)
)
_getIsSomeSelected = createSelector(
() => this.state.selectedItems,
some
)
_toggleMaster = () => {
const selectedItems = {}
if (!this._getIsAllSelected()) {
forEach(this._getVisibleItems(), ({ id }) => {
selectedItems[id] = true
})
}
this.setState({ selectedItems })
}
_getSelectedItemsIds = createSelector(
() => this.state.selectedItems,
items => keys(pickBy(items))
)
// Shortcuts -----------------------------------------------------------------
_getShortcutsHandler = createSelector(
() => this._getVisibleItems(),
items => (command, event) => {
@@ -442,11 +506,17 @@ export default class Home extends Component {
this.setState({ highlighted: (this.state.highlighted + items.length - 1) % items.length || 0 })
break
case 'SELECT':
this._selectItem(items[this.state.highlighted].id)
const itemId = items[this.state.highlighted].id
this.setState({
selectedItems: {
...this.state.selectedItems,
[itemId]: !this.state.selectedItems[itemId]
}
})
break
case 'JUMP_INTO':
const item = items[this.state.highlighted]
if (includes(['VM', 'host', 'pool'], item.type)) {
if (includes(['VM', 'host', 'pool', 'SR'], item && item.type)) {
this.context.router.push({
pathname: `${item.type.toLowerCase()}s/${item.id}`
})
@@ -455,9 +525,7 @@ export default class Home extends Component {
}
)
_typesDropdownItems = map(TYPES, (label, type) =>
<MenuItem onClick={() => this._setType(type)}>{label}</MenuItem>
)
// Header --------------------------------------------------------------------
_renderHeader () {
const { type } = this.props
@@ -526,27 +594,35 @@ export default class Home extends Component {
</Container>
}
render () {
const { props } = this
const { user } = this.props
const isAdmin = user && user.permission === 'admin'
// ---------------------------------------------------------------------------
if (!props.areObjectsFetched) {
render () {
const {
areObjectsFetched,
noServersConnected,
servers,
user
} = this.props
const isAdmin = user && user.permission === 'admin'
const noRegisteredServers = !servers || !servers.length
if (!areObjectsFetched) {
return <CenterPanel>
<h2><img src='assets/loading.svg' /></h2>
</CenterPanel>
}
if (props.noServersConnected && isAdmin) {
if (noServersConnected && isAdmin) {
return <CenterPanel>
<Card shadow>
<CardHeader>{_('homeWelcome')}</CardHeader>
<CardBlock>
<Link to='/settings/servers'>
<Icon icon='pool' size={4} />
<h4>{_('homeAddServer')}</h4>
<h4>{noRegisteredServers ? _('homeAddServer') : _('homeConnectServer')}</h4>
</Link>
<p className='text-muted'>{_('homeWelcomeText')}</p>
<p className='text-muted'>{noRegisteredServers ? _('homeWelcomeText') : _('homeConnectServerText')}</p>
<br /><br />
<h3>{_('homeHelp')}</h3>
<Row>
@@ -609,12 +685,35 @@ export default class Home extends Component {
const filteredItems = this._getFilteredItems()
const visibleItems = this._getVisibleItems()
const { activePage, sortBy, highlighted } = this.state
const { type } = props
const {
activePage,
expandAll,
highlighted,
selectedHosts,
selectedItems,
selectedPools,
selectedTags,
sortBy
} = this.state
const {
items,
type
} = this.props
const options = OPTIONS[type]
const { Item } = options
const { mainActions, otherActions } = options
const selectedItemsIds = keys(this._selectedItems)
const {
Item,
mainActions,
otherActions,
showHostsSelector,
showPoolsSelector
} = options
// Necessary because indeterminate cannot be used as an attribute
if (this.refs.masterCheckbox) {
this.refs.masterCheckbox.indeterminate = this._getIsSomeSelected() && !this._getIsAllSelected()
}
return <Page header={this._renderHeader()}>
<Shortcuts name='Home' handler={this._getShortcutsHandler()} targetNodeSelector='body' stopPropagation={false} />
@@ -622,13 +721,18 @@ export default class Home extends Component {
<div className={styles.itemContainer}>
<SingleLineRow className={styles.itemContainerHeader}>
<Col smallsize={11} mediumSize={3}>
<input type='checkbox' onChange={() => this._selectAllItems()} ref='masterCheckbox' />
<input
checked={this._getIsAllSelected()}
onChange={this._toggleMaster}
ref='masterCheckbox'
type='checkbox'
/>
{' '}
<span className='text-muted'>
{size(this._selectedItems)
{this._getNumberOfSelectedItems()
? _('homeSelectedItems', {
icon: <Icon icon={type.toLowerCase()} />,
selected: size(this._selectedItems),
selected: this._getNumberOfSelectedItems(),
total: nItems
})
: _('homeDisplayedItems', {
@@ -640,7 +744,7 @@ export default class Home extends Component {
</span>
</Col>
<Col mediumSize={8} className='text-xs-right hidden-sm-down'>
{this.state.displayActions
{this._getNumberOfSelectedItems()
? (
<div>
{mainActions && <div className='btn-group'>
@@ -649,7 +753,7 @@ export default class Home extends Component {
<ActionButton
btnStyle='secondary'
{...action}
handlerParam={selectedItemsIds}
handlerParam={this._getSelectedItemsIds()}
/>
</Tooltip>
))}
@@ -657,7 +761,7 @@ export default class Home extends Component {
{otherActions && (
<DropdownButton bsStyle='secondary' id='advanced' title={_('homeMore')}>
{map(otherActions, (action, key) => (
<MenuItem key={key} onClick={() => { action.handler(selectedItemsIds, action.params) }}>
<MenuItem key={key} onClick={() => { action.handler(this._getSelectedItemsIds(), action.params) }}>
<Icon icon={action.icon} fixedWidth /> {_(action.labelId)}
</MenuItem>
))}
@@ -665,7 +769,7 @@ export default class Home extends Component {
)}
</div>
) : <div>
{options.showPoolsSelector && (
{showPoolsSelector && (
<OverlayTrigger
trigger='click'
rootClose
@@ -676,7 +780,7 @@ export default class Home extends Component {
autoFocus
multi
onChange={this._updateSelectedPools}
value={this.state.selectedPools}
value={selectedPools}
/>
</Popover>
}
@@ -685,7 +789,7 @@ export default class Home extends Component {
</OverlayTrigger>
)}
{' '}
{options.showHostsSelector && (
{showHostsSelector && (
<OverlayTrigger
trigger='click'
rootClose
@@ -696,7 +800,7 @@ export default class Home extends Component {
autoFocus
multi
onChange={this._updateSelectedHosts}
value={this.state.selectedHosts}
value={selectedHosts}
/>
</Popover>
}
@@ -715,9 +819,9 @@ export default class Home extends Component {
<SelectTag
autoFocus
multi
objects={props.items}
objects={items}
onChange={this._updateSelectedTags}
value={this.state.selectedTags}
value={selectedTags}
/>
</Popover>
}
@@ -753,13 +857,13 @@ export default class Home extends Component {
</a>
</p>
: map(visibleItems, (item, index) => (
<div className={highlighted === index && styles.highlight}>
<div key={item.id} className={highlighted === index && styles.highlight}>
<Item
expandAll={this.state.expandAll}
expandAll={expandAll}
item={item}
key={item.id}
onSelect={this._selectItem}
selected={this._selectedItems[item.id]}
onSelect={this.toggleState(`selectedItems.${item.id}`)}
selected={selectedItems[item.id]}
/>
</div>
))

Some files were not shown because too many files have changed in this diff Show More