Compare commits
455 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1dad5b5c3a | ||
|
|
5cc5ee4e87 | ||
|
|
e8d2b32a14 | ||
|
|
f492909e42 | ||
|
|
7ea17750a1 | ||
|
|
663e1f1a4b | ||
|
|
079310c67e | ||
|
|
5cf7f1f886 | ||
|
|
9f64af859e | ||
|
|
007aa776cb | ||
|
|
66bc092edd | ||
|
|
140a88ee12 | ||
|
|
f42758938d | ||
|
|
e19fd81536 | ||
|
|
73835ded96 | ||
|
|
1ec1a8bd94 | ||
|
|
f0b6d57ba8 | ||
|
|
f9a3ad14d1 | ||
|
|
1b86f533f7 | ||
|
|
46416fb026 | ||
|
|
54ed37c95d | ||
|
|
fd79b47d9e | ||
|
|
be8333824b | ||
|
|
55daffc791 | ||
|
|
375baf7fe5 | ||
|
|
815e74c93c | ||
|
|
547d6fbc93 | ||
|
|
b45a4b9e6c | ||
|
|
3436d0256a | ||
|
|
2627cfd426 | ||
|
|
34b18c00a1 | ||
|
|
e13af7f5f0 | ||
|
|
ca08613292 | ||
|
|
4ab63591a0 | ||
|
|
5b4f98b03b | ||
|
|
f396d61633 | ||
|
|
9f8c0c8cdf | ||
|
|
198777ffab | ||
|
|
29c5ca1132 | ||
|
|
05d6f3d1ed | ||
|
|
536e82de3d | ||
|
|
c59be7c315 | ||
|
|
b327bb5bd0 | ||
|
|
a3103587f5 | ||
|
|
1bb11b574f | ||
|
|
405efe6a31 | ||
|
|
73663c3703 | ||
|
|
421ee7125b | ||
|
|
1a6166b63c | ||
|
|
3828e75b7d | ||
|
|
154da142c7 | ||
|
|
312cd60dd1 | ||
|
|
6bf522f72f | ||
|
|
a844f8d459 | ||
|
|
8ee206174b | ||
|
|
1a08e24a5c | ||
|
|
086cd0e038 | ||
|
|
42d123318c | ||
|
|
89f160317c | ||
|
|
9ccd1a0362 | ||
|
|
d116d014bc | ||
|
|
7956cabcf4 | ||
|
|
36c61ad357 | ||
|
|
25d60360d5 | ||
|
|
1e5579e3ad | ||
|
|
77d43b2280 | ||
|
|
33e8929e8b | ||
|
|
b79fa9cb9f | ||
|
|
a2812a85bd | ||
|
|
e8ff46a8ba | ||
|
|
351c01d642 | ||
|
|
e333b1d083 | ||
|
|
5ad49de642 | ||
|
|
b45bb5c144 | ||
|
|
9402596f69 | ||
|
|
096687ae2c | ||
|
|
210b5de992 | ||
|
|
f742fdbf1b | ||
|
|
e7026c522d | ||
|
|
c21fc4beda | ||
|
|
edf6fe782e | ||
|
|
3cbb6c4a98 | ||
|
|
568a50acc5 | ||
|
|
fbcb756cef | ||
|
|
81eb4ba4f9 | ||
|
|
0cc14d2ab8 | ||
|
|
6aedadc982 | ||
|
|
a8d10dab3c | ||
|
|
1ff6ff1d7a | ||
|
|
8afe4a85dc | ||
|
|
c57fbdce63 | ||
|
|
bdc0278fd1 | ||
|
|
c3ac8d0587 | ||
|
|
f3a5e1e97c | ||
|
|
919aa5fc43 | ||
|
|
416c98ffd2 | ||
|
|
8094447183 | ||
|
|
575375d3e0 | ||
|
|
4296ae02dc | ||
|
|
0e40af0515 | ||
|
|
5d3a0e7a41 | ||
|
|
8ae2aae37a | ||
|
|
83b3cf406a | ||
|
|
1643ced4e0 | ||
|
|
b2a1840da7 | ||
|
|
b9f20d1e80 | ||
|
|
0c77781be8 | ||
|
|
83245af1e2 | ||
|
|
7db806a461 | ||
|
|
92b15fb1e2 | ||
|
|
7b5182111c | ||
|
|
82b1b81999 | ||
|
|
f0a430f350 | ||
|
|
90f95b7270 | ||
|
|
15e6a93fac | ||
|
|
01541a2577 | ||
|
|
8c70bc0a17 | ||
|
|
9d96074604 | ||
|
|
114a4028f4 | ||
|
|
b342a4ba17 | ||
|
|
fcbf037619 | ||
|
|
a8e4ab433d | ||
|
|
6613ba02ab | ||
|
|
2af7fde83f | ||
|
|
19a0d4bc98 | ||
|
|
9ed49b1f27 | ||
|
|
d56df30a22 | ||
|
|
64908068d9 | ||
|
|
fe69d59aeb | ||
|
|
b65e737f84 | ||
|
|
bd274fdc3c | ||
|
|
ac19249c63 | ||
|
|
2abff1fec8 | ||
|
|
f1a6cfae0d | ||
|
|
e43e90ed3c | ||
|
|
0ee88fe0dc | ||
|
|
07e7f2e14d | ||
|
|
366ab95a2f | ||
|
|
ca723068a1 | ||
|
|
e424a105b3 | ||
|
|
32d2f92413 | ||
|
|
898e2ff010 | ||
|
|
dfa5e76870 | ||
|
|
c93dd12fae | ||
|
|
dbb1b1e582 | ||
|
|
76388ee160 | ||
|
|
5ec2eee69a | ||
|
|
31875a36fe | ||
|
|
c50598b78e | ||
|
|
2f0c81d9ad | ||
|
|
c22f89c6bb | ||
|
|
568a23cd35 | ||
|
|
eb7c4c131d | ||
|
|
f0664cd2c7 | ||
|
|
570eb7bc89 | ||
|
|
1ee91b4925 | ||
|
|
69fee37f00 | ||
|
|
49be66ae69 | ||
|
|
a0efe6895c | ||
|
|
8ef07e917d | ||
|
|
d3995b7bab | ||
|
|
c353e71ce7 | ||
|
|
a3570a1c9f | ||
|
|
c593c98e6d | ||
|
|
a4b5b532f2 | ||
|
|
6357f23aeb | ||
|
|
01d9b3bd0e | ||
|
|
6b428f7587 | ||
|
|
f829aa76d7 | ||
|
|
a72051e96f | ||
|
|
797622ba66 | ||
|
|
39342cd662 | ||
|
|
051a3ac122 | ||
|
|
f842a321ba | ||
|
|
3cd2dd65d3 | ||
|
|
5ce7e0b108 | ||
|
|
71c2058cc8 | ||
|
|
f200d39d23 | ||
|
|
7932845ac5 | ||
|
|
94bda6ac9e | ||
|
|
7a65f80406 | ||
|
|
36ab58dad9 | ||
|
|
e9be9e3761 | ||
|
|
b54645c86c | ||
|
|
ab77d8430c | ||
|
|
c6f683b532 | ||
|
|
a2604f5156 | ||
|
|
5ae7f683d6 | ||
|
|
f953c89979 | ||
|
|
bb8aab02ea | ||
|
|
af0c03ff6a | ||
|
|
8859900537 | ||
|
|
130852ab85 | ||
|
|
65fa8f96b4 | ||
|
|
0a84e9e363 | ||
|
|
163c69454b | ||
|
|
49d3fde0f3 | ||
|
|
bb67e2254e | ||
|
|
6d2abc4e74 | ||
|
|
4875450053 | ||
|
|
19184ca8a0 | ||
|
|
654c3d324b | ||
|
|
c5b4811f16 | ||
|
|
7a9dc4fd59 | ||
|
|
e79096626a | ||
|
|
332d074d32 | ||
|
|
e511ecd76e | ||
|
|
bcfbd5eba9 | ||
|
|
9fa3db395b | ||
|
|
52a41ceb04 | ||
|
|
e65d67266d | ||
|
|
0d1045821c | ||
|
|
45d526dda2 | ||
|
|
e52f998e78 | ||
|
|
42ed3b9355 | ||
|
|
563b4cb1ec | ||
|
|
45bad231cf | ||
|
|
d76bd2484b | ||
|
|
445b60bb63 | ||
|
|
3214e0e41e | ||
|
|
c61230e145 | ||
|
|
fac6a29226 | ||
|
|
7a8f414748 | ||
|
|
9f450d282e | ||
|
|
31787067e3 | ||
|
|
1a769b23e2 | ||
|
|
ae002abafc | ||
|
|
31a25d9c16 | ||
|
|
356295c361 | ||
|
|
d10681b6d1 | ||
|
|
0602410aa8 | ||
|
|
1112768adc | ||
|
|
86b599df89 | ||
|
|
88f7661172 | ||
|
|
29c96c0119 | ||
|
|
d8c6e54c68 | ||
|
|
df053eb016 | ||
|
|
d1715f7711 | ||
|
|
240282c72d | ||
|
|
9e8dd6ea21 | ||
|
|
32806a20c9 | ||
|
|
34dcfbbf49 | ||
|
|
91fec43866 | ||
|
|
aa2d196a79 | ||
|
|
180ca458ad | ||
|
|
aa881c60e7 | ||
|
|
5b6966042d | ||
|
|
dc859da0cd | ||
|
|
151eb6cbd6 | ||
|
|
16db591bbf | ||
|
|
05a55e5eb2 | ||
|
|
dcd84b2b8f | ||
|
|
4a89119f0a | ||
|
|
bc1c30a7bf | ||
|
|
33cffbf28b | ||
|
|
a18b68116c | ||
|
|
d5acf15bca | ||
|
|
84f970af68 | ||
|
|
969f636bb7 | ||
|
|
6939aee20a | ||
|
|
ab2a02a555 | ||
|
|
70038e0764 | ||
|
|
e730ef5e11 | ||
|
|
835ad5aaf1 | ||
|
|
ac645c8617 | ||
|
|
b801fdbab2 | ||
|
|
bf495953e2 | ||
|
|
45b165deec | ||
|
|
09169578e8 | ||
|
|
43b2366927 | ||
|
|
f015a69eec | ||
|
|
99568508dd | ||
|
|
e8515344dd | ||
|
|
edc873a570 | ||
|
|
1a03e96ab2 | ||
|
|
89e0bb4f0a | ||
|
|
7d0fd60908 | ||
|
|
6b20523df4 | ||
|
|
e9a612647e | ||
|
|
28404ef149 | ||
|
|
a5f8230def | ||
|
|
39171de5de | ||
|
|
5aa5a0acbc | ||
|
|
a4518e630a | ||
|
|
94975f5ea6 | ||
|
|
7e98838d96 | ||
|
|
e8c9c196ff | ||
|
|
db314a238f | ||
|
|
2c85a6d4ab | ||
|
|
b683e14e80 | ||
|
|
ba45095fa8 | ||
|
|
b8e5ffa9f7 | ||
|
|
b4bff9e032 | ||
|
|
0c461bc4e2 | ||
|
|
a33b2a5294 | ||
|
|
298e1c4471 | ||
|
|
1c70cdc10b | ||
|
|
160e4bb530 | ||
|
|
e69ba8dd96 | ||
|
|
e55f4c3eb2 | ||
|
|
1a3272b980 | ||
|
|
7bed5e025a | ||
|
|
29d22c0598 | ||
|
|
a38c7c34ac | ||
|
|
8d690ce4ff | ||
|
|
2569568a03 | ||
|
|
2c6ff6b5b8 | ||
|
|
1257f01027 | ||
|
|
fad6830863 | ||
|
|
66262bb20b | ||
|
|
4abb0754c7 | ||
|
|
78c53bf3ad | ||
|
|
810d666d84 | ||
|
|
67699f0bb6 | ||
|
|
46274948c0 | ||
|
|
28e3a842ef | ||
|
|
6d90f1d45d | ||
|
|
09642c347d | ||
|
|
2d0e06f785 | ||
|
|
a5bc8497cf | ||
|
|
4bcb65c518 | ||
|
|
25361fa7eb | ||
|
|
889a265000 | ||
|
|
3122f6dcd5 | ||
|
|
16aa2e8085 | ||
|
|
074d51a670 | ||
|
|
2122a79132 | ||
|
|
26dbc585ba | ||
|
|
4b3cfbd424 | ||
|
|
035191a2cc | ||
|
|
06a40180a1 | ||
|
|
aaf4c5dff7 | ||
|
|
0c83bc2b0e | ||
|
|
2d412fd8db | ||
|
|
443e2bec25 | ||
|
|
d5e1323d82 | ||
|
|
7f0b77cc89 | ||
|
|
0169cff66c | ||
|
|
0fd1424a41 | ||
|
|
6280d56f32 | ||
|
|
9f2a77872f | ||
|
|
b571c18e9a | ||
|
|
49863d6e4d | ||
|
|
48cc7bb647 | ||
|
|
442d42d8dc | ||
|
|
9501ebacfc | ||
|
|
23f9fa46f8 | ||
|
|
1bd0f37fd4 | ||
|
|
ed74ded923 | ||
|
|
b732410b74 | ||
|
|
a51f2b7fcf | ||
|
|
fe12bbb60d | ||
|
|
8882df7939 | ||
|
|
185a554cd9 | ||
|
|
230e0dc2a5 | ||
|
|
f5b69fdfdc | ||
|
|
01dc0d8f1e | ||
|
|
8035886a3c | ||
|
|
0ab5f4b13f | ||
|
|
a1bc98def8 | ||
|
|
868cf6140b | ||
|
|
4b3473f480 | ||
|
|
7bc782cc62 | ||
|
|
e625a53e4a | ||
|
|
b31185d96d | ||
|
|
09d75e972f | ||
|
|
f33568951b | ||
|
|
8d8c442be5 | ||
|
|
f890b8ea7a | ||
|
|
1b80b3929c | ||
|
|
4f946293f6 | ||
|
|
36788cde2b | ||
|
|
1547c99e5a | ||
|
|
5c9606dad8 | ||
|
|
fdcb1dccf5 | ||
|
|
12812b8c23 | ||
|
|
0098497255 | ||
|
|
6562d2de7f | ||
|
|
1f0e88cdb0 | ||
|
|
197da91ef3 | ||
|
|
cbd59789e2 | ||
|
|
190ecf3d74 | ||
|
|
15b8f6bca2 | ||
|
|
5b406d731b | ||
|
|
4be9e67ac4 | ||
|
|
d047421685 | ||
|
|
f6f415a421 | ||
|
|
edfaaebac0 | ||
|
|
67df22a1bf | ||
|
|
7dc59a00f6 | ||
|
|
6214fe4c2e | ||
|
|
21610c3e0a | ||
|
|
87550b0189 | ||
|
|
b7c42d0a08 | ||
|
|
c15ad299ac | ||
|
|
48c56cd602 | ||
|
|
7957f621ef | ||
|
|
38ddbfdc9c | ||
|
|
3d2aae81da | ||
|
|
2227b9d061 | ||
|
|
12aab5fa8c | ||
|
|
7323e6e117 | ||
|
|
6f36869609 | ||
|
|
4a12419162 | ||
|
|
bf91938aa6 | ||
|
|
bd70bd2b45 | ||
|
|
bb26c8e449 | ||
|
|
93c7a01e62 | ||
|
|
9c2359e8ee | ||
|
|
5b9000012e | ||
|
|
bf00b4e8e3 | ||
|
|
ee7787f4ae | ||
|
|
0b88e743c9 | ||
|
|
f07a947580 | ||
|
|
0b8a9eedbc | ||
|
|
8d24e596ac | ||
|
|
c2378a44cd | ||
|
|
023f7fdef1 | ||
|
|
5d7a64bc28 | ||
|
|
8661957a97 | ||
|
|
7a15d265b7 | ||
|
|
2736881975 | ||
|
|
44a85f4e0c | ||
|
|
52a6e42e7e | ||
|
|
3dbe058d4e | ||
|
|
620139efc1 | ||
|
|
71464ac2e3 | ||
|
|
4a65489d39 | ||
|
|
65d7eac590 | ||
|
|
02bbc01dc4 | ||
|
|
3066237c86 | ||
|
|
53f3c0bef1 | ||
|
|
823c91b457 | ||
|
|
3bd7e20411 | ||
|
|
24d4610b04 | ||
|
|
b16097767a | ||
|
|
2ff74ffd39 | ||
|
|
f0bb464136 | ||
|
|
4767830386 | ||
|
|
ce23d4f164 | ||
|
|
c1380d1256 | ||
|
|
ed9a848858 | ||
|
|
5e4e15fc12 | ||
|
|
0dea952a2a | ||
|
|
a1818dd525 | ||
|
|
659e336f66 | ||
|
|
058f7ecd9f | ||
|
|
831d9cb49f | ||
|
|
a5d059b0b1 | ||
|
|
4c3b959869 | ||
|
|
d81a169a39 | ||
|
|
0d47332526 | ||
|
|
539d136936 | ||
|
|
4c28b5775d | ||
|
|
fe6f351f84 |
9
.gitignore
vendored
9
.gitignore
vendored
@@ -1,9 +1,8 @@
|
||||
/.nyc_output/
|
||||
/bower_components/
|
||||
/dist/
|
||||
/node_modules/
|
||||
/src/common/intl/locales/index.js
|
||||
|
||||
npm-debug.log
|
||||
npm-debug.log.*
|
||||
|
||||
!node_modules/*
|
||||
node_modules/*/
|
||||
pnpm-debug.log
|
||||
pnpm-debug.log.*
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
language: node_js
|
||||
node_js:
|
||||
- 'stable'
|
||||
#- '4' # Disabled for now because npm 2 cannot properly handled broken peer dependencies.
|
||||
- '6'
|
||||
#- '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/
|
||||
|
||||
319
CHANGELOG.md
319
CHANGELOG.md
@@ -1,5 +1,324 @@
|
||||
# ChangeLog
|
||||
|
||||
## **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)
|
||||
|
||||
### Enhancements
|
||||
|
||||
- Missing favicon [\#1660](https://github.com/vatesfr/xo-web/issues/1660)
|
||||
- ipPools quota [\#1565](https://github.com/vatesfr/xo-web/issues/1565)
|
||||
- Dashboard - orphaned VDI [\#1654](https://github.com/vatesfr/xo-web/issues/1654)
|
||||
- Stats in home/host view when expanded [\#1634](https://github.com/vatesfr/xo-web/issues/1634)
|
||||
- Bar for used and total RAM on home pool view [\#1625](https://github.com/vatesfr/xo-web/issues/1625)
|
||||
- Can't translate some text [\#1624](https://github.com/vatesfr/xo-web/issues/1624)
|
||||
- Dynamic RAM allocation at creation time [\#1603](https://github.com/vatesfr/xo-web/issues/1603)
|
||||
- Display memory bar in home/host view [\#1616](https://github.com/vatesfr/xo-web/issues/1616)
|
||||
- Improve keyboard navigation [\#1578](https://github.com/vatesfr/xo-web/issues/1578)
|
||||
- Strongly suggest to install the guest tools [\#1575](https://github.com/vatesfr/xo-web/issues/1575)
|
||||
- Missing tooltip [\#1568](https://github.com/vatesfr/xo-web/issues/1568)
|
||||
- Emphasize already used ips in ipPools [\#1566](https://github.com/vatesfr/xo-web/issues/1566)
|
||||
- Change "missing feature message" for non-admins [\#1564](https://github.com/vatesfr/xo-web/issues/1564)
|
||||
- Allow VIF edition [\#1446](https://github.com/vatesfr/xo-web/issues/1446)
|
||||
- Disable browser autocomplete on credentials on the Update page [\#1304](https://github.com/vatesfr/xo-web/issues/1304)
|
||||
- keyboard shortcuts [\#1279](https://github.com/vatesfr/xo-web/issues/1279)
|
||||
- Add network bond creation [\#876](https://github.com/vatesfr/xo-web/issues/876)
|
||||
- `pool.setDefaultSr\(\)` should not require `pool` param [\#1558](https://github.com/vatesfr/xo-web/issues/1558)
|
||||
- Select default SR [\#1554](https://github.com/vatesfr/xo-web/issues/1554)
|
||||
- No error message when I exceed my resource set quota [\#1541](https://github.com/vatesfr/xo-web/issues/1541)
|
||||
- Hide some buttons for self service VMs [\#1539](https://github.com/vatesfr/xo-web/issues/1539)
|
||||
- Add Job ID to backup schedules [\#1534](https://github.com/vatesfr/xo-web/issues/1534)
|
||||
- Correct name for VM selector with templates [\#1530](https://github.com/vatesfr/xo-web/issues/1530)
|
||||
- Help text when no matches for a filter [\#1517](https://github.com/vatesfr/xo-web/issues/1517)
|
||||
- Icon or tooltip to allow VDI migration in VM disk view [\#1512](https://github.com/vatesfr/xo-web/issues/1512)
|
||||
- Create a snapshot before restoring one [\#1445](https://github.com/vatesfr/xo-web/issues/1445)
|
||||
- Auto power on setting at creation time [\#1444](https://github.com/vatesfr/xo-web/issues/1444)
|
||||
- local remotes should be avoided if possible [\#1441](https://github.com/vatesfr/xo-web/issues/1441)
|
||||
- Self service edition unclear [\#1429](https://github.com/vatesfr/xo-web/issues/1429)
|
||||
- Avoid "\_" char in job tag name [\#1414](https://github.com/vatesfr/xo-web/issues/1414)
|
||||
- Display message if host reboot needed to apply patches [\#1352](https://github.com/vatesfr/xo-web/issues/1352)
|
||||
- Color code on host PIF stats can be misleading [\#1265](https://github.com/vatesfr/xo-web/issues/1265)
|
||||
- Sign in page is not rendered correctly [\#1161](https://github.com/vatesfr/xo-web/issues/1161)
|
||||
- Template management [\#1091](https://github.com/vatesfr/xo-web/issues/1091)
|
||||
- On pool view: collapse network list [\#1461](https://github.com/vatesfr/xo-web/issues/1461)
|
||||
- Alert when trying to reboot/halt the pool master XS [\#1458](https://github.com/vatesfr/xo-web/issues/1458)
|
||||
- Adding tooltip on Home page [\#1456](https://github.com/vatesfr/xo-web/issues/1456)
|
||||
- Docker container management functionality missing from v5 [\#1442](https://github.com/vatesfr/xo-web/issues/1442)
|
||||
- bad error message - delete snapshot [\#1433](https://github.com/vatesfr/xo-web/issues/1433)
|
||||
- Create tag during VM creation [\#1431](https://github.com/vatesfr/xo-web/issues/1431)
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- Display issues on plugin array edition [\#1663](https://github.com/vatesfr/xo-web/issues/1663)
|
||||
- Import of delta backups fails [\#1656](https://github.com/vatesfr/xo-web/issues/1656)
|
||||
- Host - Missing IP config for PIF [\#1651](https://github.com/vatesfr/xo-web/issues/1651)
|
||||
- Remote copy is always activating compression [\#1645](https://github.com/vatesfr/xo-web/issues/1645)
|
||||
- LB plugin UI problems [\#1630](https://github.com/vatesfr/xo-web/issues/1630)
|
||||
- Keyboard shortcuts should not work when a modal is open [\#1589](https://github.com/vatesfr/xo-web/issues/1589)
|
||||
- UI small bug in drop-down lists [\#1411](https://github.com/vatesfr/xo-web/issues/1411)
|
||||
- md5 delta backup error [\#1672](https://github.com/vatesfr/xo-web/issues/1672)
|
||||
- Can't edit VIF network [\#1640](https://github.com/vatesfr/xo-web/issues/1640)
|
||||
- Do not expose shortcuts while console is focused [\#1614](https://github.com/vatesfr/xo-web/issues/1614)
|
||||
- All users can see VM templates [\#1621](https://github.com/vatesfr/xo-web/issues/1621)
|
||||
- Profile page is broken [\#1612](https://github.com/vatesfr/xo-web/issues/1612)
|
||||
- SR delete should redirect to home [\#1611](https://github.com/vatesfr/xo-web/issues/1611)
|
||||
- Delta VHD backup checksum is invalidated by chaining [\#1606](https://github.com/vatesfr/xo-web/issues/1606)
|
||||
- VM with long description break on 2 lines [\#1580](https://github.com/vatesfr/xo-web/issues/1580)
|
||||
- Network status on VM edition [\#1573](https://github.com/vatesfr/xo-web/issues/1573)
|
||||
- VM template deletion fails [\#1571](https://github.com/vatesfr/xo-web/issues/1571)
|
||||
- Template edition - "no such object" [\#1569](https://github.com/vatesfr/xo-web/issues/1569)
|
||||
- missing links / element not displayed as links [\#1567](https://github.com/vatesfr/xo-web/issues/1567)
|
||||
- Backup restore stalled on some SMB shares [\#1412](https://github.com/vatesfr/xo-web/issues/1412)
|
||||
- Wrong bond display [\#1156](https://github.com/vatesfr/xo-web/issues/1156)
|
||||
- Multiple reboot selection doesn't work [\#1562](https://github.com/vatesfr/xo-web/issues/1562)
|
||||
- Server logs should be displayed in reverse chonological order [\#1547](https://github.com/vatesfr/xo-web/issues/1547)
|
||||
- Cannot create resource sets without limits [\#1537](https://github.com/vatesfr/xo-web/issues/1537)
|
||||
- UI - Weird display when editing long VM desc [\#1528](https://github.com/vatesfr/xo-web/issues/1528)
|
||||
- Useless iso selector in host console [\#1527](https://github.com/vatesfr/xo-web/issues/1527)
|
||||
- Pool and Host dummy welcome message [\#1519](https://github.com/vatesfr/xo-web/issues/1519)
|
||||
- Bug on Network VM tab [\#1518](https://github.com/vatesfr/xo-web/issues/1518)
|
||||
- Link to home with filter in query does not work [\#1513](https://github.com/vatesfr/xo-web/issues/1513)
|
||||
- VHD merge fails with "RangeError: index out of range" on SMB remote [\#1511](https://github.com/vatesfr/xo-web/issues/1511)
|
||||
- DR: previous VDIs are not removed [\#1510](https://github.com/vatesfr/xo-web/issues/1510)
|
||||
- DR: previous copies not removed when same number as depth [\#1509](https://github.com/vatesfr/xo-web/issues/1509)
|
||||
- Empty Saved Search doesn't load when set to default filter [\#1354](https://github.com/vatesfr/xo-web/issues/1354)
|
||||
- Removing a user/group should delete its ACLs [\#899](https://github.com/vatesfr/xo-web/issues/899)
|
||||
- OVA Import - XO stuck during import [\#1551](https://github.com/vatesfr/xo-web/issues/1551)
|
||||
- SMB remote empty domain fails [\#1499](https://github.com/vatesfr/xo-web/issues/1499)
|
||||
- Can't edit a remote password [\#1498](https://github.com/vatesfr/xo-web/issues/1498)
|
||||
- Issue in VM create with CoreOS [\#1493](https://github.com/vatesfr/xo-web/issues/1493)
|
||||
- Overlapping months in backup view [\#1488](https://github.com/vatesfr/xo-web/issues/1488)
|
||||
- No line break for SSH key in user view [\#1475](https://github.com/vatesfr/xo-web/issues/1475)
|
||||
- Create VIF UI issues [\#1472](https://github.com/vatesfr/xo-web/issues/1472)
|
||||
|
||||
## **5.2.0** (2016-09-09)
|
||||
|
||||
### Enhancements
|
||||
|
||||
- IP management [\#1350](https://github.com/vatesfr/xo-web/issues/1350), [\#988](https://github.com/vatesfr/xo-web/issues/988), [\#1427](https://github.com/vatesfr/xo-web/issues/1427) and [\#240](https://github.com/vatesfr/xo-web/issues/240)
|
||||
- Update reverse proxy example [\#1474](https://github.com/vatesfr/xo-web/issues/1474)
|
||||
- Improve log view [\#1467](https://github.com/vatesfr/xo-web/issues/1467)
|
||||
- Backup Reports: e-mail subject [\#1463](https://github.com/vatesfr/xo-web/issues/1463)
|
||||
- Backup Reports: report the error [\#1462](https://github.com/vatesfr/xo-web/issues/1462)
|
||||
- Vif selector: select management network by default [\#1425](https://github.com/vatesfr/xo-web/issues/1425)
|
||||
- Display when browser disconnected to server [\#1417](https://github.com/vatesfr/xo-web/issues/1417)
|
||||
- Tooltip on OS icon in VM view [\#1416](https://github.com/vatesfr/xo-web/issues/1416)
|
||||
- Display pool master [\#1407](https://github.com/vatesfr/xo-web/issues/1407)
|
||||
- Missing tooltips in VM creation view [\#1402](https://github.com/vatesfr/xo-web/issues/1402)
|
||||
- Handle VDB disconnect and connect [\#1397](https://github.com/vatesfr/xo-web/issues/1397)
|
||||
- Eject host from a pool [\#1395](https://github.com/vatesfr/xo-web/issues/1395)
|
||||
- Improve pool general view [\#1393](https://github.com/vatesfr/xo-web/issues/1393)
|
||||
- Improve patching system [\#1392](https://github.com/vatesfr/xo-web/issues/1392)
|
||||
- Pool name modification [\#1390](https://github.com/vatesfr/xo-web/issues/1390)
|
||||
- Confirmation dialog before destroying VDIs [\#1388](https://github.com/vatesfr/xo-web/issues/1388)
|
||||
- Tooltips for meter object [\#1387](https://github.com/vatesfr/xo-web/issues/1387)
|
||||
- New Host assistant [\#1374](https://github.com/vatesfr/xo-web/issues/1374)
|
||||
- New VM assistant [\#1373](https://github.com/vatesfr/xo-web/issues/1373)
|
||||
- New SR assistant [\#1372](https://github.com/vatesfr/xo-web/issues/1372)
|
||||
- Direct access to VDI listing from dashboard's SR usage breakdown [\#1371](https://github.com/vatesfr/xo-web/issues/1371)
|
||||
- Can't set a network name at pool level [\#1368](https://github.com/vatesfr/xo-web/issues/1368)
|
||||
- Change a few mouse over descriptions [\#1363](https://github.com/vatesfr/xo-web/issues/1363)
|
||||
- Hide network install in VM create if template is HVM [\#1362](https://github.com/vatesfr/xo-web/issues/1362)
|
||||
- SR space left during VM creation [\#1358](https://github.com/vatesfr/xo-web/issues/1358)
|
||||
- Add destination SR on migration modal in VM view [\#1357](https://github.com/vatesfr/xo-web/issues/1357)
|
||||
- Ability to create a new VM from a snapshot [\#1353](https://github.com/vatesfr/xo-web/issues/1353)
|
||||
- Missing explanation/confirmation on Snapshot Page [\#1349](https://github.com/vatesfr/xo-web/issues/1349)
|
||||
- Log view: expose API errors in the web UI [\#1344](https://github.com/vatesfr/xo-web/issues/1344)
|
||||
- Registration on update page [\#1341](https://github.com/vatesfr/xo-web/issues/1341)
|
||||
- Add export snapshot button [\#1336](https://github.com/vatesfr/xo-web/issues/1336)
|
||||
- Use saved SSH keys in VM create CloudConfig [\#1319](https://github.com/vatesfr/xo-web/issues/1319)
|
||||
- Collapse header in console view [\#1268](https://github.com/vatesfr/xo-web/issues/1268)
|
||||
- Two max concurrent jobs in parallel [\#915](https://github.com/vatesfr/xo-web/issues/915)
|
||||
- Handle OVA import via the web UI [\#709](https://github.com/vatesfr/xo-web/issues/709)
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- Bug on VM console when header is hidden [\#1485](https://github.com/vatesfr/xo-web/issues/1485)
|
||||
- Disks not removed when deleting multiple VMs [\#1484](https://github.com/vatesfr/xo-web/issues/1484)
|
||||
- Do not display VDI disconnect button when a VM is not running [\#1470](https://github.com/vatesfr/xo-web/issues/1470)
|
||||
- Do not display VIF disconnect button when a VM is not running [\#1468](https://github.com/vatesfr/xo-web/issues/1468)
|
||||
- Error on migration if no default SR \(even when not used\) [\#1466](https://github.com/vatesfr/xo-web/issues/1466)
|
||||
- DR issue while rotating old backup [\#1464](https://github.com/vatesfr/xo-web/issues/1464)
|
||||
- Giving resource set to end-user ends with error [\#1448](https://github.com/vatesfr/xo-web/issues/1448)
|
||||
- Error thrown when cancelling out of Delete User confirmation dialog [\#1439](https://github.com/vatesfr/xo-web/issues/1439)
|
||||
- Wrong month label shown in Backup and Job scheduler [\#1438](https://github.com/vatesfr/xo-web/issues/1438)
|
||||
- Bug on Self service creation/edition [\#1428](https://github.com/vatesfr/xo-web/issues/1428)
|
||||
- ISO selection during VM create is not mounted after [\#1415](https://github.com/vatesfr/xo-web/issues/1415)
|
||||
- Hosts general view: bad link for storage [\#1408](https://github.com/vatesfr/xo-web/issues/1408)
|
||||
- Backup Schedule - "Month" and "Day of Week" display error [\#1404](https://github.com/vatesfr/xo-web/issues/1404)
|
||||
- Migrate dialog doesn't present all available VIF's in new UI interface [\#1403](https://github.com/vatesfr/xo-web/issues/1403)
|
||||
- NFS mount issues [\#1396](https://github.com/vatesfr/xo-web/issues/1396)
|
||||
- Select component color [\#1391](https://github.com/vatesfr/xo-web/issues/1391)
|
||||
- SR created with local path shouldn't be shared [\#1389](https://github.com/vatesfr/xo-web/issues/1389)
|
||||
- Disks (VBD) are attached to VM in RO mode instead of RW even if RO is unchecked [\#1386](https://github.com/vatesfr/xo-web/issues/1386)
|
||||
- Re-connection issues between server and XS hosts [\#1384](https://github.com/vatesfr/xo-web/issues/1384)
|
||||
- Meter object style with Chrome 52 [\#1383](https://github.com/vatesfr/xo-web/issues/1383)
|
||||
- Editing a rolling snapshot job seems to fail [\#1376](https://github.com/vatesfr/xo-web/issues/1376)
|
||||
- Dashboard SR usage and total inverted [\#1370](https://github.com/vatesfr/xo-web/issues/1370)
|
||||
- XenServer connection issue with host while using VGPUs [\#1369](https://github.com/vatesfr/xo-web/issues/1369)
|
||||
- Job created with v4 are not correctly displayed in v5 [\#1366](https://github.com/vatesfr/xo-web/issues/1366)
|
||||
- CPU accounting in resource set [\#1365](https://github.com/vatesfr/xo-web/issues/1365)
|
||||
- Tooltip stay displayed when a button change state [\#1360](https://github.com/vatesfr/xo-web/issues/1360)
|
||||
- Failure on host reboot [\#1351](https://github.com/vatesfr/xo-web/issues/1351)
|
||||
- Editing Backup Jobs Without Compression, Slider Always Set To On [\#1339](https://github.com/vatesfr/xo-web/issues/1339)
|
||||
- Month Selection on Backup Screen Wrong [\#1338](https://github.com/vatesfr/xo-web/issues/1338)
|
||||
- Delta backup fail when removed VDIs [\#1333](https://github.com/vatesfr/xo-web/issues/1333)
|
||||
|
||||
## **5.1.0** (2016-07-26)
|
||||
|
||||
### Enhancements
|
||||
|
||||
- Improve backups timezone UI [\#1314](https://github.com/vatesfr/xo-web/issues/1314)
|
||||
- HOME view submenus [\#1306](https://github.com/vatesfr/xo-web/issues/1306)
|
||||
- Ability for a user to save SSH keys [\#1299](https://github.com/vatesfr/xo-web/issues/1299)
|
||||
- \[ACLs\] Ability to select all hosts/VMs [\#1296](https://github.com/vatesfr/xo-web/issues/1296)
|
||||
- Improve scheduling UI [\#1295](https://github.com/vatesfr/xo-web/issues/1295)
|
||||
- Plugins: Predefined configurations [\#1289](https://github.com/vatesfr/xo-web/issues/1289)
|
||||
- Button to recompute resource sets limits [\#1287](https://github.com/vatesfr/xo-web/issues/1287)
|
||||
- Credit scheduler CAP and weight configuration [\#1283](https://github.com/vatesfr/xo-web/issues/1283)
|
||||
- Migration form problem on the /v5/vms/\_\_UUID\_\_ page when doing xenmotion inside a pool [\#1254](https://github.com/vatesfr/xo-web/issues/1254)
|
||||
- /v5/\#/pools/\_\_UUID\_\_: patch table improvement [\#1246](https://github.com/vatesfr/xo-web/issues/1246)
|
||||
- /v5/\#/hosts/\_\_UUID\_\_: patch list improvements ? [\#1245](https://github.com/vatesfr/xo-web/issues/1245)
|
||||
- F\*cking patches, how do they work? [\#1236](https://github.com/vatesfr/xo-web/issues/1236)
|
||||
- Change Default Filter [\#1235](https://github.com/vatesfr/xo-web/issues/1235)
|
||||
- Add a property on jobs to know their state [\#1232](https://github.com/vatesfr/xo-web/issues/1232)
|
||||
- Spanish translation [\#1231](https://github.com/vatesfr/xo-web/issues/1231)
|
||||
- Home: "Filter" input and keyboard focus [\#1228](https://github.com/vatesfr/xo-web/issues/1228)
|
||||
- Display xenserver version [\#1225](https://github.com/vatesfr/xo-web/issues/1225)
|
||||
- Plugin config: presets & defaults [\#1222](https://github.com/vatesfr/xo-web/issues/1222)
|
||||
- Allow halted VM migration [\#1216](https://github.com/vatesfr/xo-web/issues/1216)
|
||||
- Missing confirm dialog on critical button [\#1211](https://github.com/vatesfr/xo-web/issues/1211)
|
||||
- Backup logs are not sortable [\#1196](https://github.com/vatesfr/xo-web/issues/1196)
|
||||
- Page title with the name of current object [\#1185](https://github.com/vatesfr/xo-web/issues/1185)
|
||||
- Existing VIF management [\#1176](https://github.com/vatesfr/xo-web/issues/1176)
|
||||
- Do not display fast clone option is there isn't template disks [\#1172](https://github.com/vatesfr/xo-web/issues/1172)
|
||||
- UI issue when adding a user [\#1159](https://github.com/vatesfr/xo-web/issues/1159)
|
||||
- Combined values on stats [\#1158](https://github.com/vatesfr/xo-web/issues/1158)
|
||||
- Parallel coordinates graph [\#1157](https://github.com/vatesfr/xo-web/issues/1157)
|
||||
- VM creation on self-service as user [\#1155](https://github.com/vatesfr/xo-web/issues/1155)
|
||||
- VM copy bulk action on home view [\#1154](https://github.com/vatesfr/xo-web/issues/1154)
|
||||
- Better VDI map [\#1151](https://github.com/vatesfr/xo-web/issues/1151)
|
||||
- Missing tooltips on buttons [\#1150](https://github.com/vatesfr/xo-web/issues/1150)
|
||||
- Patching from pool view [\#1149](https://github.com/vatesfr/xo-web/issues/1149)
|
||||
- Missing patches in dashboard [\#1148](https://github.com/vatesfr/xo-web/issues/1148)
|
||||
- Improve tasks view [\#1147](https://github.com/vatesfr/xo-web/issues/1147)
|
||||
- Home bulk VM migration [\#1146](https://github.com/vatesfr/xo-web/issues/1146)
|
||||
- LDAP plugin clear password field [\#1145](https://github.com/vatesfr/xo-web/issues/1145)
|
||||
- Cron default behavior [\#1144](https://github.com/vatesfr/xo-web/issues/1144)
|
||||
- Modal for migrate on home [\#1143](https://github.com/vatesfr/xo-web/issues/1143)
|
||||
- /v5/\#/srs/\_\_UUID\_\_: UI improvements [\#1142](https://github.com/vatesfr/xo-web/issues/1142)
|
||||
- /v5/\#/pools/: some name should be links [\#1141](https://github.com/vatesfr/xo-web/issues/1141)
|
||||
- create the page /v5/\#/pools/ [\#1140](https://github.com/vatesfr/xo-web/issues/1140)
|
||||
- Dashboard: add links to different part of XOA [\#1139](https://github.com/vatesfr/xo-web/issues/1139)
|
||||
- /v5/\#/dashboard/overview: add link on the "Top 5 SR Usage" graph [\#1135](https://github.com/vatesfr/xo-web/issues/1135)
|
||||
- /v5/\#/backup/overview: display the error when there is one returned by xenserver on failed job. [\#1134](https://github.com/vatesfr/xo-web/issues/1134)
|
||||
- /v5/: add an option to set the number of element displayed in tables [\#1133](https://github.com/vatesfr/xo-web/issues/1133)
|
||||
- Updater refresh page after update [\#1131](https://github.com/vatesfr/xo-web/issues/1131)
|
||||
- /v5/\#/settings/plugins [\#1130](https://github.com/vatesfr/xo-web/issues/1130)
|
||||
- /v5/\#/new/sr: layout issue [\#1129](https://github.com/vatesfr/xo-web/issues/1129)
|
||||
- v5 /v5/\#/vms/new: layout issue [\#1128](https://github.com/vatesfr/xo-web/issues/1128)
|
||||
- v5 user page missing style [\#1127](https://github.com/vatesfr/xo-web/issues/1127)
|
||||
- Remote helper/tester [\#1075](https://github.com/vatesfr/xo-web/issues/1075)
|
||||
- Generate uiSchema from custom schema properties [\#951](https://github.com/vatesfr/xo-web/issues/951)
|
||||
- Customizing VM names generation during batch creation [\#949](https://github.com/vatesfr/xo-web/issues/949)
|
||||
|
||||
### Bug fixes
|
||||
|
||||
- Plugins: Don't use `default` attributes in presets list [\#1288](https://github.com/vatesfr/xo-web/issues/1288)
|
||||
- CPU weight must be an integer [\#1286](https://github.com/vatesfr/xo-web/issues/1286)
|
||||
- Overview of self service is always empty [\#1282](https://github.com/vatesfr/xo-web/issues/1282)
|
||||
- SR attach/creation issue [\#1281](https://github.com/vatesfr/xo-web/issues/1281)
|
||||
- Self service resources not modified after a VM deletion [\#1276](https://github.com/vatesfr/xo-web/issues/1276)
|
||||
- Scheduled jobs seems use GMT since 5.0 [\#1258](https://github.com/vatesfr/xo-web/issues/1258)
|
||||
- Can't create a VM with disks on 2 different SRs [\#1257](https://github.com/vatesfr/xo-web/issues/1257)
|
||||
- Graph display bug [\#1247](https://github.com/vatesfr/xo-web/issues/1247)
|
||||
- /v5/#/hosts/__UUID__: Patch list not limited to the current pool [\#1244](https://github.com/vatesfr/xo-web/issues/1244)
|
||||
- Replication issues [\#1233](https://github.com/vatesfr/xo-web/issues/1233)
|
||||
- VM creation install method disabled fields [\#1198](https://github.com/vatesfr/xo-web/issues/1198)
|
||||
- Update icon shouldn't be displayed when menu is collapsed [\#1188](https://github.com/vatesfr/xo-web/issues/1188)
|
||||
- /v5/ : Load average graph axis issue [\#1167](https://github.com/vatesfr/xo-web/issues/1167)
|
||||
- Some remote can't be opened [\#1164](https://github.com/vatesfr/xo-web/issues/1164)
|
||||
- Bulk action for hosts in home and pool view [\#1153](https://github.com/vatesfr/xo-web/issues/1153)
|
||||
- New Vif [\#1138](https://github.com/vatesfr/xo-web/issues/1138)
|
||||
- Missing SRs [\#1123](https://github.com/vatesfr/xo-web/issues/1123)
|
||||
- Continuous replication email alert does not obey per job setting [\#1121](https://github.com/vatesfr/xo-web/issues/1121)
|
||||
- Safari XO5 issue [\#1120](https://github.com/vatesfr/xo-web/issues/1120)
|
||||
- ACLs shoud be available in Enterprise Edition [\#1118](https://github.com/vatesfr/xo-web/issues/1118)
|
||||
- SR edit name or description doesn't work [\#1116](https://github.com/vatesfr/xo-web/issues/1116)
|
||||
- Bad RRD parsing for VIFs [\#969](https://github.com/vatesfr/xo-web/issues/969)
|
||||
|
||||
## **5.0.0** (2016-06-24)
|
||||
|
||||
### Enhancements
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Xen Orchestra Web [](https://travis-ci.org/vatesfr/xo-web)
|
||||
# Xen Orchestra Web [](https://go.crisp.im/chat/embed/?website_id=-JzqzzwddSV7bKGtEyAQ) [](https://travis-ci.org/vatesfr/xo-web)
|
||||
|
||||

|
||||
|
||||
@@ -10,7 +10,7 @@ ___
|
||||
|
||||
## Installation
|
||||
|
||||
XOA or manual install procedure is [available here](https://github.com/vatesfr/xo/blob/master/doc/installation/README.md)
|
||||
XOA or manual install procedure is [available here](https://xen-orchestra.com/docs/installation.html)
|
||||
|
||||
## Compilation
|
||||
|
||||
|
||||
43
gulpfile.js
43
gulpfile.js
@@ -11,17 +11,6 @@ var DIST_DIR = __dirname + '/dist' // eslint-disable-line no-path-concat
|
||||
// http://www.random.org/integers/?num=1&min=1024&max=65535&col=1&base=10&format=plain&rnd=new
|
||||
var LIVERELOAD_PORT = 26242
|
||||
|
||||
// Port to use for the embedded web server.
|
||||
//
|
||||
// Set to 0 to choose a random port at each run.
|
||||
var SERVER_PORT = LIVERELOAD_PORT + 1
|
||||
|
||||
// Address the server should bind to.
|
||||
//
|
||||
// - `'localhost'` to make it accessible from this host only
|
||||
// - `null` to make it accessible for the whole network
|
||||
var SERVER_ADDR = 'localhost'
|
||||
|
||||
var PRODUCTION = process.env.NODE_ENV === 'production'
|
||||
var DEVELOPMENT = !PRODUCTION
|
||||
|
||||
@@ -173,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',
|
||||
@@ -249,8 +238,8 @@ function browserify (path, opts) {
|
||||
|
||||
gulp.task(function buildPages () {
|
||||
return pipe(
|
||||
src('index.jade', { sourcemaps: true }),
|
||||
require('gulp-jade')(),
|
||||
src('index.pug', { sourcemaps: true }),
|
||||
require('gulp-pug')(),
|
||||
DEVELOPMENT && require('gulp-embedlr')({
|
||||
port: LIVERELOAD_PORT
|
||||
}),
|
||||
@@ -268,6 +257,7 @@ gulp.task(function buildScripts () {
|
||||
}]
|
||||
]
|
||||
}),
|
||||
require('gulp-sourcemaps').init({ loadMaps: true }),
|
||||
PRODUCTION && require('gulp-uglify')(),
|
||||
dest()
|
||||
)
|
||||
@@ -313,28 +303,3 @@ gulp.task('build', gulp.parallel(
|
||||
gulp.task(function clean (done) {
|
||||
require('rimraf')(DIST_DIR, done)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
gulp.task(function server (done) {
|
||||
require('connect')()
|
||||
.use(require('serve-static')(DIST_DIR))
|
||||
.listen(SERVER_PORT, SERVER_ADDR, function onListen () {
|
||||
var address = this.address()
|
||||
|
||||
var port = address.port
|
||||
address = address.address
|
||||
|
||||
// Correctly handle IPv6 addresses.
|
||||
if (address.indexOf(':') !== -1) {
|
||||
address = '[' + address + ']'
|
||||
}
|
||||
|
||||
/* jshint devel: true*/
|
||||
console.log('Listening on http://' + address + ':' + port)
|
||||
})
|
||||
.on('error', done)
|
||||
.on('close', function onClose () {
|
||||
done()
|
||||
})
|
||||
})
|
||||
|
||||
118
package.json
118
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"private": false,
|
||||
"name": "xo-web",
|
||||
"version": "5.0.8",
|
||||
"version": "5.6.3",
|
||||
"license": "AGPL-3.0",
|
||||
"description": "Web interface client for Xen-Orchestra",
|
||||
"keywords": [
|
||||
@@ -33,72 +33,90 @@
|
||||
"devDependencies": {
|
||||
"ansi_up": "^1.3.0",
|
||||
"asap": "^2.0.4",
|
||||
"ava": "^0.15.0",
|
||||
"babel-eslint": "^6.0.0",
|
||||
"babel-eslint": "^7.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",
|
||||
"babel-plugin-transform-react-jsx-self": "^6.11.0",
|
||||
"babel-plugin-transform-react-jsx-source": "^6.9.0",
|
||||
"babel-plugin-transform-runtime": "^6.6.0",
|
||||
"babel-preset-es2015": "^6.6.0",
|
||||
"babel-preset-react": "^6.5.0",
|
||||
"babel-preset-stage-0": "^6.5.0",
|
||||
"babel-register": "^6.16.3",
|
||||
"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-plugin-legend": "^0.3.1",
|
||||
"chartist": "^0.10.1",
|
||||
"chartist-plugin-legend": "^0.6.1",
|
||||
"chartist-plugin-tooltip": "0.0.11",
|
||||
"classnames": "^2.2.3",
|
||||
"connect": "^3.4.1",
|
||||
"cookies-js": "^1.2.2",
|
||||
"d3": "^4.0.0-alpha.50",
|
||||
"d3": "^4.2.8",
|
||||
"dependency-check": "^2.5.1",
|
||||
"font-awesome": "^4.5.0",
|
||||
"enzyme": "^2.6.0",
|
||||
"enzyme-to-json": "^1.4.4",
|
||||
"event-to-promise": "^0.7.0",
|
||||
"font-awesome": "^4.7.0",
|
||||
"font-mfizz": "github:fizzed/font-mfizz",
|
||||
"ghooks": "^1.1.1",
|
||||
"globby": "^5.0.0",
|
||||
"get-stream": "^2.3.0",
|
||||
"globby": "^6.0.0",
|
||||
"gulp": "github:gulpjs/gulp#4.0",
|
||||
"gulp-autoprefixer": "^3.1.0",
|
||||
"gulp-csso": "^2.0.0",
|
||||
"gulp-embedlr": "^0.5.2",
|
||||
"gulp-jade": "^1.1.0",
|
||||
"gulp-plumber": "^1.1.0",
|
||||
"gulp-pug": "^3.1.0",
|
||||
"gulp-refresh": "^1.1.0",
|
||||
"gulp-sass": "^2.2.0",
|
||||
"gulp-uglify": "^1.5.3",
|
||||
"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",
|
||||
"jsonrpc-websocket-client": "0.0.1-5",
|
||||
"human-format": "^0.7.0",
|
||||
"husky": "^0.13.1",
|
||||
"index-modules": "^0.3.0",
|
||||
"is-ip": "^1.0.0",
|
||||
"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.22.1",
|
||||
"modular-css": "^4.1.1",
|
||||
"moment": "^2.13.0",
|
||||
"notifyjs": "^2.0.1",
|
||||
"moment-timezone": "^0.5.4",
|
||||
"notifyjs": "^3.0.0",
|
||||
"novnc-node": "^0.5.3",
|
||||
"promise-toolbox": "^0.4.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-dom": "^15.0.0",
|
||||
"react-document-title": "^2.0.2",
|
||||
"react-dom": "^15.4.1",
|
||||
"react-dropzone": "^3.5.0",
|
||||
"react-intl": "^2.0.1",
|
||||
"react-key-handler": "^0.2.0",
|
||||
"react-key-handler": "^0.3.0",
|
||||
"react-notify": "^2.0.1",
|
||||
"react-redux": "^4.4.0",
|
||||
"react-router": "^3.0.0-alpha.1",
|
||||
"react-select": "^1.0.0-beta13",
|
||||
"react-overlays": "^0.6.0",
|
||||
"react-redux": "^5.0.0",
|
||||
"react-router": "^3.0.0",
|
||||
"react-select": "^1.0.0-rc.3",
|
||||
"react-shortcuts": "^1.3.1",
|
||||
"react-sparklines": "^1.5.0",
|
||||
"react-virtualized": "^7.4.0",
|
||||
"react-virtualized": "^8.0.8",
|
||||
"readable-stream": "^2.0.6",
|
||||
"redux": "^3.3.1",
|
||||
"redux-devtools": "^3.1.1",
|
||||
@@ -106,25 +124,28 @@
|
||||
"redux-devtools-log-monitor": "^1.0.5",
|
||||
"redux-thunk": "^2.0.1",
|
||||
"reselect": "^2.2.1",
|
||||
"serve-static": "^1.10.2",
|
||||
"standard": "^7.0.0",
|
||||
"superagent": "^2.0.0",
|
||||
"vinyl": "^1.1.1",
|
||||
"standard": "^8.4.0",
|
||||
"superagent": "^3.5.0",
|
||||
"tar-stream": "^1.5.2",
|
||||
"vinyl": "^2.0.0",
|
||||
"watchify": "^3.7.0",
|
||||
"xo-acl-resolver": "^0.2.1",
|
||||
"xo-lib": "^0.8.0-1",
|
||||
"xml2js": "^0.4.17",
|
||||
"xo-acl-resolver": "^0.2.3",
|
||||
"xo-common": "0.1.0",
|
||||
"xo-lib": "^0.8.0",
|
||||
"xo-remote-parser": "^0.3"
|
||||
},
|
||||
"scripts": {
|
||||
"benchmarks": "./tools/run-benchmarks.js 'src/**/*.bench.js'",
|
||||
"build": "npm run build-indexes && NODE_ENV=production gulp build",
|
||||
"build-indexes": "./tools/generate-index src/common/intl/locales",
|
||||
"dev": "npm run build-indexes && gulp build server",
|
||||
"dev-test": "ava --watch",
|
||||
"build-indexes": "index-modules --auto src",
|
||||
"commitmsg": "npm test",
|
||||
"dev": "npm run build-indexes && NODE_ENV=development gulp build",
|
||||
"dev-test": "jest --watch",
|
||||
"lint": "standard",
|
||||
"posttest": "npm run lint",
|
||||
"prepublish": "npm run build",
|
||||
"test": "ava"
|
||||
"test": "jest"
|
||||
},
|
||||
"browserify": {
|
||||
"transform": [
|
||||
@@ -132,17 +153,14 @@
|
||||
"loose-envify"
|
||||
]
|
||||
},
|
||||
"ava": {
|
||||
"babel": "inherit",
|
||||
"files": [
|
||||
"src/**/*.spec.js"
|
||||
],
|
||||
"require": [
|
||||
"babel-register"
|
||||
]
|
||||
},
|
||||
"babel": {
|
||||
"env": {
|
||||
"development": {
|
||||
"plugins": [
|
||||
"transform-react-jsx-self",
|
||||
"transform-react-jsx-source"
|
||||
]
|
||||
},
|
||||
"production": {
|
||||
"plugins": [
|
||||
"transform-react-constant-elements",
|
||||
@@ -160,10 +178,10 @@
|
||||
"stage-0"
|
||||
]
|
||||
},
|
||||
"config": {
|
||||
"ghooks": {
|
||||
"commit-msg": "npm test"
|
||||
}
|
||||
"jest": {
|
||||
"snapshotSerializers": [
|
||||
"enzyme-to-json/serializer"
|
||||
]
|
||||
},
|
||||
"standard": {
|
||||
"ignore": [
|
||||
|
||||
@@ -4,16 +4,16 @@
|
||||
$ct-series-colors: (
|
||||
$brand-success,
|
||||
$brand-primary,
|
||||
#60bd68,
|
||||
#f17cb0,
|
||||
#b2912f,
|
||||
#b276b2,
|
||||
#decf3f,
|
||||
#f15854,
|
||||
#4d4d4d,
|
||||
#dda458,
|
||||
#eacf7d,
|
||||
#86797d,
|
||||
#b276b2,
|
||||
#f15854,
|
||||
#b2912f,
|
||||
#decf3f,
|
||||
#dda458,
|
||||
#60bd68,
|
||||
#4d4d4d,
|
||||
#eacf7d,
|
||||
#b2c326,
|
||||
#6188e2,
|
||||
#a748ca
|
||||
@@ -22,6 +22,18 @@ $ct-series-colors: (
|
||||
@import "../node_modules/chartist/dist/scss/settings/_chartist-settings";
|
||||
@import "../node_modules/chartist/dist/scss/chartist";
|
||||
|
||||
.ct-chart {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -55,7 +67,6 @@ $ct-series-colors: (
|
||||
|
||||
// Arrow!
|
||||
&:before {
|
||||
position: absolute;
|
||||
bottom: -14px;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
@@ -80,28 +91,27 @@ $ct-series-colors: (
|
||||
// CHARTIST LEGEND =============================================================
|
||||
|
||||
.ct-legend {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
margin-bottom: -1em;
|
||||
|
||||
li {
|
||||
position: relative;
|
||||
padding-left: 1.4em;
|
||||
padding-left: 0.5em;
|
||||
list-style-type: none;
|
||||
display: inline;
|
||||
display: inline-block;
|
||||
margin-right: 0.5em;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
li:before {
|
||||
display: inline-block;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
content: '';
|
||||
border: 3px solid transparent;
|
||||
border-radius: 2px;
|
||||
margin-top: 0.5em;
|
||||
margin-right: 0.2em;
|
||||
}
|
||||
|
||||
li.inactive:before {
|
||||
|
||||
19
src/common/__snapshots__/grid.spec.js.snap
Normal file
19
src/common/__snapshots__/grid.spec.js.snap
Normal 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"
|
||||
/>
|
||||
`;
|
||||
@@ -1,26 +1,35 @@
|
||||
// import _ from 'intl' TODO: fix tooltip
|
||||
import _ from 'intl'
|
||||
import ActionButton from 'action-button'
|
||||
import map from 'lodash/map'
|
||||
import React from 'react'
|
||||
// import Tooltip from 'tooltip' TODO: fix tooltip
|
||||
import Tooltip from 'tooltip'
|
||||
import {
|
||||
ButtonGroup
|
||||
} from 'react-bootstrap-4/lib'
|
||||
import {
|
||||
noop
|
||||
} from 'utils'
|
||||
|
||||
const ActionBar = ({ actions, param }) => (
|
||||
<ButtonGroup>
|
||||
{map(actions, ({ handler, handlerParam = param, label, icon }, index) => (
|
||||
/* <Tooltip key={index} content={_(label)}> TODO: fix tooltip */
|
||||
{map(actions, (button, index) => {
|
||||
if (!button) {
|
||||
return
|
||||
}
|
||||
|
||||
const { handler, handlerParam = param, label, icon, redirectOnSuccess } = button
|
||||
return <Tooltip key={index} content={_(label)}>
|
||||
<ActionButton
|
||||
key={index}
|
||||
btnStyle='secondary'
|
||||
handler={handler}
|
||||
handler={handler || noop}
|
||||
handlerParam={handlerParam}
|
||||
icon={icon}
|
||||
redirectOnSuccess={redirectOnSuccess}
|
||||
size='large'
|
||||
/>
|
||||
/* </Tooltip> */
|
||||
))}
|
||||
</Tooltip>
|
||||
})}
|
||||
</ButtonGroup>
|
||||
)
|
||||
ActionBar.propTypes = {
|
||||
@@ -28,7 +37,8 @@ ActionBar.propTypes = {
|
||||
React.PropTypes.shape({
|
||||
label: React.PropTypes.string.isRequired,
|
||||
icon: React.PropTypes.string.isRequired,
|
||||
handler: React.PropTypes.func
|
||||
handler: React.PropTypes.func,
|
||||
redirectOnSuccess: React.PropTypes.string
|
||||
})
|
||||
).isRequired,
|
||||
display: React.PropTypes.oneOf(['icon', 'text', 'both'])
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button } from 'react-bootstrap-4/lib'
|
||||
import Component from './base-component'
|
||||
import logError from './log-error'
|
||||
import propTypes from './prop-types'
|
||||
import Tooltip from './tooltip'
|
||||
|
||||
@propTypes({
|
||||
btnStyle: propTypes.string,
|
||||
@@ -21,7 +22,8 @@ import propTypes from './prop-types'
|
||||
size: propTypes.oneOf([
|
||||
'large',
|
||||
'small'
|
||||
])
|
||||
]),
|
||||
tooltip: propTypes.node
|
||||
})
|
||||
export default class ActionButton extends Component {
|
||||
static contextTypes = {
|
||||
@@ -62,7 +64,11 @@ export default class ActionButton extends Component {
|
||||
error,
|
||||
working: false
|
||||
})
|
||||
logError(error)
|
||||
|
||||
// ignore when undefined because it usually means that the action has been canceled
|
||||
if (error !== undefined) {
|
||||
logError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
_execute = ::this._execute
|
||||
@@ -98,12 +104,13 @@ export default class ActionButton extends Component {
|
||||
form,
|
||||
icon,
|
||||
size: bsSize,
|
||||
style
|
||||
style,
|
||||
tooltip
|
||||
},
|
||||
state: { error, working }
|
||||
} = this
|
||||
|
||||
return <Button
|
||||
const button = <Button
|
||||
bsStyle={error ? 'warning' : btnStyle}
|
||||
form={form}
|
||||
onClick={!form && this._execute}
|
||||
@@ -115,5 +122,9 @@ export default class ActionButton extends Component {
|
||||
{children && ' '}
|
||||
{children}
|
||||
</Button>
|
||||
|
||||
return tooltip
|
||||
? <Tooltip content={tooltip}>{button}</Tooltip>
|
||||
: button
|
||||
}
|
||||
}
|
||||
|
||||
87
src/common/auto-controlled-input.js
Normal file
87
src/common/auto-controlled-input.js
Normal file
@@ -0,0 +1,87 @@
|
||||
import React from 'react'
|
||||
import { isFunction, omit } from 'lodash'
|
||||
|
||||
import Component from './base-component'
|
||||
import getEventValue from './get-event-value'
|
||||
|
||||
const __DEV__ = process.env.NODE_ENV !== 'production'
|
||||
|
||||
// This decorator can be used on a controlled input component to make
|
||||
// it able to automatically handled the uncontrolled mode.
|
||||
export default options => ControlledInput => {
|
||||
class AutoControlledInput extends Component {
|
||||
constructor (props) {
|
||||
super()
|
||||
|
||||
const opts = isFunction(options) ? options(props) : options
|
||||
const controlled = this._controlled = 'value' in props
|
||||
if (!controlled) {
|
||||
this.state.value = props.defaultValue || opts && opts.defaultValue
|
||||
|
||||
this._onChange = event => {
|
||||
let defaultPrevented = false
|
||||
|
||||
const { onChange } = this.props
|
||||
if (onChange) {
|
||||
onChange(event)
|
||||
defaultPrevented = event && event.defaultPrevented
|
||||
}
|
||||
|
||||
if (!defaultPrevented) {
|
||||
this.setState({ value: getEventValue(event) })
|
||||
}
|
||||
}
|
||||
} else if (__DEV__ && 'defaultValue' in props) {
|
||||
throw new Error(`${this.constructor.name}: controlled component should not have a default value`)
|
||||
}
|
||||
}
|
||||
|
||||
get value () {
|
||||
return this._controlled
|
||||
? this.props.value
|
||||
: this.state.value
|
||||
}
|
||||
|
||||
set value (value) {
|
||||
if (__DEV__ && this._controlled) {
|
||||
throw new Error(`${this.constructor.name}: should not set value on controlled component`)
|
||||
}
|
||||
|
||||
this.setState({ value })
|
||||
}
|
||||
|
||||
render () {
|
||||
if (this._controlled) {
|
||||
return <ControlledInput {...this.props} />
|
||||
}
|
||||
|
||||
return <ControlledInput
|
||||
{...omit(this.props, 'defaultValue')}
|
||||
onChange={this._onChange}
|
||||
value={this.state.value}
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
||||
if (__DEV__) {
|
||||
AutoControlledInput.prototype.componentWillReceiveProps = function (newProps) {
|
||||
const { name } = this.constructor
|
||||
const controlled = this._controlled
|
||||
const newControlled = 'value' in newProps
|
||||
|
||||
if (!controlled) {
|
||||
if (newControlled) {
|
||||
throw new Error(`${name}: uncontrolled component should not become controlled`)
|
||||
}
|
||||
} else if (!newControlled) {
|
||||
throw new Error(`${name}: controlled component should not become uncontrolled`)
|
||||
}
|
||||
|
||||
if (newProps.defaultValue !== this.props.defaultValue) {
|
||||
throw new Error(`${name}: default value should not change`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return AutoControlledInput
|
||||
}
|
||||
@@ -1,10 +1,41 @@
|
||||
import clone from 'lodash/clone'
|
||||
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 getEventValue from './get-event-value'
|
||||
import invoke from './invoke'
|
||||
import shallowEqual from './shallow-equal'
|
||||
|
||||
// Should components logs every renders?
|
||||
//
|
||||
// Usually set to process.env.NODE_ENV !== 'production'.
|
||||
const VERBOSE = false
|
||||
|
||||
const cowSet = (object, path, value, depth) => {
|
||||
if (depth >= path.length) {
|
||||
return value
|
||||
}
|
||||
|
||||
object = clone(object)
|
||||
const prop = path[depth]
|
||||
object[prop] = cowSet(object[prop], path, value, depth + 1)
|
||||
return object
|
||||
}
|
||||
|
||||
const get = (object, path, depth) => {
|
||||
if (depth >= path.length) {
|
||||
return object
|
||||
}
|
||||
|
||||
const prop = path[depth++]
|
||||
return isArray(object) && prop === '*'
|
||||
? map(object, value => get(value, path, depth))
|
||||
: get(object[prop], path, depth)
|
||||
}
|
||||
|
||||
export default class BaseComponent extends Component {
|
||||
constructor (props, context) {
|
||||
super(props, context)
|
||||
@@ -14,7 +45,7 @@ export default class BaseComponent extends Component {
|
||||
|
||||
this._linkedState = null
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
if (VERBOSE) {
|
||||
this.render = invoke(this.render, render => () => {
|
||||
console.log('render', this.constructor.name)
|
||||
|
||||
@@ -24,7 +55,42 @@ export default class BaseComponent extends Component {
|
||||
}
|
||||
|
||||
// See https://preactjs.com/guide/linked-state
|
||||
linkState (name) {
|
||||
linkState (name, targetPath) {
|
||||
const key = targetPath
|
||||
? `${name}##${targetPath}`
|
||||
: name
|
||||
|
||||
let linkedState = this._linkedState
|
||||
let cb
|
||||
if (!linkedState) {
|
||||
linkedState = this._linkedState = {}
|
||||
} else if ((cb = linkedState[key])) {
|
||||
return cb
|
||||
}
|
||||
|
||||
let getValue
|
||||
if (targetPath) {
|
||||
const path = targetPath.split('.')
|
||||
getValue = event => get(getEventValue(event), path, 0)
|
||||
} else {
|
||||
getValue = getEventValue
|
||||
}
|
||||
|
||||
if (includes(name, '.')) {
|
||||
const path = name.split('.')
|
||||
return (linkedState[key] = event => {
|
||||
this.setState(cowSet(this.state, path, getValue(event), 0))
|
||||
})
|
||||
}
|
||||
|
||||
return (linkedState[key] = event => {
|
||||
this.setState({
|
||||
[name]: getValue(event)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
toggleState (name) {
|
||||
let linkedState = this._linkedState
|
||||
let cb
|
||||
if (!linkedState) {
|
||||
@@ -33,9 +99,16 @@ export default class BaseComponent extends Component {
|
||||
return cb
|
||||
}
|
||||
|
||||
return (linkedState[name] = event => {
|
||||
if (includes(name, '.')) {
|
||||
const path = name.split('.')
|
||||
return (linkedState[path] = event => {
|
||||
this.setState(cowSet(this.state, path, !get(this.state, path, 0), 0))
|
||||
})
|
||||
}
|
||||
|
||||
return (linkedState[name] = () => {
|
||||
this.setState({
|
||||
[name]: getEventValue(event)
|
||||
[name]: !this.state[name]
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -48,7 +121,7 @@ export default class BaseComponent extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
if (VERBOSE) {
|
||||
const diff = (name, old, cur) => {
|
||||
const keys = []
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,9 +13,10 @@ import {
|
||||
import styles from './index.css'
|
||||
|
||||
@propTypes({
|
||||
buttonTitle: propTypes.any,
|
||||
defaultValue: propTypes.any,
|
||||
disabled: propTypes.bool,
|
||||
max: propTypes.number,
|
||||
min: propTypes.number,
|
||||
options: propTypes.oneOfType([
|
||||
propTypes.arrayOf(propTypes.string),
|
||||
propTypes.number,
|
||||
@@ -31,7 +32,6 @@ import styles from './index.css'
|
||||
})
|
||||
export default class Combobox extends Component {
|
||||
static defaultProps = {
|
||||
buttonTitle: 'Presets',
|
||||
type: 'text'
|
||||
}
|
||||
|
||||
@@ -45,7 +45,10 @@ export default class Combobox extends Component {
|
||||
|
||||
_handleChange = event => {
|
||||
const { onChange } = this.props
|
||||
onChange && (() => onChange(event.target.value))
|
||||
|
||||
if (onChange) {
|
||||
onChange(event.target.value)
|
||||
}
|
||||
}
|
||||
|
||||
_setText (value) {
|
||||
@@ -59,8 +62,10 @@ export default class Combobox extends Component {
|
||||
const Input = (
|
||||
<input
|
||||
className='form-control'
|
||||
defaultValue={props.defaultValue || ''}
|
||||
defaultValue={props.defaultValue}
|
||||
disabled={props.disabled}
|
||||
max={props.max}
|
||||
min={props.min}
|
||||
options={options}
|
||||
onChange={this._handleChange}
|
||||
placeholder={props.placeholder}
|
||||
@@ -84,7 +89,7 @@ export default class Combobox extends Component {
|
||||
className={styles.button}
|
||||
disabled={props.disabled}
|
||||
id='selectInput'
|
||||
title={props.buttonTitle}
|
||||
title=''
|
||||
>
|
||||
{map(options, option => (
|
||||
<MenuItem key={option} onClick={() => this._setText(option)}>
|
||||
|
||||
@@ -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')
|
||||
])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import _ from 'intl'
|
||||
import CopyToClipboard from 'react-copy-to-clipboard'
|
||||
import classNames from 'classnames'
|
||||
import Tooltip from 'tooltip'
|
||||
import React, { createElement } from 'react'
|
||||
|
||||
import Icon from '../icon'
|
||||
@@ -10,18 +12,20 @@ import styles from './index.css'
|
||||
const Copiable = propTypes({
|
||||
data: propTypes.string,
|
||||
tagName: propTypes.string
|
||||
})(props => createElement(
|
||||
props.tagName || 'span',
|
||||
})(({ className, tagName = 'span', ...props }) => createElement(
|
||||
tagName,
|
||||
{
|
||||
...props,
|
||||
className: classNames(styles.container, props.className)
|
||||
className: classNames(styles.container, className)
|
||||
},
|
||||
props.children,
|
||||
' ',
|
||||
<CopyToClipboard text={props.data || props.children}>
|
||||
<button className={classNames('btn btn-sm btn-secondary', styles.button)}>
|
||||
<Icon icon='clipboard' />
|
||||
</button>
|
||||
</CopyToClipboard>
|
||||
<Tooltip content={_('copyToClipboard')}>
|
||||
<CopyToClipboard text={props.data || props.children}>
|
||||
<button className={classNames('btn btn-sm btn-secondary', styles.button)}>
|
||||
<Icon icon='clipboard' />
|
||||
</button>
|
||||
</CopyToClipboard>
|
||||
</Tooltip>
|
||||
))
|
||||
export { Copiable as default }
|
||||
|
||||
@@ -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 {
|
||||
@@ -35,8 +37,8 @@ class DebugAsync extends Component {
|
||||
|
||||
return <pre>
|
||||
{'Promise { '}
|
||||
{status === 'rejected' && '<rejected> '}
|
||||
{toString(value)}
|
||||
{status === 'rejected' && '<rejected> '}
|
||||
{toString(value)}
|
||||
{' }'}
|
||||
</pre>
|
||||
}
|
||||
|
||||
22
src/common/dropzone/index.css
Normal file
22
src/common/dropzone/index.css
Normal file
@@ -0,0 +1,22 @@
|
||||
@value dropzoneColor: #8f8686;
|
||||
|
||||
.dropzone {
|
||||
border-radius: 4px;
|
||||
border: 2px dashed dropzoneColor;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
height: 12em;
|
||||
margin-bottom: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.activeDropzone {
|
||||
background: #f0f0f0;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.dropzoneText {
|
||||
color: dropzoneColor;
|
||||
font-size: 1.2em;
|
||||
margin: auto;
|
||||
}
|
||||
20
src/common/dropzone/index.js
Normal file
20
src/common/dropzone/index.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import Component from 'base-component'
|
||||
import propTypes from 'prop-types'
|
||||
import React from 'react'
|
||||
import ReactDropzone from 'react-dropzone'
|
||||
|
||||
import styles from './index.css'
|
||||
|
||||
@propTypes({
|
||||
onDrop: propTypes.func,
|
||||
message: propTypes.node
|
||||
})
|
||||
export default class Dropzone extends Component {
|
||||
render () {
|
||||
const { onDrop, message } = this.props
|
||||
|
||||
return <ReactDropzone onDrop={onDrop} className={styles.dropzone} activeClassName={styles.activeDropzone}>
|
||||
<div className={styles.dropzoneText}>{message}</div>
|
||||
</ReactDropzone>
|
||||
}
|
||||
}
|
||||
13
src/common/editable/index.css
Normal file
13
src/common/editable/index.css
Normal file
@@ -0,0 +1,13 @@
|
||||
.clickToEdit * {
|
||||
cursor: context-menu !important;
|
||||
}
|
||||
.shortClick {
|
||||
border-bottom: 1px dashed #ccc;
|
||||
}
|
||||
|
||||
.select {
|
||||
padding: 0px;
|
||||
}
|
||||
.size {
|
||||
width: 10rem;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import classNames from 'classnames'
|
||||
import findKey from 'lodash/findKey'
|
||||
import isFunction from 'lodash/isFunction'
|
||||
import isString from 'lodash/isString'
|
||||
@@ -5,30 +6,32 @@ import map from 'lodash/map'
|
||||
import pick from 'lodash/pick'
|
||||
import React from 'react'
|
||||
|
||||
import _ from './intl'
|
||||
import Component from './base-component'
|
||||
import Icon from './icon'
|
||||
import logError from './log-error'
|
||||
import propTypes from './prop-types'
|
||||
import Tooltip from './tooltip'
|
||||
import { formatSize } from './utils'
|
||||
import { SizeInput } from './form'
|
||||
import _ from '../intl'
|
||||
import Component from '../base-component'
|
||||
import getEventValue from '../get-event-value'
|
||||
import Icon from '../icon'
|
||||
import logError from '../log-error'
|
||||
import propTypes from '../prop-types'
|
||||
import Tooltip from '../tooltip'
|
||||
import { formatSize } from '../utils'
|
||||
import { SizeInput } from '../form'
|
||||
import {
|
||||
SelectHost,
|
||||
SelectIp,
|
||||
SelectNetwork,
|
||||
SelectPool,
|
||||
SelectRemote,
|
||||
SelectResourceSetIp,
|
||||
SelectSr,
|
||||
SelectSubject,
|
||||
SelectTag,
|
||||
SelectVm,
|
||||
SelectVmTemplate
|
||||
} from './select-objects'
|
||||
} from '../select-objects'
|
||||
|
||||
import styles from './index.css'
|
||||
|
||||
const LONG_CLICK = 400
|
||||
const SELECT_STYLE = { padding: '0px' }
|
||||
const SIZE_STYLE = { width: '10rem' }
|
||||
const EDITABLE_STYLE = { borderBottom: '1px dashed #ccc' }
|
||||
|
||||
@propTypes({
|
||||
alt: propTypes.node.isRequired
|
||||
@@ -133,7 +136,8 @@ class Editable extends Component {
|
||||
this._closeEdition()
|
||||
} catch (error) {
|
||||
this.setState({
|
||||
error: isString(error) ? error : error.message,
|
||||
// `error` may be undefined if the action has been cancelled
|
||||
error: error !== undefined && (isString(error) ? error : error.message),
|
||||
saving: false
|
||||
})
|
||||
logError(error)
|
||||
@@ -157,7 +161,7 @@ class Editable extends Component {
|
||||
const { useLongClick } = props
|
||||
|
||||
const success = <Icon icon='success' />
|
||||
return <span style={useLongClick ? null : EDITABLE_STYLE}>
|
||||
return <span className={classNames(styles.clickToEdit, !useLongClick && styles.shortClick)}>
|
||||
<span
|
||||
onClick={!useLongClick && this._openEdition}
|
||||
onMouseDown={useLongClick && this.__startTimer}
|
||||
@@ -257,7 +261,8 @@ export class Text extends Editable {
|
||||
readOnly={saving}
|
||||
ref='input'
|
||||
style={{
|
||||
width: `${value.length + 1}ex`
|
||||
width: `${value.length + 1}ex`,
|
||||
maxWidth: '50ex'
|
||||
}}
|
||||
type={this._isPassword ? 'password' : 'text'}
|
||||
/>
|
||||
@@ -271,80 +276,99 @@ export class Password extends Text {
|
||||
}
|
||||
|
||||
@propTypes({
|
||||
value: propTypes.number.isRequired
|
||||
nullable: propTypes.bool,
|
||||
value: propTypes.number
|
||||
})
|
||||
export class Number extends Component {
|
||||
get value () {
|
||||
return +this.refs.input.value
|
||||
}
|
||||
|
||||
_onChange = value => this.props.onChange(+value)
|
||||
_onChange = value => {
|
||||
if (value === '') {
|
||||
if (this.props.nullable) {
|
||||
value = null
|
||||
} else {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
value = +value
|
||||
}
|
||||
|
||||
this.props.onChange(value)
|
||||
}
|
||||
|
||||
render () {
|
||||
const { value } = this.props
|
||||
return <Text
|
||||
{...this.props}
|
||||
onChange={this._onChange}
|
||||
value={String(this.props.value)}
|
||||
value={value === null ? '' : String(value)}
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
||||
@propTypes({
|
||||
labelProp: propTypes.string.isRequired,
|
||||
options: propTypes.oneOfType([
|
||||
propTypes.array,
|
||||
propTypes.object
|
||||
]).isRequired
|
||||
]).isRequired,
|
||||
renderer: propTypes.func
|
||||
})
|
||||
export class Select extends Editable {
|
||||
constructor (props) {
|
||||
super()
|
||||
|
||||
this._defaultValue = findKey(props.options, option => option === props.value)
|
||||
componentWillReceiveProps (props) {
|
||||
if (
|
||||
props.value !== this.props.value ||
|
||||
props.options !== this.props.options
|
||||
) {
|
||||
this.setState({ valueKey: findKey(props.options, option => option === props.value) })
|
||||
}
|
||||
}
|
||||
|
||||
get value () {
|
||||
return this.props.options[this._select.value]
|
||||
return this.props.options[this.state.valueKey]
|
||||
}
|
||||
|
||||
_onChange = event => {
|
||||
this._save()
|
||||
this.setState({ valueKey: getEventValue(event) }, this._save)
|
||||
}
|
||||
_optionToJsx = (option, index) => {
|
||||
const { labelProp } = this.props
|
||||
|
||||
_optionToJsx = (option, key) => {
|
||||
const { renderer } = this.props
|
||||
|
||||
return <option
|
||||
key={index}
|
||||
value={index}
|
||||
key={key}
|
||||
value={key}
|
||||
>
|
||||
{labelProp ? option[labelProp] : option}
|
||||
{renderer ? renderer(option) : option}
|
||||
</option>
|
||||
}
|
||||
|
||||
_onEditionMount = ref => {
|
||||
this._select = ref
|
||||
// Seems to work in Google Chrome (not in Firefox)
|
||||
ref && ref.dispatchEvent(new window.MouseEvent('mousedown'))
|
||||
}
|
||||
|
||||
_renderDisplay () {
|
||||
return this.props.children ||
|
||||
<span>{this.props.value[this.props.labelProp]}</span>
|
||||
const { children, renderer, value } = this.props
|
||||
|
||||
return children ||
|
||||
<span>{renderer ? renderer(value) : value}</span>
|
||||
}
|
||||
|
||||
_renderEdition () {
|
||||
const { saving } = this.state
|
||||
const { saving, valueKey } = this.state
|
||||
const { options } = this.props
|
||||
|
||||
return <select
|
||||
autoFocus
|
||||
className='form-control'
|
||||
defaultValue={this._defaultValue}
|
||||
className={classNames('form-control', styles.select)}
|
||||
onBlur={this._closeEdition}
|
||||
onChange={this._onChange}
|
||||
onKeyDown={this._onKeyDown}
|
||||
readOnly={saving}
|
||||
ref={this._onEditionMount}
|
||||
style={SELECT_STYLE}
|
||||
value={valueKey}
|
||||
>
|
||||
{map(options, this._optionToJsx)}
|
||||
</select>
|
||||
@@ -353,9 +377,11 @@ export class Select extends Editable {
|
||||
|
||||
const MAP_TYPE_SELECT = {
|
||||
host: SelectHost,
|
||||
ip: SelectIp,
|
||||
network: SelectNetwork,
|
||||
pool: SelectPool,
|
||||
remote: SelectRemote,
|
||||
resourceSetIp: SelectResourceSetIp,
|
||||
SR: SelectSr,
|
||||
subject: SelectSubject,
|
||||
tag: SelectTag,
|
||||
@@ -365,7 +391,6 @@ const MAP_TYPE_SELECT = {
|
||||
|
||||
@propTypes({
|
||||
labelProp: propTypes.string.isRequired,
|
||||
predicate: propTypes.func,
|
||||
value: propTypes.oneOfType([
|
||||
propTypes.string,
|
||||
propTypes.object
|
||||
@@ -373,7 +398,7 @@ const MAP_TYPE_SELECT = {
|
||||
})
|
||||
export class XoSelect extends Editable {
|
||||
get value () {
|
||||
return this.refs.select.value
|
||||
return this.state.value
|
||||
}
|
||||
|
||||
_renderDisplay () {
|
||||
@@ -381,16 +406,14 @@ 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 {
|
||||
placeholder,
|
||||
predicate,
|
||||
saving,
|
||||
xoType
|
||||
xoType,
|
||||
...props
|
||||
} = this.props
|
||||
|
||||
const Select = MAP_TYPE_SELECT[xoType]
|
||||
@@ -404,12 +427,10 @@ export class XoSelect extends Editable {
|
||||
// when this element is clicked.
|
||||
return <a onBlur={this._closeEdition}>
|
||||
<Select
|
||||
{...props}
|
||||
autoFocus
|
||||
disabled={saving}
|
||||
onChange={this._onChange}
|
||||
placeholder={placeholder}
|
||||
predicate={predicate}
|
||||
ref='select'
|
||||
/>
|
||||
</a>
|
||||
}
|
||||
@@ -441,15 +462,18 @@ export class Size extends Editable {
|
||||
const { value } = this.props
|
||||
|
||||
return <span
|
||||
// SizeInput uses `input-group` which makes it behave as a block element (display: table).
|
||||
// `form-inline` to use it as an inline element
|
||||
className='form-inline'
|
||||
onBlur={this._closeEditionIfUnfocused}
|
||||
onFocus={this._focus}
|
||||
onKeyDown={this._onKeyDown}
|
||||
>
|
||||
<SizeInput
|
||||
autoFocus
|
||||
className={styles.size}
|
||||
ref='input'
|
||||
readOnly={saving}
|
||||
style={SIZE_STYLE}
|
||||
defaultValue={value}
|
||||
/>
|
||||
</span>
|
||||
@@ -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 ]
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
@@ -36,7 +39,16 @@ export class Password extends Component {
|
||||
}
|
||||
|
||||
_generate = () => {
|
||||
this.refs.field.value = randomPassword(8)
|
||||
const value = randomPassword(8)
|
||||
const isControlled = this.props.value !== undefined
|
||||
if (isControlled) {
|
||||
this.props.onChange(value)
|
||||
} else {
|
||||
this.refs.field.value = value
|
||||
}
|
||||
|
||||
// FIXME: in controlled mode, visibility should only be updated
|
||||
// when the value prop is changed according to the emitted value.
|
||||
this.setState({
|
||||
visible: true
|
||||
})
|
||||
@@ -80,71 +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) {
|
||||
const { onChange } = this.props
|
||||
this.state.value = +value
|
||||
|
||||
if (onChange) {
|
||||
onChange(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>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,86 +148,125 @@ const DEFAULT_UNIT = 'GiB'
|
||||
readOnly: propTypes.bool,
|
||||
required: propTypes.bool,
|
||||
style: propTypes.object,
|
||||
value: propTypes.number
|
||||
value: propTypes.oneOfType([
|
||||
propTypes.number,
|
||||
propTypes.oneOf([ null ])
|
||||
])
|
||||
})
|
||||
export class SizeInput extends BaseComponent {
|
||||
constructor (props) {
|
||||
super(props)
|
||||
|
||||
this.state = this._createStateFromBytes(firstDefined(props.value, props.defaultValue, 0))
|
||||
this.state = this._createStateFromBytes(firstDefined(props.value, props.defaultValue, null))
|
||||
}
|
||||
|
||||
componentWillReceiveProps (newProps) {
|
||||
const { value } = newProps
|
||||
if (value == null && value === this.props.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const { _bytes, _unit, _value } = this
|
||||
this._bytes = this._unit = this._value = null
|
||||
|
||||
if (value === _bytes) {
|
||||
// Update input value
|
||||
this.setState({
|
||||
unit: _unit,
|
||||
value: _value
|
||||
})
|
||||
} else {
|
||||
componentWillReceiveProps (props) {
|
||||
const { value } = props
|
||||
if (value !== undefined && value !== this.props.value) {
|
||||
this.setState(this._createStateFromBytes(value))
|
||||
}
|
||||
}
|
||||
|
||||
_createStateFromBytes = bytes => {
|
||||
const humanSize = formatSizeRaw(bytes)
|
||||
_createStateFromBytes (bytes) {
|
||||
if (bytes === this._bytes) {
|
||||
return {
|
||||
input: this._input,
|
||||
unit: this._unit
|
||||
}
|
||||
}
|
||||
|
||||
if (bytes === null) {
|
||||
return {
|
||||
input: '',
|
||||
unit: this.props.defaultUnit || DEFAULT_UNIT
|
||||
}
|
||||
}
|
||||
|
||||
const { prefix, value } = formatSizeRaw(bytes)
|
||||
return {
|
||||
unit: humanSize && humanSize.value ? humanSize.prefix + 'B' : this.props.defaultUnit || DEFAULT_UNIT,
|
||||
value: humanSize ? round(humanSize.value, 3) : ''
|
||||
input: String(round(value, 2)),
|
||||
unit: `${prefix}B`
|
||||
}
|
||||
}
|
||||
|
||||
get value () {
|
||||
const { unit, value } = this.state
|
||||
return parseSize(value + ' ' + unit)
|
||||
const { input, unit } = this.state
|
||||
|
||||
if (!input) {
|
||||
return null
|
||||
}
|
||||
|
||||
return parseSize(`${+input} ${unit}`)
|
||||
}
|
||||
|
||||
set value (newValue) {
|
||||
set value (value) {
|
||||
if (
|
||||
process.env.NODE_ENV !== 'production' &&
|
||||
this.props.value != null
|
||||
this.props.value !== undefined
|
||||
) {
|
||||
throw new Error('cannot set value of controlled SizeInput')
|
||||
}
|
||||
this.setState(this._createStateFromBytes(newValue))
|
||||
this.setState(this._createStateFromBytes(value))
|
||||
}
|
||||
|
||||
_onChange = value =>
|
||||
this.props.onChange && this.props.onChange(value)
|
||||
_onChange (input, unit) {
|
||||
const { onChange } = this.props
|
||||
|
||||
_updateValue = event => {
|
||||
const { value } = event.target
|
||||
if (this.props.value != null) {
|
||||
this._value = value
|
||||
this._unit = this.state.unit
|
||||
this._bytes = parseSize((value || 0) + ' ' + this.state.unit)
|
||||
// Empty input equals null.
|
||||
const bytes = input
|
||||
? parseSize(`${+input} ${unit}`)
|
||||
: null
|
||||
|
||||
this._onChange(this._bytes)
|
||||
} else {
|
||||
this.setState({ value }, () => {
|
||||
this._onChange(this.value)
|
||||
})
|
||||
}
|
||||
}
|
||||
_updateUnit = unit => {
|
||||
if (this.props.value != null) {
|
||||
this._value = this.state.value
|
||||
const isControlled = this.props.value !== undefined
|
||||
if (isControlled) {
|
||||
// Store input and unit for this change to update correctly on new
|
||||
// props.
|
||||
this._bytes = bytes
|
||||
this._input = input
|
||||
this._unit = unit
|
||||
this._bytes = parseSize(this.state.value + ' ' + unit)
|
||||
|
||||
this._onChange(this._bytes)
|
||||
} else {
|
||||
this.setState({ unit }, () => {
|
||||
this._onChange(this.value)
|
||||
})
|
||||
this.setState({ input, unit })
|
||||
|
||||
// onChange is optional in uncontrolled mode.
|
||||
if (!onChange) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
onChange(bytes)
|
||||
}
|
||||
|
||||
_updateNumber = event => {
|
||||
const input = event.target.value
|
||||
|
||||
if (!input) {
|
||||
return this._onChange(input, this.state.unit)
|
||||
}
|
||||
|
||||
const number = +input
|
||||
|
||||
// NaN: do not ack this change.
|
||||
if (number !== number) { // eslint-disable-line no-self-compare
|
||||
return
|
||||
}
|
||||
|
||||
// Same numeric value: simply update the input.
|
||||
const prevInput = this.state.input
|
||||
if (prevInput && +prevInput === number) {
|
||||
return this.setState({ input })
|
||||
}
|
||||
|
||||
this._onChange(input, this.state.unit)
|
||||
}
|
||||
|
||||
_updateUnit = unit => {
|
||||
const { input } = this.state
|
||||
|
||||
// 0 is always 0, no matter the unit.
|
||||
if (+input) {
|
||||
this._onChange(input, unit)
|
||||
} else {
|
||||
this.setState({ unit })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,39 +274,30 @@ export class SizeInput extends BaseComponent {
|
||||
const {
|
||||
autoFocus,
|
||||
className,
|
||||
placeholder,
|
||||
readOnly,
|
||||
placeholder,
|
||||
required,
|
||||
style
|
||||
} = this.props
|
||||
|
||||
const {
|
||||
value,
|
||||
unit
|
||||
} = this.state
|
||||
|
||||
return <span
|
||||
className={classNames(className, 'input-group')}
|
||||
style={style}
|
||||
>
|
||||
return <span className={classNames('input-group', className)} style={style}>
|
||||
<input
|
||||
autoFocus={autoFocus}
|
||||
className='form-control'
|
||||
min={0}
|
||||
onChange={this._updateValue}
|
||||
disabled={readOnly}
|
||||
onChange={this._updateNumber}
|
||||
placeholder={placeholder}
|
||||
readOnly={readOnly}
|
||||
required={required}
|
||||
type='number'
|
||||
value={value}
|
||||
type='text'
|
||||
value={this.state.input}
|
||||
/>
|
||||
<span className='input-group-btn'>
|
||||
<DropdownButton
|
||||
bsStyle='secondary'
|
||||
disabled={readOnly}
|
||||
id='size'
|
||||
pullRight
|
||||
title={unit}
|
||||
disabled={readOnly}
|
||||
title={this.state.unit}
|
||||
>
|
||||
{map(UNITS, unit =>
|
||||
<MenuItem
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import autoControlledInput from 'auto-controlled-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
|
||||
})
|
||||
@autoControlledInput()
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import map from 'lodash/map'
|
||||
import React, { Component } from 'react'
|
||||
import ReactSelect from 'react-select'
|
||||
import sum from 'lodash/sum'
|
||||
import {
|
||||
AutoSizer,
|
||||
VirtualScroll
|
||||
CellMeasurer,
|
||||
List
|
||||
} from 'react-virtualized'
|
||||
|
||||
import propTypes from '../prop-types'
|
||||
@@ -15,15 +18,19 @@ 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
|
||||
@propTypes({
|
||||
maxHeight: propTypes.number,
|
||||
optionHeight: propTypes.number
|
||||
maxHeight: propTypes.number
|
||||
})
|
||||
export default class Select extends Component {
|
||||
static defaultProps = {
|
||||
maxHeight: 200,
|
||||
optionHeight: 40,
|
||||
optionRenderer: (option, labelKey) => option[labelKey]
|
||||
}
|
||||
|
||||
@@ -32,34 +39,52 @@ export default class Select extends Component {
|
||||
options,
|
||||
...otherOptions
|
||||
}) => {
|
||||
const {
|
||||
maxHeight,
|
||||
optionHeight
|
||||
} = this.props
|
||||
const { maxHeight } = this.props
|
||||
|
||||
const focusedOptionIndex = options.indexOf(focusedOption)
|
||||
const height = Math.min(maxHeight, options.length * optionHeight)
|
||||
let height = options.length > MAX_OPTIONS && maxHeight
|
||||
|
||||
const wrappedRowRenderer = ({ index }) =>
|
||||
const wrappedRowRenderer = ({ index, key, style }) =>
|
||||
this._optionRenderer({
|
||||
...otherOptions,
|
||||
focusedOption,
|
||||
focusedOptionIndex,
|
||||
key,
|
||||
option: options[index],
|
||||
options
|
||||
options,
|
||||
style
|
||||
})
|
||||
|
||||
return (
|
||||
<AutoSizer disableHeight>
|
||||
{({ width }) => (
|
||||
<VirtualScroll
|
||||
height={height}
|
||||
rowCount={options.length}
|
||||
rowHeight={optionHeight}
|
||||
rowRenderer={wrappedRowRenderer}
|
||||
scrollToIndex={focusedOptionIndex}
|
||||
width={width}
|
||||
/>
|
||||
width ? (
|
||||
<CellMeasurer
|
||||
cellRenderer={({ rowIndex }) => wrappedRowRenderer({ index: rowIndex })}
|
||||
columnCount={1}
|
||||
rowCount={options.length}
|
||||
// FIXME: 16 px: ugly workaround to take into account the scrollbar
|
||||
// during the offscreen render to measure the row height
|
||||
// See https://github.com/bvaughn/react-virtualized/issues/401
|
||||
width={width - 16}
|
||||
>
|
||||
{({ getRowHeight }) => {
|
||||
if (options.length <= MAX_OPTIONS) {
|
||||
height = sum(map(options, (_, index) => getRowHeight({ index })))
|
||||
}
|
||||
|
||||
return <List
|
||||
height={height}
|
||||
rowCount={options.length}
|
||||
rowHeight={getRowHeight}
|
||||
rowRenderer={wrappedRowRenderer}
|
||||
scrollToIndex={focusedOptionIndex}
|
||||
style={LIST_STYLE}
|
||||
width={width}
|
||||
/>
|
||||
}}
|
||||
</CellMeasurer>
|
||||
) : null
|
||||
)}
|
||||
</AutoSizer>
|
||||
)
|
||||
@@ -68,8 +93,10 @@ export default class Select extends Component {
|
||||
_optionRenderer = ({
|
||||
focusedOption,
|
||||
focusOption,
|
||||
key,
|
||||
labelKey,
|
||||
option,
|
||||
style,
|
||||
selectValue
|
||||
}) => {
|
||||
let className = 'Select-option'
|
||||
@@ -91,7 +118,8 @@ export default class Select extends Component {
|
||||
className={className}
|
||||
onClick={!disabled && (() => selectValue(option))}
|
||||
onMouseOver={!disabled && (() => focusOption(option))}
|
||||
style={{ height: props.optionHeight }}
|
||||
style={style}
|
||||
key={key}
|
||||
>
|
||||
{props.optionRenderer(option, labelKey)}
|
||||
</div>
|
||||
@@ -102,6 +130,7 @@ export default class Select extends Component {
|
||||
return (
|
||||
<ReactSelect
|
||||
{...this.props}
|
||||
backspaceToRemoveMessage=''
|
||||
menuRenderer={this._renderMenu}
|
||||
menuStyle={SELECT_MENU_STYLE}
|
||||
style={SELECT_STYLE}
|
||||
|
||||
@@ -49,6 +49,7 @@ export default class Toggle extends Component {
|
||||
}
|
||||
|
||||
this.refs.input.checked = Boolean(value)
|
||||
this.forceUpdate()
|
||||
}
|
||||
|
||||
_onChange = event => {
|
||||
|
||||
13
src/common/grid.spec.js
Normal file
13
src/common/grid.spec.js
Normal 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()
|
||||
})
|
||||
})
|
||||
24
src/common/home-filters.js
Normal file
24
src/common/home-filters.js
Normal file
@@ -0,0 +1,24 @@
|
||||
export const VM = {
|
||||
homeFilterPendingVms: 'current_operations:"" ',
|
||||
homeFilterNonRunningVms: '!power_state:running ',
|
||||
homeFilterHvmGuests: 'virtualizationMode:hvm ',
|
||||
homeFilterRunningVms: 'power_state:running ',
|
||||
homeFilterTags: 'tags:'
|
||||
}
|
||||
|
||||
export const host = {
|
||||
homeFilterRunningHosts: 'power_state:running ',
|
||||
homeFilterTags: 'tags:'
|
||||
}
|
||||
|
||||
export const pool = {
|
||||
homeFilterTags: 'tags:'
|
||||
}
|
||||
|
||||
export const vmTemplate = {
|
||||
homeFilterTags: 'tags:'
|
||||
}
|
||||
|
||||
export const SR = {
|
||||
homeFilterTags: 'tags:'
|
||||
}
|
||||
36
src/common/home-tags.js
Normal file
36
src/common/home-tags.js
Normal 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}
|
||||
/>
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import isEmpty from 'lodash/isEmpty'
|
||||
import keys from 'lodash/keys'
|
||||
import map from 'lodash/map'
|
||||
import React from 'react'
|
||||
import { Portal } from 'react-overlays'
|
||||
@@ -19,7 +20,8 @@ import {
|
||||
} from './selectors'
|
||||
import {
|
||||
getHostMissingPatches,
|
||||
installAllHostPatches
|
||||
installAllHostPatches,
|
||||
installAllPatchesOnPool
|
||||
} from './xo'
|
||||
|
||||
// ===================================================================
|
||||
@@ -84,9 +86,17 @@ class HostsPatchesTable extends Component {
|
||||
)
|
||||
)
|
||||
|
||||
_installAllMissingPatches = () => (
|
||||
Promise.all(map(this._getHosts(), this._installAllHostPatches))
|
||||
)
|
||||
_installAllMissingPatches = () => {
|
||||
const pools = {}
|
||||
forEach(this._getHosts(), host => {
|
||||
pools[host.$pool] = true
|
||||
})
|
||||
|
||||
return Promise.all(map(
|
||||
keys(pools),
|
||||
installAllPatchesOnPool
|
||||
)).then(this._refreshMissingPatches)
|
||||
}
|
||||
|
||||
_refreshHostMissingPatches = host => (
|
||||
getHostMissingPatches(host).then(patches => {
|
||||
@@ -164,15 +174,15 @@ class HostsPatchesTable extends Component {
|
||||
<div>
|
||||
{!noPatches
|
||||
? (
|
||||
<SortedTable
|
||||
collection={hosts}
|
||||
columns={props.displayPools ? POOLS_MISSING_PATCHES_COLUMNS : MISSING_PATCHES_COLUMNS}
|
||||
userData={{
|
||||
installAllHostPatches: this._installAllHostPatches,
|
||||
missingPatches: this.state.missingPatches,
|
||||
pools: props.pools
|
||||
}}
|
||||
/>
|
||||
<SortedTable
|
||||
collection={hosts}
|
||||
columns={props.displayPools ? POOLS_MISSING_PATCHES_COLUMNS : MISSING_PATCHES_COLUMNS}
|
||||
userData={{
|
||||
installAllHostPatches: this._installAllHostPatches,
|
||||
missingPatches: this.state.missingPatches,
|
||||
pools: props.pools
|
||||
}}
|
||||
/>
|
||||
) : <p>{_('patchNothing')}</p>
|
||||
}
|
||||
<Portal container={() => props.buttonsGroupContainer()}>
|
||||
|
||||
@@ -62,11 +62,17 @@ 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]}
|
||||
>
|
||||
{children}
|
||||
{children}
|
||||
</IntlProvider_>
|
||||
}
|
||||
}
|
||||
|
||||
1
src/common/intl/locales/.gitignore
vendored
1
src/common/intl/locales/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
/index.js
|
||||
0
src/common/intl/locales/.index-modules
Normal file
0
src/common/intl/locales/.index-modules
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
2281
src/common/intl/locales/zh.js
Normal file
2281
src/common/intl/locales/zh.js
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
126
src/common/ip.js
Normal file
126
src/common/ip.js
Normal file
@@ -0,0 +1,126 @@
|
||||
import forEachRight from 'lodash/forEachRight'
|
||||
import forEach from 'lodash/forEach'
|
||||
import isArray from 'lodash/isArray'
|
||||
import isIp from 'is-ip'
|
||||
import some from 'lodash/some'
|
||||
|
||||
export { isIp }
|
||||
export const isIpV4 = isIp.v4
|
||||
export const isIpV6 = isIp.v6
|
||||
|
||||
// Source: https://github.com/ezpaarse-project/ip-range-generator/blob/master/index.js
|
||||
|
||||
const ipv4 = /^(?:(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(?:\.(?!$)|$)){4}$/
|
||||
|
||||
function ip2hex (ip) {
|
||||
let parts = ip.split('.').map(str => parseInt(str, 10))
|
||||
let n = 0
|
||||
|
||||
n += parts[3]
|
||||
n += parts[2] * 256 // 2^8
|
||||
n += parts[1] * 65536 // 2^16
|
||||
n += parts[0] * 16777216 // 2^24
|
||||
|
||||
return n
|
||||
}
|
||||
|
||||
function assertIpv4 (str, msg) {
|
||||
if (!ipv4.test(str)) { throw new Error(msg) }
|
||||
}
|
||||
|
||||
function *range (ip1, ip2) {
|
||||
assertIpv4(ip1, 'argument "ip1" must be a valid IPv4 address')
|
||||
assertIpv4(ip2, 'argument "ip2" must be a valid IPv4 address')
|
||||
|
||||
let hex = ip2hex(ip1)
|
||||
let hex2 = ip2hex(ip2)
|
||||
|
||||
if (hex > hex2) {
|
||||
let tmp = hex
|
||||
hex = hex2
|
||||
hex2 = tmp
|
||||
}
|
||||
|
||||
for (let i = hex; i <= hex2; i++) {
|
||||
yield `${(i >> 24) & 0xff}.${(i >> 16) & 0xff}.${(i >> 8) & 0xff}.${i & 0xff}`
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const getNextIpV4 = ip => {
|
||||
const splitIp = ip.split('.')
|
||||
if (splitIp.length !== 4 || some(splitIp, value => value < 0 || value > 255)) {
|
||||
return
|
||||
}
|
||||
let index
|
||||
forEachRight(splitIp, (value, i) => {
|
||||
if (value < 255) {
|
||||
index = i
|
||||
return false
|
||||
}
|
||||
splitIp[i] = 1
|
||||
})
|
||||
if (index === 0 && +splitIp[0] === 255) {
|
||||
return 0
|
||||
}
|
||||
splitIp[index]++
|
||||
|
||||
return splitIp.join('.')
|
||||
}
|
||||
|
||||
export const formatIps = ips => {
|
||||
if (!isArray(ips)) {
|
||||
throw new Error('ips must be an array')
|
||||
}
|
||||
if (ips.length === 0) {
|
||||
return []
|
||||
}
|
||||
const sortedIps = ips.sort((ip1, ip2) => {
|
||||
const splitIp1 = ip1.split('.')
|
||||
const splitIp2 = ip2.split('.')
|
||||
if (splitIp1.length !== 4) {
|
||||
return 1
|
||||
}
|
||||
if (splitIp2.length !== 4) {
|
||||
return -1
|
||||
}
|
||||
return splitIp1[3] - splitIp2[3] +
|
||||
(splitIp1[2] - splitIp2[2]) * 256 +
|
||||
(splitIp1[1] - splitIp2[1]) * 256 * 256 +
|
||||
(splitIp1[0] - splitIp2[0]) * 256 * 256 * 256
|
||||
})
|
||||
const range = { first: '', last: '' }
|
||||
const formattedIps = []
|
||||
let index = 0
|
||||
forEach(sortedIps, ip => {
|
||||
if (ip !== getNextIpV4(range.last)) {
|
||||
if (range.first) {
|
||||
formattedIps[index] = range.first === range.last ? range.first : { ...range }
|
||||
index++
|
||||
}
|
||||
range.first = range.last = ip
|
||||
} else {
|
||||
range.last = ip
|
||||
}
|
||||
})
|
||||
formattedIps[index] = range.first === range.last ? range.first : range
|
||||
|
||||
return formattedIps
|
||||
}
|
||||
|
||||
export const parseIpPattern = pattern => {
|
||||
const ips = []
|
||||
forEach(pattern.split(';'), rawIpRange => {
|
||||
const ipRange = rawIpRange.split('-')
|
||||
if (ipRange.length < 2) {
|
||||
ips.push(ipRange[0])
|
||||
} else if (!isIpV4(ipRange[0]) || !isIpV4(ipRange[1])) {
|
||||
ips.push(rawIpRange)
|
||||
} else {
|
||||
ips.push(...range(ipRange[0], ipRange[1]))
|
||||
}
|
||||
})
|
||||
|
||||
return ips
|
||||
}
|
||||
@@ -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'>
|
||||
|
||||
@@ -20,7 +20,11 @@ class ArrayItem extends Component {
|
||||
}
|
||||
|
||||
set value (value) {
|
||||
this.refs.input.value = value
|
||||
this.setState({
|
||||
use: true
|
||||
}, () => {
|
||||
this.refs.input.value = value
|
||||
})
|
||||
}
|
||||
|
||||
render () {
|
||||
@@ -31,7 +35,7 @@ class ArrayItem extends Component {
|
||||
{cloneElement(children, {
|
||||
ref: 'input'
|
||||
})}
|
||||
<button disabled={children.props.disabled} className='btn btn-danger pull-xs-right' type='button' onClick={this.props.onDelete}>
|
||||
<button disabled={children.props.disabled} className='btn btn-danger pull-right' type='button' onClick={this.props.onDelete}>
|
||||
{_('remove')}
|
||||
</button>
|
||||
</li>
|
||||
@@ -53,11 +57,13 @@ class ArrayItem extends Component {
|
||||
export default class ArrayInput extends Component {
|
||||
constructor (props) {
|
||||
super(props)
|
||||
|
||||
this._nextChildKey = 0
|
||||
|
||||
this.state = {
|
||||
use: props.required || forceDisplayOptionalAttr(props),
|
||||
children: this._makeChildren(props)
|
||||
}
|
||||
this._nextChildKey = 0
|
||||
}
|
||||
|
||||
get value () {
|
||||
@@ -91,7 +97,7 @@ export default class ArrayInput extends Component {
|
||||
})
|
||||
}
|
||||
|
||||
_makeChild (props) {
|
||||
_makeChild (props, defaultValue) {
|
||||
const key = String(this._nextChildKey++)
|
||||
const {
|
||||
schema: {
|
||||
@@ -108,21 +114,16 @@ export default class ArrayInput extends Component {
|
||||
required
|
||||
schema={items}
|
||||
uiSchema={props.uiSchema.items}
|
||||
defaultValue={props.defaultValue}
|
||||
defaultValue={defaultValue}
|
||||
/>
|
||||
</ArrayItem>
|
||||
)
|
||||
}
|
||||
|
||||
_makeChildren ({ defaultValue, ...props }) {
|
||||
return map(defaultValue, defaultValue => {
|
||||
return (
|
||||
this._makeChild({
|
||||
...props,
|
||||
defaultValue
|
||||
})
|
||||
)
|
||||
})
|
||||
_makeChildren (props) {
|
||||
return map(props.defaultValue, defaultValue =>
|
||||
this._makeChild(props, defaultValue)
|
||||
)
|
||||
}
|
||||
|
||||
componentWillReceiveProps (props) {
|
||||
@@ -175,7 +176,7 @@ export default class ArrayInput extends Component {
|
||||
cloneElement(child, { ref: index })
|
||||
)}
|
||||
</ul>
|
||||
<button disabled={disabled} className='btn btn-primary pull-xs-right m-t-1 m-r-1' type='button' onClick={this._handleAdd}>
|
||||
<button disabled={disabled} className='btn btn-primary pull-right mt-1 mr-1' type='button' onClick={this._handleAdd}>
|
||||
{_('add')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -28,9 +28,11 @@ export default class IntegerInput extends AbstractInput {
|
||||
<Combobox
|
||||
defaultValue={props.defaultValue}
|
||||
disabled={props.disabled}
|
||||
max={schema.max}
|
||||
min={schema.min}
|
||||
onChange={props.onChange}
|
||||
options={schema.defaults || schema.default}
|
||||
placeholder={props.placeholder}
|
||||
options={schema.defaults}
|
||||
placeholder={props.placeholder || schema.default}
|
||||
ref='input'
|
||||
required={props.required}
|
||||
step={1}
|
||||
|
||||
@@ -28,9 +28,11 @@ export default class NumberInput extends AbstractInput {
|
||||
<Combobox
|
||||
defaultValue={props.defaultValue}
|
||||
disabled={props.disabled}
|
||||
max={schema.max}
|
||||
min={schema.min}
|
||||
onChange={props.onChange}
|
||||
options={schema.defaults || schema.default}
|
||||
placeholder={props.placeholder}
|
||||
options={schema.defaults}
|
||||
placeholder={props.placeholder || schema.default}
|
||||
ref='input'
|
||||
required={props.required}
|
||||
step='any'
|
||||
|
||||
@@ -29,7 +29,7 @@ class ObjectItem extends Component {
|
||||
const { props } = this
|
||||
|
||||
return (
|
||||
<div className='p-b-1'>
|
||||
<div className='pb-1'>
|
||||
{cloneElement(props.children, {
|
||||
ref: 'input'
|
||||
})}
|
||||
@@ -73,8 +73,12 @@ export default class ObjectInput extends Component {
|
||||
}
|
||||
|
||||
set value (value = {}) {
|
||||
forEach(this.refs, (instance, id) => {
|
||||
instance.value = value[id]
|
||||
this.setState({
|
||||
use: true
|
||||
}, () => {
|
||||
forEach(this.refs, (instance, id) => {
|
||||
instance.value = value[id]
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@ export default class StringInput extends AbstractInput {
|
||||
defaultValue={props.defaultValue}
|
||||
disabled={props.disabled}
|
||||
onChange={props.onChange}
|
||||
options={schema.defaults || schema.default}
|
||||
placeholder={props.placeholder}
|
||||
options={schema.defaults}
|
||||
placeholder={props.placeholder || schema.default}
|
||||
ref='input'
|
||||
required={props.required}
|
||||
type={props.password && 'password'}
|
||||
|
||||
@@ -6,6 +6,10 @@ import React, { Component, cloneElement } from 'react'
|
||||
import { Button, Modal as ReactModal } from 'react-bootstrap-4/lib'
|
||||
|
||||
import propTypes from './prop-types'
|
||||
import {
|
||||
disable as disableShortcuts,
|
||||
enable as enableShortcuts
|
||||
} from './shortcuts'
|
||||
|
||||
let instance
|
||||
|
||||
@@ -15,7 +19,7 @@ const modal = (content, onClose) => {
|
||||
} else if (instance.state.showModal) {
|
||||
throw new Error('Other modal still open.')
|
||||
}
|
||||
instance.setState({ content, onClose, showModal: true })
|
||||
instance.setState({ content, onClose, showModal: true }, disableShortcuts)
|
||||
}
|
||||
|
||||
export const alert = (title, body) => {
|
||||
@@ -60,7 +64,10 @@ const _addRef = (component, ref) => {
|
||||
class Confirm extends Component {
|
||||
_resolve = () => {
|
||||
const { body } = this.refs
|
||||
this.props.resolve(body && body.value || body.getWrappedInstance && body.getWrappedInstance().value)
|
||||
this.props.resolve(body && (body.getWrappedInstance
|
||||
? body.getWrappedInstance().value
|
||||
: body.value
|
||||
))
|
||||
instance.close()
|
||||
}
|
||||
_reject = () => {
|
||||
@@ -79,9 +86,10 @@ class Confirm extends Component {
|
||||
return <div>
|
||||
<Header closeButton>
|
||||
<Title>
|
||||
{icon
|
||||
? <span><Icon icon={icon} /> {title}</span>
|
||||
: title}
|
||||
{icon
|
||||
? <span><Icon icon={icon} /> {title}</span>
|
||||
: title
|
||||
}
|
||||
</Title>
|
||||
</Header>
|
||||
<Body>
|
||||
@@ -130,18 +138,22 @@ 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 () {
|
||||
this.setState({ showModal: false })
|
||||
this.setState({ showModal: false }, enableShortcuts)
|
||||
}
|
||||
|
||||
_onHide = () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
15
src/common/react-novnc.js
vendored
15
src/common/react-novnc.js
vendored
@@ -6,6 +6,7 @@ import {
|
||||
parse as parseUrl,
|
||||
resolve as resolveUrl
|
||||
} from 'url'
|
||||
import { enable as enableShortcuts, disable as disableShortcuts } from 'shortcuts'
|
||||
|
||||
import propTypes from './prop-types'
|
||||
|
||||
@@ -70,6 +71,7 @@ export default class NoVnc extends Component {
|
||||
this._rfb = null
|
||||
rfb.disconnect()
|
||||
}
|
||||
enableShortcuts()
|
||||
}
|
||||
|
||||
_connect = () => {
|
||||
@@ -92,6 +94,7 @@ export default class NoVnc extends Component {
|
||||
})
|
||||
|
||||
rfb.connect(formatUrl(url))
|
||||
disableShortcuts()
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
@@ -102,6 +105,14 @@ export default class NoVnc extends Component {
|
||||
this._clean()
|
||||
}
|
||||
|
||||
componentWillReceiveProps (props) {
|
||||
const rfb = this._rfb
|
||||
if (rfb && this.props.scale !== props.scale) {
|
||||
rfb.get_display().set_scale(props.scale || 1)
|
||||
rfb.get_mouse().set_scale(props.scale || 1)
|
||||
}
|
||||
}
|
||||
|
||||
_focus = () => {
|
||||
const rfb = this._rfb
|
||||
if (rfb) {
|
||||
@@ -112,6 +123,8 @@ export default class NoVnc extends Component {
|
||||
|
||||
rfb.get_keyboard().grab()
|
||||
rfb.get_mouse().grab()
|
||||
|
||||
disableShortcuts()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +133,8 @@ export default class NoVnc extends Component {
|
||||
if (rfb) {
|
||||
rfb.get_keyboard().ungrab()
|
||||
rfb.get_mouse().ungrab()
|
||||
|
||||
enableShortcuts()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { Component } from 'react'
|
||||
import _ from 'intl'
|
||||
import React from 'react'
|
||||
|
||||
import Icon from './icon'
|
||||
import propTypes from './prop-types'
|
||||
@@ -55,13 +56,12 @@ export const SrItem = propTypes({
|
||||
let label = `${sr.name_label || sr.id}`
|
||||
|
||||
if (isSrWritable(sr)) {
|
||||
label += ` (${formatSize(sr.size)})`
|
||||
label += ` (${formatSize(sr.size - sr.physical_usage)} free)`
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
<Icon icon='sr' /> {label}
|
||||
{container && ` (${container.name_label || container.id})`}
|
||||
</span>
|
||||
)
|
||||
}))
|
||||
@@ -108,6 +108,27 @@ const xoItemToRender = {
|
||||
<Icon icon='user' /> {user.email}
|
||||
</span>
|
||||
),
|
||||
resourceSet: resourceSet => (
|
||||
<span>
|
||||
<Icon icon='resource-set' /> {resourceSet.name}
|
||||
</span>
|
||||
),
|
||||
sshKey: key => (
|
||||
<span>
|
||||
<Icon icon='ssh-key' /> {key.label}
|
||||
</span>
|
||||
),
|
||||
ipPool: ipPool => (
|
||||
<span>
|
||||
<Icon icon='ip' /> {ipPool.name}
|
||||
</span>
|
||||
),
|
||||
ipAddress: ({label, used}) => {
|
||||
if (used) {
|
||||
return <strong className='text-warning'>{label}</strong>
|
||||
}
|
||||
return <span>{label}</span>
|
||||
},
|
||||
|
||||
// XO objects.
|
||||
pool: pool => (
|
||||
@@ -144,7 +165,7 @@ const xoItemToRender = {
|
||||
// PIF.
|
||||
PIF: pif => (
|
||||
<span>
|
||||
<Icon icon='network' /> {pif.device}
|
||||
<Icon icon='network' /> {pif.device} ({pif.deviceName})
|
||||
</span>
|
||||
),
|
||||
|
||||
@@ -161,7 +182,10 @@ const renderXoItem = (item, {
|
||||
} = {}) => {
|
||||
const { id, type, label } = item
|
||||
|
||||
if (!type && label) {
|
||||
if (!type) {
|
||||
if (process.env.NODE_ENV !== 'production' && !label) {
|
||||
throw new Error(`an item must have at least either a type or a label`)
|
||||
}
|
||||
return (
|
||||
<span key={id} className={className}>
|
||||
{label}
|
||||
@@ -194,7 +218,7 @@ const GenericXoItem = connectStore(() => {
|
||||
})
|
||||
})(({ xoItem, ...props }) => xoItem
|
||||
? renderXoItem(xoItem, props)
|
||||
: <span className='text-muted'>no such item</span>
|
||||
: <span className='text-muted'>{_('errorNoSuchItem')}</span>
|
||||
)
|
||||
|
||||
export const renderXoItemFromId = (id, props) => <GenericXoItem {...props} id={id} />
|
||||
|
||||
@@ -1,35 +1,53 @@
|
||||
import forEach from 'lodash/forEach'
|
||||
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 { FormattedTime } from 'react-intl'
|
||||
import Tooltip from 'tooltip'
|
||||
import { Toggle } from 'form'
|
||||
import { FormattedDate, FormattedTime } from 'react-intl'
|
||||
import {
|
||||
Panel,
|
||||
Tab,
|
||||
Tabs
|
||||
} from 'react-bootstrap-4/lib'
|
||||
forEach,
|
||||
includes,
|
||||
isArray,
|
||||
map,
|
||||
sortedIndex
|
||||
} from 'lodash'
|
||||
|
||||
import _ from './intl'
|
||||
import Component from './base-component'
|
||||
import propTypes from './prop-types'
|
||||
import TimezonePicker from './timezone-picker'
|
||||
import { Card, CardHeader, CardBlock } from './card'
|
||||
import { Col, Row } from './grid'
|
||||
import { Range } from './form'
|
||||
|
||||
// ===================================================================
|
||||
|
||||
const NAV_EVERY = 1
|
||||
const NAV_EACH_SELECTED = 2
|
||||
const NAV_EVERY_N = 3
|
||||
// By default, later uses UTC but we use this line for future versions.
|
||||
later.date.UTC()
|
||||
|
||||
// ===================================================================
|
||||
|
||||
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
|
||||
|
||||
const MONTHS = [
|
||||
[ 0, 1, 2, 3, 4, 5 ],
|
||||
[ 6, 7, 8, 9, 10, 11 ]
|
||||
[ 0, 1, 2 ],
|
||||
[ 3, 4, 5 ],
|
||||
[ 6, 7, 8 ],
|
||||
[ 9, 10, 11 ]
|
||||
]
|
||||
|
||||
const DAYS = (() => {
|
||||
@@ -48,7 +66,11 @@ const DAYS = (() => {
|
||||
return days
|
||||
})()
|
||||
|
||||
const WEEK_DAYS = [[ 0, 1, 2, 3, 4, 5, 6 ]]
|
||||
const WEEK_DAYS = [
|
||||
[ 0, 1, 2 ],
|
||||
[ 3, 4, 5 ],
|
||||
[ 6 ]
|
||||
]
|
||||
|
||||
const HOURS = (() => {
|
||||
const hours = []
|
||||
@@ -78,47 +100,62 @@ const MINS = (() => {
|
||||
return minutes
|
||||
})()
|
||||
|
||||
const PICKTIME_TO_ID = {
|
||||
minute: 0,
|
||||
hour: 1,
|
||||
monthDay: 2,
|
||||
month: 3,
|
||||
weekDay: 4
|
||||
}
|
||||
|
||||
const TIME_FORMAT = {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric'
|
||||
minute: 'numeric',
|
||||
|
||||
// The timezone is not significant for displaying the date previews
|
||||
// as long as it is the same used to generate the next occurrences
|
||||
// from the cron patterns.
|
||||
|
||||
// Therefore we can use UTC everywhere and say to the user that the
|
||||
// previews are in the configured timezone.
|
||||
timeZone: 'UTC'
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
|
||||
// monthNum: [ 0 : 11 ]
|
||||
const getMonthName = (monthNum) =>
|
||||
<FormattedTime value={new Date(1970, monthNum)} month='long' />
|
||||
<FormattedDate value={Date.UTC(1970, monthNum)} month='long' timeZone='UTC' />
|
||||
|
||||
// dayNum: [ 0 : 6 ]
|
||||
const getDayName = (dayNum) =>
|
||||
// January, 1970, 5th => Monday
|
||||
<FormattedTime value={new Date(1970, 0, 4 + dayNum)} weekday='long' />
|
||||
<FormattedDate value={Date.UTC(1970, 0, 4 + dayNum)} weekday='long' timeZone='UTC' />
|
||||
|
||||
// ===================================================================
|
||||
|
||||
@propTypes({
|
||||
cron: propTypes.string.isRequired
|
||||
cronPattern: propTypes.string.isRequired
|
||||
})
|
||||
export class SchedulePreview extends Component {
|
||||
_handleChange = value => {
|
||||
this.setState({
|
||||
value
|
||||
})
|
||||
}
|
||||
|
||||
render () {
|
||||
const { props } = this
|
||||
const cronSched = later.parse.cron(props.cron)
|
||||
const dates = later.schedule(cronSched).next(this.state.value || MIN_PREVIEWS)
|
||||
const { cronPattern } = this.props
|
||||
const { value } = this.state
|
||||
|
||||
const cronSched = later.parse.cron(cronPattern)
|
||||
const dates = later.schedule(cronSched).next(value)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='form-inline p-b-1'>
|
||||
<Range min={MIN_PREVIEWS} max={MAX_PREVIEWS} onChange={this._handleChange} />
|
||||
<div className='alert alert-info' role='alert'>
|
||||
{_('cronPattern')} <strong>{cronPattern}</strong>
|
||||
</div>
|
||||
<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) => (
|
||||
@@ -137,34 +174,21 @@ export class SchedulePreview extends Component {
|
||||
|
||||
@propTypes({
|
||||
children: propTypes.any.isRequired,
|
||||
onChange: propTypes.func
|
||||
onChange: propTypes.func.isRequired,
|
||||
tdId: propTypes.number.isRequired,
|
||||
value: propTypes.bool.isRequired
|
||||
})
|
||||
class ToggleTd extends Component {
|
||||
get value () {
|
||||
return this.state.value
|
||||
}
|
||||
|
||||
set value (value) {
|
||||
const { onChange } = this.props
|
||||
|
||||
this.setState({
|
||||
value
|
||||
}, onChange && (() => onChange(value)))
|
||||
}
|
||||
|
||||
_onClick = () => {
|
||||
const { onChange } = this.props
|
||||
const value = !this.state.value
|
||||
|
||||
this.setState({
|
||||
value
|
||||
}, onChange && (() => onChange(value)))
|
||||
const { props } = this
|
||||
props.onChange(props.tdId, !props.value)
|
||||
}
|
||||
|
||||
render () {
|
||||
const { props } = this
|
||||
return (
|
||||
<td style={{ cursor: 'pointer' }} className={this.state.value ? 'table-success' : ''} onClick={this._onClick}>
|
||||
{this.props.children}
|
||||
<td style={CLICKABLE} className={props.value ? 'table-success' : ''} onClick={this._onClick}>
|
||||
{props.children}
|
||||
</td>
|
||||
)
|
||||
}
|
||||
@@ -173,319 +197,337 @@ class ToggleTd extends Component {
|
||||
// ===================================================================
|
||||
|
||||
@propTypes({
|
||||
data: propTypes.array.isRequired,
|
||||
dataRender: propTypes.func,
|
||||
onChange: propTypes.func
|
||||
labelId: propTypes.string.isRequired,
|
||||
options: propTypes.array.isRequired,
|
||||
optionRenderer: propTypes.func,
|
||||
onChange: propTypes.func.isRequired,
|
||||
value: propTypes.array.isRequired
|
||||
})
|
||||
class TableSelect extends Component {
|
||||
constructor () {
|
||||
super()
|
||||
this.state = {
|
||||
value: []
|
||||
}
|
||||
}
|
||||
|
||||
get value () {
|
||||
return this.state.value
|
||||
}
|
||||
|
||||
set value (value) {
|
||||
const { onChange } = this.props
|
||||
|
||||
forEach(this.refs, (ref, id) => {
|
||||
// Don't call ref.input directly because onChange of each ToggleTd is called else!
|
||||
ref.setState({
|
||||
value: includes(value, +id)
|
||||
})
|
||||
})
|
||||
|
||||
this.setState({
|
||||
value
|
||||
}, onChange && (() => onChange(value)))
|
||||
static defaultProps = {
|
||||
optionRenderer: value => value
|
||||
}
|
||||
|
||||
_reset = () => {
|
||||
this.value = []
|
||||
this.props.onChange([])
|
||||
}
|
||||
|
||||
_handleChange = (id, value) => {
|
||||
const { onChange } = this.props
|
||||
const newValue = this.state.value.slice()
|
||||
_handleChange = (tdId, tdValue) => {
|
||||
const { props } = this
|
||||
|
||||
if (value) {
|
||||
newValue.splice(sortedIndex(newValue, id), 0, id)
|
||||
const newValue = props.value.slice()
|
||||
const index = sortedIndex(newValue, tdId)
|
||||
|
||||
if (tdValue) {
|
||||
// Add
|
||||
if (newValue[index] !== tdId) {
|
||||
newValue.splice(index, 0, tdId)
|
||||
}
|
||||
} else {
|
||||
newValue.splice(sortedIndex(newValue, id), 1)
|
||||
// Remove
|
||||
if (newValue[index] === tdId) {
|
||||
newValue.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({
|
||||
value: newValue
|
||||
}, onChange && (() => onChange(newValue)))
|
||||
props.onChange(newValue)
|
||||
}
|
||||
|
||||
render () {
|
||||
const dataRender = this.props.dataRender || ((value) => value)
|
||||
const {
|
||||
props: {
|
||||
data
|
||||
}
|
||||
} = this
|
||||
const { length } = data[0]
|
||||
labelId,
|
||||
options,
|
||||
optionRenderer,
|
||||
value
|
||||
} = this.props
|
||||
|
||||
return (
|
||||
<div>
|
||||
<table className='table table-bordered table-sm'>
|
||||
<tbody>
|
||||
{map(data, (line, i) => (
|
||||
<tr key={i}>
|
||||
{map(line, (value, j) => {
|
||||
const id = length * i + j
|
||||
return (
|
||||
<ToggleTd
|
||||
key={id}
|
||||
ref={id}
|
||||
children={dataRender(value)}
|
||||
onChange={(value) => { this._handleChange(id, value) }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<button className='btn btn-secondary pull-xs-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({
|
||||
dataRender: propTypes.func,
|
||||
onChange: propTypes.func,
|
||||
headerAddon: propTypes.node,
|
||||
optionRenderer: propTypes.func,
|
||||
onChange: propTypes.func.isRequired,
|
||||
range: propTypes.array,
|
||||
type: propTypes.string.isRequired
|
||||
labelId: propTypes.string.isRequired,
|
||||
value: propTypes.any.isRequired
|
||||
})
|
||||
class TimePicker extends Component {
|
||||
constructor () {
|
||||
super()
|
||||
this.state = {
|
||||
activeKey: NAV_EVERY
|
||||
}
|
||||
}
|
||||
_update = cron => {
|
||||
const { tableValue, rangeValue } = this.state
|
||||
|
||||
get value () {
|
||||
const { activeKey } = this.state
|
||||
const { refs } = this
|
||||
const newValue = cronToValue(cron)
|
||||
const periodic = !isArray(newValue)
|
||||
|
||||
if (activeKey === NAV_EVERY) {
|
||||
return 'all'
|
||||
}
|
||||
|
||||
if (activeKey === NAV_EACH_SELECTED) {
|
||||
return refs.select.value
|
||||
}
|
||||
|
||||
return refs.range.value
|
||||
}
|
||||
|
||||
set value (value) {
|
||||
const { refs } = this
|
||||
const { onChange } = this.props
|
||||
|
||||
if (value === 'all') {
|
||||
this.setState({
|
||||
activeKey: NAV_EVERY
|
||||
}, onChange && (() => onChange(value)))
|
||||
} else if (Array.isArray(value)) {
|
||||
this.setState({
|
||||
activeKey: NAV_EACH_SELECTED
|
||||
})
|
||||
refs.select.value = value
|
||||
} else {
|
||||
this.setState({
|
||||
activeKey: NAV_EVERY_N
|
||||
})
|
||||
refs.range.value = value
|
||||
}
|
||||
}
|
||||
|
||||
_updateOpen = () => {
|
||||
this.setState({
|
||||
open: !this.state.open
|
||||
periodic,
|
||||
tableValue: periodic ? tableValue : newValue,
|
||||
rangeValue: periodic ? newValue : rangeValue
|
||||
})
|
||||
}
|
||||
|
||||
_selectTab = activeKey => {
|
||||
const { onChange } = this.props
|
||||
|
||||
this.setState({
|
||||
activeKey
|
||||
}, onChange && (() => onChange(this.value)))
|
||||
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 {
|
||||
props,
|
||||
state
|
||||
} = this
|
||||
headerAddon,
|
||||
labelId,
|
||||
options,
|
||||
optionRenderer,
|
||||
range
|
||||
} = this.props
|
||||
|
||||
const {
|
||||
onChange,
|
||||
range,
|
||||
type
|
||||
} = props
|
||||
periodic,
|
||||
tableValue,
|
||||
rangeValue
|
||||
} = this.state
|
||||
|
||||
return (
|
||||
<div className='card'>
|
||||
<button className='card-header btn btn-lg btn-block' onClick={this._updateOpen}>
|
||||
{_(`scheduling${type}`)}
|
||||
</button>
|
||||
<Panel collapsible expanded={state.open}>
|
||||
<div className='card-block'>
|
||||
<Tabs bsStyle='tabs' activeKey={state.activeKey} onSelect={this._selectTab}>
|
||||
<Tab tabClassName='nav-item' eventKey={NAV_EVERY} title={_(`schedulingEvery${type}`)} />
|
||||
<Tab tabClassName='nav-item' eventKey={NAV_EACH_SELECTED} title={_(`schedulingEachSelected${type}`)}>
|
||||
<TableSelect ref='select' data={props.data} dataRender={props.dataRender} onChange={onChange} />
|
||||
</Tab>
|
||||
{range &&
|
||||
<Tab tabClassName='nav-item' eventKey={NAV_EVERY_N} title={_(`schedulingEveryN${type}`)}>
|
||||
<Range ref='range' min={range[0]} max={range[1]} onChange={onChange} />
|
||||
</Tab>}
|
||||
</Tabs>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
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 <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 ID_TO_PICKTIME = [
|
||||
'minute',
|
||||
'hour',
|
||||
'monthDay',
|
||||
'month',
|
||||
'weekDay'
|
||||
]
|
||||
|
||||
@propTypes({
|
||||
onChange: propTypes.func
|
||||
cronPattern: propTypes.string.isRequired,
|
||||
onChange: propTypes.func,
|
||||
timezone: propTypes.string
|
||||
})
|
||||
export default class Scheduler extends Component {
|
||||
constructor () {
|
||||
super()
|
||||
this.cron = {
|
||||
minute: '*',
|
||||
hour: '*',
|
||||
monthDay: '*',
|
||||
month: '*',
|
||||
weekDay: '*'
|
||||
}
|
||||
}
|
||||
constructor (props) {
|
||||
super(props)
|
||||
|
||||
get value () {
|
||||
const { cron } = this
|
||||
return `${cron.minute} ${cron.hour} ${cron.monthDay} ${cron.month} ${cron.weekDay}`
|
||||
}
|
||||
this._onCronChange = newCrons => {
|
||||
const cronPattern = this.props.cronPattern.split(' ')
|
||||
forEach(newCrons, (cron, unit) => {
|
||||
cronPattern[PICKTIME_TO_ID[unit]] = cron
|
||||
})
|
||||
|
||||
set value (value) {
|
||||
if (!value) {
|
||||
value = '* * * * *'
|
||||
this.props.onChange({
|
||||
cronPattern: cronPattern.join(' '),
|
||||
timezone: this.props.timezone
|
||||
})
|
||||
}
|
||||
|
||||
forEach(value.split(' '), (t, id) => {
|
||||
const ref = this.refs[ID_TO_PICKTIME[id]]
|
||||
forEach(UNITS, unit => {
|
||||
this[`_${unit}Change`] = cron => this._onCronChange({ [unit]: cron })
|
||||
})
|
||||
this._dayChange = ([ monthDay, weekDay ]) => this._onCronChange({ monthDay, weekDay })
|
||||
}
|
||||
|
||||
if (t === '*') {
|
||||
ref.value = 'all'
|
||||
} else if (t.indexOf('/') === 1) {
|
||||
ref.value = t.split('/')[1]
|
||||
} else {
|
||||
ref.value = map(t.split(','), e => +e)
|
||||
}
|
||||
_onTimezoneChange = timezone => {
|
||||
this.props.onChange({
|
||||
cronPattern: this.props.cronPattern,
|
||||
timezone
|
||||
})
|
||||
}
|
||||
|
||||
_update (type, value) {
|
||||
const { cron } = this
|
||||
const { onChange } = this.props
|
||||
|
||||
if (value === 'all') {
|
||||
cron[type] = '*'
|
||||
} else if (Array.isArray(value)) {
|
||||
if (!value.length) {
|
||||
cron[type] = '*'
|
||||
} else {
|
||||
cron[type] = join(
|
||||
(type === 'monthDay' || type === 'month')
|
||||
? map(value, (n) => n + 1)
|
||||
: value,
|
||||
','
|
||||
)
|
||||
}
|
||||
} else {
|
||||
cron[type] = `*/${value}`
|
||||
}
|
||||
|
||||
if (onChange) {
|
||||
onChange(this.value)
|
||||
}
|
||||
}
|
||||
|
||||
_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)
|
||||
|
||||
render () {
|
||||
const {
|
||||
cronPattern,
|
||||
timezone
|
||||
} = this.props
|
||||
const cronPatternArr = cronPattern.split(' ')
|
||||
|
||||
return (
|
||||
<div className='card-block'>
|
||||
<Row>
|
||||
<Col mediumSize={6}>
|
||||
<TimePicker
|
||||
ref='month'
|
||||
type='Month'
|
||||
dataRender={getMonthName}
|
||||
data={MONTHS}
|
||||
onChange={this._onMonthChange}
|
||||
labelId='Month'
|
||||
optionRenderer={getMonthName}
|
||||
options={MONTHS}
|
||||
onChange={this._monthChange}
|
||||
range={MONTHS_RANGE}
|
||||
value={cronPatternArr[PICKTIME_TO_ID['month']]}
|
||||
/>
|
||||
<TimePicker
|
||||
ref='monthDay'
|
||||
type='MonthDay'
|
||||
data={DAYS}
|
||||
onChange={this._onMonthDayChange}
|
||||
/>
|
||||
<TimePicker
|
||||
ref='weekDay'
|
||||
type='WeekDay'
|
||||
dataRender={getDayName}
|
||||
data={WEEK_DAYS}
|
||||
onChange={this._onWeekDayChange}
|
||||
<DayPicker
|
||||
onChange={this._dayChange}
|
||||
monthDayPattern={cronPatternArr[PICKTIME_TO_ID['monthDay']]}
|
||||
weekDayPattern={cronPatternArr[PICKTIME_TO_ID['weekDay']]}
|
||||
/>
|
||||
</Col>
|
||||
<Col mediumSize={6}>
|
||||
<TimePicker
|
||||
ref='hour'
|
||||
type='Hour'
|
||||
data={HOURS}
|
||||
range={[2, 12]}
|
||||
onChange={this._onHourChange}
|
||||
labelId='Hour'
|
||||
options={HOURS}
|
||||
range={HOURS_RANGE}
|
||||
onChange={this._hourChange}
|
||||
value={cronPatternArr[PICKTIME_TO_ID['hour']]}
|
||||
/>
|
||||
<TimePicker
|
||||
ref='minute'
|
||||
type='Minute'
|
||||
data={MINS}
|
||||
range={[2, 30]}
|
||||
onChange={this._onMinuteChange}
|
||||
labelId='Minute'
|
||||
options={MINS}
|
||||
range={MINUTES_RANGE}
|
||||
onChange={this._minuteChange}
|
||||
value={cronPatternArr[PICKTIME_TO_ID['minute']]}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col>
|
||||
<hr />
|
||||
<TimezonePicker value={timezone} onChange={this._onTimezoneChange} />
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
32
src/common/select-files.js
Normal file
32
src/common/select-files.js
Normal 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>
|
||||
}
|
||||
}
|
||||
@@ -1,40 +1,83 @@
|
||||
import React from 'react'
|
||||
import assign from 'lodash/assign'
|
||||
import classNames from 'classnames'
|
||||
import filter from 'lodash/filter'
|
||||
import forEach from 'lodash/forEach'
|
||||
import groupBy from 'lodash/groupBy'
|
||||
import keyBy from 'lodash/keyBy'
|
||||
import keys from 'lodash/keys'
|
||||
import map from 'lodash/map'
|
||||
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 autoControlledInput from './auto-controlled-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,
|
||||
createSelector
|
||||
createSelector,
|
||||
getObject
|
||||
} from './selectors'
|
||||
import {
|
||||
addSubscriptions,
|
||||
connectStore,
|
||||
mapPlus
|
||||
mapPlus,
|
||||
resolveResourceSets
|
||||
} from './utils'
|
||||
import {
|
||||
isSrWritable,
|
||||
subscribeCurrentUser,
|
||||
subscribeGroups,
|
||||
subscribeIpPools,
|
||||
subscribeRemotes,
|
||||
subscribeResourceSets,
|
||||
subscribeRoles,
|
||||
subscribeUsers
|
||||
} from './xo'
|
||||
|
||||
// ===================================================================
|
||||
|
||||
// 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 ||
|
||||
@@ -43,17 +86,46 @@ const getLabel = object =>
|
||||
object.value ||
|
||||
object.label
|
||||
|
||||
const options = props => ({
|
||||
defaultValue: props.multi ? [] : undefined
|
||||
})
|
||||
|
||||
// ===================================================================
|
||||
|
||||
/*
|
||||
* WITHOUT xoContainers :
|
||||
*
|
||||
* xoObjects: [
|
||||
* { type: 'myType', id: 'abc', label: 'First object' },
|
||||
* { type: 'myType', id: 'def', label: 'Second object' }
|
||||
* ]
|
||||
*
|
||||
*
|
||||
* WITH xoContainers :
|
||||
*
|
||||
* xoContainers: [
|
||||
* { type: 'containerType', id: 'ghi', label: 'First container' },
|
||||
* { type: 'containerType', id: 'jkl', label: 'Second container' }
|
||||
* ]
|
||||
*
|
||||
* xoObjects: {
|
||||
* ghi: [
|
||||
* { type: 'objectType', id: 'mno', label: 'First object' }
|
||||
* { type: 'objectType', id: 'pqr', label: 'Second object' }
|
||||
* ],
|
||||
* jkl: [
|
||||
* { type: 'objectType', id: 'stu', label: 'Third 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,
|
||||
@@ -63,221 +135,206 @@ 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 || 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 || object)
|
||||
}
|
||||
|
||||
return (value != null)
|
||||
? 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 __DEV__ = process.env.NODE_ENV !== 'production'
|
||||
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 && 'm-l-1'
|
||||
!option.disabled && this.props.xoContainers && 'ml-1'
|
||||
)}
|
||||
>
|
||||
{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) => autoControlledInput(options)(
|
||||
connectStore(createSelectors)(
|
||||
props =>
|
||||
<GenericSelect
|
||||
{...defaultProps}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
|
||||
const makeSubscriptionSelect = (subscribe, props) => (
|
||||
const makeSubscriptionSelect = (subscribe, props) => autoControlledInput(options)(
|
||||
class extends Component {
|
||||
constructor (props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
xoObjects: []
|
||||
}
|
||||
|
||||
this._getFilteredXoObjects = createFilter(
|
||||
() => this.state.xoObjects,
|
||||
() => this.props.predicate
|
||||
this._getFilteredXoContainers = createFilter(
|
||||
() => this.state.xoContainers,
|
||||
() => this.props.containerPredicate
|
||||
)
|
||||
}
|
||||
|
||||
get value () {
|
||||
return this.refs.select.value
|
||||
}
|
||||
|
||||
set value (value) {
|
||||
this.refs.select.value = value
|
||||
this._getFilteredXoObjects = createSelector(
|
||||
() => this.state.xoObjects,
|
||||
() => this.state.xoContainers && this._getFilteredXoContainers(),
|
||||
() => this.props.predicate,
|
||||
(xoObjects, xoContainers, predicate) => {
|
||||
if (xoContainers == null) {
|
||||
return filter(xoObjects, predicate)
|
||||
} else {
|
||||
// Filter xoObjects with `predicate`...
|
||||
const filteredObjects = mapValues(xoObjects, xoObjectsGroup =>
|
||||
filter(xoObjectsGroup, predicate)
|
||||
)
|
||||
// ...and keep only those whose xoContainer hasn't been filtered out
|
||||
return pick(filteredObjects, map(xoContainers, container => container.id))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
componentWillMount () {
|
||||
@@ -287,11 +344,10 @@ const makeSubscriptionSelect = (subscribe, props) => (
|
||||
render () {
|
||||
return (
|
||||
<GenericSelect
|
||||
ref='select'
|
||||
{...props}
|
||||
{...this.props}
|
||||
xoObjects={this._getFilteredXoObjects()}
|
||||
xoContainers={this.state.xoContainers}
|
||||
xoContainers={this.state.xoContainers && this._getFilteredXoContainers()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -414,7 +470,7 @@ export const SelectVmTemplate = makeStoreSelect(() => {
|
||||
xoObjects: getVmTemplatesByPool,
|
||||
xoContainers: getPools
|
||||
}
|
||||
}, { placeholder: _('selectVms') })
|
||||
}, { placeholder: _('selectVmTemplates') })
|
||||
|
||||
// ===================================================================
|
||||
|
||||
@@ -468,11 +524,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,
|
||||
@@ -580,3 +636,295 @@ export const SelectRemote = makeSubscriptionSelect(subscriber => {
|
||||
|
||||
return unsubscribeRemotes
|
||||
}, { placeholder: _('selectRemotes') })
|
||||
|
||||
// ===================================================================
|
||||
|
||||
export const SelectResourceSet = makeSubscriptionSelect(subscriber => {
|
||||
const unsubscribeResourceSets = subscribeResourceSets(resourceSets => {
|
||||
const xoObjects = map(sortBy(resolveResourceSets(resourceSets), 'name'), resourceSet => ({...resourceSet, type: 'resourceSet'}))
|
||||
|
||||
subscriber({xoObjects})
|
||||
})
|
||||
|
||||
return unsubscribeResourceSets
|
||||
}, { placeholder: _('selectResourceSets') })
|
||||
|
||||
// ===================================================================
|
||||
|
||||
export class SelectResourceSetsVmTemplate extends Component {
|
||||
get value () {
|
||||
return this.refs.select.value
|
||||
}
|
||||
|
||||
set value (value) {
|
||||
this.refs.select.value = value
|
||||
}
|
||||
|
||||
_getTemplates = createSelector(
|
||||
() => this.props.resourceSet,
|
||||
({ objectsByType }) => {
|
||||
const { predicate } = this.props
|
||||
const templates = objectsByType['VM-template']
|
||||
return sortBy(predicate ? filter(templates, predicate) : templates, 'name_label')
|
||||
}
|
||||
)
|
||||
|
||||
render () {
|
||||
return (
|
||||
<GenericSelect
|
||||
ref='select'
|
||||
placeholder={_('selectResourceSetsVmTemplate')}
|
||||
{...this.props}
|
||||
xoObjects={this._getTemplates()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
|
||||
export class SelectResourceSetsSr extends Component {
|
||||
get value () {
|
||||
return this.refs.select.value
|
||||
}
|
||||
|
||||
set value (value) {
|
||||
this.refs.select.value = value
|
||||
}
|
||||
_getSrs = createSelector(
|
||||
() => this.props.resourceSet,
|
||||
({ objectsByType }) => {
|
||||
const { predicate } = this.props
|
||||
const srs = objectsByType['SR']
|
||||
return sortBy(predicate ? filter(srs, predicate) : srs, 'name_label')
|
||||
}
|
||||
)
|
||||
|
||||
render () {
|
||||
return (
|
||||
<GenericSelect
|
||||
ref='select'
|
||||
placeholder={_('selectResourceSetsSr')}
|
||||
{...this.props}
|
||||
xoObjects={this._getSrs()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
|
||||
export class SelectResourceSetsVdi extends Component {
|
||||
get value () {
|
||||
return this.refs.select.value
|
||||
}
|
||||
|
||||
set value (value) {
|
||||
this.refs.select.value = value
|
||||
}
|
||||
|
||||
_getObject (id) {
|
||||
return getObject(store.getState(), id, true)
|
||||
}
|
||||
|
||||
_getSrs = createSelector(
|
||||
() => this.props.resourceSet,
|
||||
({ objectsByType }) => {
|
||||
const { srPredicate } = this.props
|
||||
const srs = objectsByType['SR']
|
||||
return srPredicate ? filter(srs, srPredicate) : srs
|
||||
}
|
||||
)
|
||||
|
||||
_getVdis = createSelector(
|
||||
this._getSrs,
|
||||
srs => sortBy(map(flatten(map(srs, sr => sr.VDIs)), this._getObject), 'name_label')
|
||||
)
|
||||
|
||||
render () {
|
||||
return (
|
||||
<GenericSelect
|
||||
ref='select'
|
||||
placeholder={_('selectResourceSetsVdi')}
|
||||
{...this.props}
|
||||
xoObjects={this._getVdis()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
|
||||
export class SelectResourceSetsNetwork extends Component {
|
||||
get value () {
|
||||
return this.refs.select.value
|
||||
}
|
||||
|
||||
set value (value) {
|
||||
this.refs.select.value = value
|
||||
}
|
||||
|
||||
_getNetworks = createSelector(
|
||||
() => this.props.resourceSet,
|
||||
({ objectsByType }) => {
|
||||
const { predicate } = this.props
|
||||
const networks = objectsByType['network']
|
||||
return sortBy(predicate ? filter(networks, predicate) : networks, 'name_label')
|
||||
}
|
||||
)
|
||||
|
||||
render () {
|
||||
return (
|
||||
<GenericSelect
|
||||
ref='select'
|
||||
placeholder={_('selectResourceSetsNetwork')}
|
||||
{...this.props}
|
||||
xoObjects={this._getNetworks()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
|
||||
// Pass a function to @addSubscriptions to ensure subscribeIpPools and subscribeResourceSets
|
||||
// are correctly imported before they are called
|
||||
@addSubscriptions(() => ({
|
||||
ipPools: subscribeIpPools,
|
||||
resourceSets: subscribeResourceSets
|
||||
}))
|
||||
@propTypes({
|
||||
containerPredicate: propTypes.func,
|
||||
predicate: propTypes.func,
|
||||
resourceSetId: propTypes.string.isRequired
|
||||
})
|
||||
export class SelectResourceSetIp extends Component {
|
||||
get value () {
|
||||
return this.refs.select.value
|
||||
}
|
||||
|
||||
set value (value) {
|
||||
this.refs.select.value = value
|
||||
}
|
||||
|
||||
_getResourceSetIpPools = createSelector(
|
||||
() => this.props.ipPools,
|
||||
() => this.props.resourceSets,
|
||||
() => this.props.resourceSetId,
|
||||
(allIpPools, allResourceSets, resourceSetId) => {
|
||||
const { ipPools } = allResourceSets[resourceSetId]
|
||||
return filter(allIpPools, ({ id }) => includes(ipPools, id))
|
||||
}
|
||||
)
|
||||
|
||||
_getIpPools = createSelector(
|
||||
() => this.props.ipPools,
|
||||
() => this.props.containerPredicate,
|
||||
(ipPools, predicate) => predicate
|
||||
? filter(ipPools, predicate)
|
||||
: ipPools
|
||||
)
|
||||
|
||||
_getIps = createSelector(
|
||||
this._getIpPools,
|
||||
() => this.props.predicate,
|
||||
() => this.props.ipPools,
|
||||
(ipPools, predicate, resolvedIpPools) => {
|
||||
return flatten(
|
||||
map(ipPools, ipPool => {
|
||||
const poolIps = map(ipPool.addresses, (address, ip) => ({
|
||||
...address,
|
||||
id: ip,
|
||||
label: ip,
|
||||
type: 'ipAddress',
|
||||
used: !isEmpty(address.vifs)
|
||||
}))
|
||||
return predicate ? filter(poolIps, predicate) : poolIps
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
render () {
|
||||
return (
|
||||
<GenericSelect
|
||||
ref='select'
|
||||
placeholder={_('selectIpPool')}
|
||||
{...this.props}
|
||||
xoObjects={this._getIps()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
|
||||
export class SelectSshKey extends Component {
|
||||
get value () {
|
||||
return this.refs.select.value
|
||||
}
|
||||
|
||||
set value (value) {
|
||||
this.refs.select.value = value
|
||||
}
|
||||
|
||||
componentWillMount () {
|
||||
this.componentWillUnmount = subscribeCurrentUser(user => {
|
||||
this.setState({
|
||||
sshKeys: user && user.preferences && map(user.preferences.sshKeys, (key, id) => ({
|
||||
id,
|
||||
label: key.title,
|
||||
type: 'sshKey'
|
||||
}))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
render () {
|
||||
return (
|
||||
<GenericSelect
|
||||
ref='select'
|
||||
placeholder={_('selectSshKey')}
|
||||
{...this.props}
|
||||
xoObjects={this.state.sshKeys || []}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
|
||||
export const SelectIp = makeSubscriptionSelect(subscriber => {
|
||||
const unsubscribeIpPools = subscribeIpPools(ipPools => {
|
||||
const sortedIpPools = sortBy(ipPools, 'name')
|
||||
const xoObjects = mapValues(
|
||||
groupBy(sortedIpPools, 'id'),
|
||||
ipPools => map(ipPools[0].addresses, (address, ip) => ({
|
||||
...address,
|
||||
id: ip,
|
||||
label: ip,
|
||||
type: 'ipAddress',
|
||||
used: !isEmpty(address.vifs)
|
||||
}))
|
||||
)
|
||||
const xoContainers = map(sortedIpPools, ipPool => ({
|
||||
...ipPool,
|
||||
type: 'ipPool'
|
||||
}))
|
||||
subscriber({ xoObjects, xoContainers })
|
||||
})
|
||||
|
||||
return unsubscribeIpPools
|
||||
}, { placeholder: _('selectIp') })
|
||||
|
||||
// ===================================================================
|
||||
|
||||
export const SelectIpPool = makeSubscriptionSelect(subscriber => {
|
||||
const unsubscribeIpPools = subscribeIpPools(ipPools => {
|
||||
subscriber({
|
||||
xoObjects: map(sortBy(ipPools, 'name'), ipPool => ({ ...ipPool, type: 'ipPool' }))
|
||||
})
|
||||
})
|
||||
|
||||
return unsubscribeIpPools
|
||||
}, { placeholder: _('selectIpPool') })
|
||||
|
||||
@@ -7,6 +7,7 @@ import isArray from 'lodash/isArray'
|
||||
import isArrayLike from 'lodash/isArrayLike'
|
||||
import isFunction from 'lodash/isFunction'
|
||||
import keys from 'lodash/keys'
|
||||
import map from 'lodash/map'
|
||||
import orderBy from 'lodash/orderBy'
|
||||
import pickBy from 'lodash/pickBy'
|
||||
import size from 'lodash/size'
|
||||
@@ -36,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
|
||||
}
|
||||
@@ -141,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
|
||||
@@ -167,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,
|
||||
@@ -186,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) {
|
||||
@@ -212,8 +218,40 @@ const _getId = (state, { routeParams, id }) => routeParams
|
||||
|
||||
export const getLang = state => state.lang
|
||||
|
||||
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,
|
||||
@@ -240,18 +278,29 @@ const _getPermissionsPredicate = invoke(() => {
|
||||
}
|
||||
})
|
||||
|
||||
export const isAdmin = (...args) => {
|
||||
const user = getUser(...args)
|
||||
|
||||
return user && user.permission === 'admin'
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Common selector creators.
|
||||
|
||||
// Creates an object selector from an id selector.
|
||||
export const createGetObject = (idSelector = _getId) =>
|
||||
(state, props) => {
|
||||
(state, props, useResourceSet) => {
|
||||
const object = state.objects.all[idSelector(state, props)]
|
||||
if (!object) {
|
||||
return
|
||||
}
|
||||
|
||||
if (useResourceSet) {
|
||||
return object
|
||||
}
|
||||
|
||||
const predicate = _getPermissionsPredicate(state)
|
||||
|
||||
if (!predicate) {
|
||||
if (predicate == null) {
|
||||
return object // no filtering
|
||||
@@ -315,7 +364,11 @@ const _extendCollectionSelector = (selector, objectsType) => {
|
||||
return selector
|
||||
}
|
||||
_addGroupBy(selector)
|
||||
selector.find = predicate => createFinder(selector, predicate)
|
||||
const _addFind = selector => {
|
||||
selector.find = predicate => createFinder(selector, predicate)
|
||||
return selector
|
||||
}
|
||||
_addFind(selector)
|
||||
|
||||
// groupBy can be chained.
|
||||
const _addSort = selector => {
|
||||
@@ -335,9 +388,9 @@ const _extendCollectionSelector = (selector, objectsType) => {
|
||||
_addFilter(selector)
|
||||
|
||||
// filter, groupBy and sort can be chained.
|
||||
selector.pick = idsSelector => _addFilter(_addGroupBy(_addSort(
|
||||
selector.pick = idsSelector => _addFind(_addFilter(_addGroupBy(_addSort(
|
||||
createPicker(selector, idsSelector)
|
||||
)))
|
||||
))))
|
||||
|
||||
return selector
|
||||
}
|
||||
@@ -355,7 +408,7 @@ const _extendCollectionSelector = (selector, objectsType) => {
|
||||
// - groupBy: returns a selector which returns the objects grouped by
|
||||
// a value determined by a getter selector
|
||||
// - pick: returns a selector which returns only the objects with given
|
||||
// ids (filter, groupBy and sort can be chained)
|
||||
// ids (filter, find, groupBy and sort can be chained)
|
||||
// - sort: returns a selector which returns the objects appropriately
|
||||
// sorted (groupBy can be chained)
|
||||
export const createGetObjectsOfType = type => {
|
||||
@@ -409,23 +462,50 @@ export const createGetObjectMessages = objectSelector =>
|
||||
// ...
|
||||
export const getObject = createGetObject((_, id) => id)
|
||||
|
||||
export const createGetHostMetrics = hostSelector => _createCollectionWrapper(
|
||||
export const createDoesHostNeedRestart = hostSelector => {
|
||||
// Returns the first patch of the host which requires it to be
|
||||
// restarted.
|
||||
const restartPoolPatch = createGetObjectsOfType('pool_patch').pick(
|
||||
create(
|
||||
createGetObjectsOfType('host_patch').pick(
|
||||
(state, props) => {
|
||||
const host = hostSelector(state, props)
|
||||
return host && host.patches
|
||||
}
|
||||
).filter(create(
|
||||
(state, props) => {
|
||||
const host = hostSelector(state, props)
|
||||
return host && host.startTime
|
||||
},
|
||||
startTime => patch => patch.time > startTime
|
||||
)),
|
||||
hostPatches => map(hostPatches, hostPatch => hostPatch.pool_patch)
|
||||
)
|
||||
).find([ ({ guidance }) => find(guidance, action =>
|
||||
action === 'restartHost' || action === 'restartXapi'
|
||||
) ])
|
||||
|
||||
return (state, props) => restartPoolPatch(state, props) !== undefined
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
35
src/common/shortcuts.js
Normal file
35
src/common/shortcuts.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import Component from 'base-component'
|
||||
import forEach from 'lodash/forEach'
|
||||
import React from 'react'
|
||||
import remove from 'lodash/remove'
|
||||
import { Shortcuts as ReactShortcuts } from 'react-shortcuts'
|
||||
|
||||
let enabled = true
|
||||
const instances = []
|
||||
|
||||
const updateInstances = () => {
|
||||
forEach(instances, instance => instance.forceUpdate())
|
||||
}
|
||||
|
||||
export const enable = () => {
|
||||
enabled = true
|
||||
updateInstances()
|
||||
}
|
||||
|
||||
export const disable = () => {
|
||||
enabled = false
|
||||
updateInstances()
|
||||
}
|
||||
|
||||
export default class Shortcuts extends Component {
|
||||
componentDidMount () {
|
||||
instances.push(this)
|
||||
}
|
||||
componentWillUnmount () {
|
||||
remove(instances, this)
|
||||
}
|
||||
|
||||
render () {
|
||||
return enabled ? <ReactShortcuts {...this.props} /> : null
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
.clickableColumn {
|
||||
cursor: pointer;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.clickableColumn:hover {
|
||||
color: #fff;
|
||||
background-color: #96b8d1;
|
||||
color: #fff;
|
||||
background-color: #96b8d1;
|
||||
}
|
||||
|
||||
.clickableRow {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import _ from 'intl'
|
||||
import ceil from 'lodash/ceil'
|
||||
import classNames from 'classnames'
|
||||
import debounce from 'lodash/debounce'
|
||||
import findIndex from 'lodash/findIndex'
|
||||
import isEmpty from 'lodash/isEmpty'
|
||||
import isFunction from 'lodash/isFunction'
|
||||
import map from 'lodash/map'
|
||||
@@ -108,19 +110,18 @@ class ColumnHead extends Component {
|
||||
return <th>{name}</th>
|
||||
}
|
||||
|
||||
let className = styles.clickableColumn
|
||||
|
||||
if (sortIcon === 'asc' || sortIcon === 'desc') {
|
||||
className += ' bg-info'
|
||||
}
|
||||
const isSelected = sortIcon === 'asc' || sortIcon === 'desc'
|
||||
|
||||
return (
|
||||
<th
|
||||
className={className}
|
||||
className={classNames(
|
||||
styles.clickableColumn,
|
||||
isSelected && classNames('text-white', 'bg-info')
|
||||
)}
|
||||
onClick={this._sort}
|
||||
>
|
||||
{name}
|
||||
<span className='pull-xs-right'>
|
||||
<span className='pull-right'>
|
||||
<Icon icon={sortIcon} />
|
||||
</span>
|
||||
</th>
|
||||
@@ -139,6 +140,7 @@ const DEFAULT_ITEMS_PER_PAGE = 10
|
||||
propTypes.object
|
||||
]).isRequired,
|
||||
columns: propTypes.arrayOf(propTypes.shape({
|
||||
default: propTypes.bool,
|
||||
name: propTypes.node.isRequired,
|
||||
itemRenderer: propTypes.func.isRequired,
|
||||
sortCriteria: propTypes.oneOfType([
|
||||
@@ -151,6 +153,7 @@ const DEFAULT_ITEMS_PER_PAGE = 10
|
||||
filters: propTypes.object,
|
||||
itemsPerPage: propTypes.number,
|
||||
paginationContainer: propTypes.func,
|
||||
rowAction: propTypes.func,
|
||||
rowLink: propTypes.oneOfType([
|
||||
propTypes.func,
|
||||
propTypes.string
|
||||
@@ -161,8 +164,17 @@ export default class SortedTable extends Component {
|
||||
constructor (props) {
|
||||
super(props)
|
||||
|
||||
let selectedColumn = props.defaultColumn
|
||||
if (selectedColumn == null) {
|
||||
selectedColumn = findIndex(props.columns, 'default')
|
||||
|
||||
if (selectedColumn === -1) {
|
||||
selectedColumn = 0
|
||||
}
|
||||
}
|
||||
|
||||
this.state = {
|
||||
selectedColumn: props.defaultColumn || 0,
|
||||
selectedColumn,
|
||||
itemsPerPage: props.itemsPerPage || DEFAULT_ITEMS_PER_PAGE
|
||||
}
|
||||
|
||||
@@ -252,6 +264,7 @@ export default class SortedTable extends Component {
|
||||
paginationContainer,
|
||||
filterContainer,
|
||||
filters,
|
||||
rowAction,
|
||||
rowLink,
|
||||
userData
|
||||
} = props
|
||||
@@ -300,7 +313,7 @@ export default class SortedTable extends Component {
|
||||
</thead>
|
||||
<tbody>
|
||||
{map(this._getVisibleItems(), (item, i) => {
|
||||
const colums = map(props.columns, (column, key) => (
|
||||
const columns = map(props.columns, (column, key) => (
|
||||
<td key={key}>
|
||||
{column.itemRenderer(item, userData)}
|
||||
</td>
|
||||
@@ -313,8 +326,14 @@ export default class SortedTable extends Component {
|
||||
key={id}
|
||||
tagName='tr'
|
||||
to={isFunction(rowLink) ? rowLink(item, userData) : rowLink}
|
||||
>{colums}</BlockLink>
|
||||
: <tr key={id}>{colums}</tr>
|
||||
>{columns}</BlockLink>
|
||||
: <tr
|
||||
className={rowAction && styles.clickableRow}
|
||||
key={id}
|
||||
onClick={rowAction && (() => rowAction(item, userData))}
|
||||
>
|
||||
{columns}
|
||||
</tr>
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -324,19 +343,19 @@ export default class SortedTable extends Component {
|
||||
<Col mediumSize={8}>
|
||||
{paginationContainer
|
||||
? (
|
||||
// Rebuild container function to refresh Portal component.
|
||||
<Portal container={() => paginationContainer()}>
|
||||
{paginationInstance}
|
||||
</Portal>
|
||||
// Rebuild container function to refresh Portal component.
|
||||
<Portal container={() => paginationContainer()}>
|
||||
{paginationInstance}
|
||||
</Portal>
|
||||
) : paginationInstance
|
||||
}
|
||||
</Col>
|
||||
<Col mediumSize={4}>
|
||||
{filterContainer
|
||||
? (
|
||||
<Portal container={() => filterContainer()}>
|
||||
{filterInstance}
|
||||
</Portal>
|
||||
<Portal container={() => filterContainer()}>
|
||||
{filterInstance}
|
||||
</Portal>
|
||||
) : filterInstance
|
||||
}
|
||||
</Col>
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
})()
|
||||
|
||||
// ===================================================================
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,44 @@
|
||||
import React from 'react'
|
||||
import filter from 'lodash/filter'
|
||||
import includes from 'lodash/includes'
|
||||
import map from 'lodash/map'
|
||||
import React from 'react'
|
||||
|
||||
import Component from './base-component'
|
||||
import Icon from './icon'
|
||||
import propTypes from './prop-types'
|
||||
|
||||
const INPUT_STYLE = {
|
||||
margin: '2px',
|
||||
maxWidth: '4em'
|
||||
}
|
||||
const TAG_STYLE = {
|
||||
backgroundColor: '#2598d9',
|
||||
borderRadius: '0.5em',
|
||||
color: 'white',
|
||||
fontSize: '0.6em',
|
||||
margin: '0.2em',
|
||||
marginTop: '-0.1em',
|
||||
padding: '0.3em',
|
||||
verticalAlign: 'middle'
|
||||
}
|
||||
const LINK_STYLE = {
|
||||
cursor: 'pointer'
|
||||
}
|
||||
const ADD_TAG_STYLE = {
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.8em',
|
||||
marginLeft: '0.2em'
|
||||
}
|
||||
const REMOVE_TAG_STYLE = {
|
||||
cursor: 'pointer'
|
||||
}
|
||||
|
||||
@propTypes({
|
||||
labels: propTypes.arrayOf(React.PropTypes.string).isRequired,
|
||||
onDelete: propTypes.func,
|
||||
onAdd: propTypes.func
|
||||
onAdd: propTypes.func,
|
||||
onChange: propTypes.func,
|
||||
onClick: propTypes.func,
|
||||
onDelete: propTypes.func
|
||||
})
|
||||
export default class Tags extends Component {
|
||||
componentWillMount () {
|
||||
@@ -22,56 +52,85 @@ export default class Tags extends Component {
|
||||
this.setState({ editing: false })
|
||||
}
|
||||
|
||||
_addTag = newTag => {
|
||||
const { labels, onAdd, onChange } = this.props
|
||||
|
||||
if (!includes(labels, newTag)) {
|
||||
onAdd && onAdd(newTag)
|
||||
onChange && onChange([ ...labels, newTag ])
|
||||
}
|
||||
}
|
||||
_deleteTag = tag => {
|
||||
const { onChange, onDelete } = this.props
|
||||
|
||||
onDelete && onDelete(tag)
|
||||
onChange && onChange(filter(this.props.labels, t => t !== tag))
|
||||
}
|
||||
|
||||
_onKeyDown = event => {
|
||||
const { keyCode, target } = event
|
||||
|
||||
if (keyCode === 13) {
|
||||
if (target.value) {
|
||||
this._addTag(target.value)
|
||||
target.value = ''
|
||||
}
|
||||
} else if (keyCode === 27) {
|
||||
this._stopEdit()
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
render () {
|
||||
const {
|
||||
labels,
|
||||
onDelete,
|
||||
onAdd
|
||||
onAdd,
|
||||
onChange,
|
||||
onClick,
|
||||
onDelete
|
||||
} = this.props
|
||||
|
||||
const deleteTag = (onDelete || onChange) && this._deleteTag
|
||||
|
||||
return (
|
||||
<span className='form-group' style={{ color: '#999' }}>
|
||||
<Icon icon='tags' />
|
||||
{' '}
|
||||
<span>
|
||||
{map(labels.sort(), (label, index) =>
|
||||
<Tag label={label} onDelete={onDelete} key={index} />
|
||||
<Tag label={label} onDelete={deleteTag} key={index} onClick={onClick} />
|
||||
)}
|
||||
</span>
|
||||
{onAdd
|
||||
? !this.state.editing
|
||||
? <span className='add-tag-action' onClick={this._startEdit} style={{cursor: 'pointer'}}>
|
||||
<Icon icon='add-tag' />
|
||||
</span>
|
||||
: <span>
|
||||
<input
|
||||
type='text'
|
||||
autoFocus
|
||||
style={{maxWidth: '4em', margin: '2px'}}
|
||||
onKeyDown={event => {
|
||||
const { target } = event
|
||||
|
||||
if (event.keyCode === 13 && target.value) {
|
||||
onAdd(target.value)
|
||||
target.value = ''
|
||||
} else if (event.keyCode === 27) {
|
||||
this._stopEdit()
|
||||
}
|
||||
}}
|
||||
onBlur={this._stopEdit}
|
||||
></input>
|
||||
</span>
|
||||
: []
|
||||
{(onAdd || onChange) && !this.state.editing
|
||||
? <span onClick={this._startEdit} style={ADD_TAG_STYLE}>
|
||||
<Icon icon='add-tag' />
|
||||
</span>
|
||||
: <span>
|
||||
<input
|
||||
type='text'
|
||||
autoFocus
|
||||
style={INPUT_STYLE}
|
||||
onKeyDown={this._onKeyDown}
|
||||
onBlur={this._stopEdit}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const Tag = ({ label, onDelete }) => (
|
||||
<span className='xo-tag'>
|
||||
{label}{' '}
|
||||
export const Tag = ({ type, label, onDelete, onClick }) => (
|
||||
<span style={TAG_STYLE}>
|
||||
<span onClick={onClick && (() => onClick(label))} style={onClick && LINK_STYLE}>
|
||||
{label}
|
||||
</span>
|
||||
{' '}
|
||||
{onDelete
|
||||
? <span onClick={onDelete && (() => onDelete(label))} style={{cursor: 'pointer'}}>
|
||||
? <span onClick={onDelete && (() => onDelete(label))} style={REMOVE_TAG_STYLE}>
|
||||
<Icon icon='remove-tag' />
|
||||
</span>
|
||||
: []
|
||||
|
||||
94
src/common/timezone-picker.js
Normal file
94
src/common/timezone-picker.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import ActionButton from 'action-button'
|
||||
import map from 'lodash/map'
|
||||
import moment from 'moment-timezone'
|
||||
import React from 'react'
|
||||
|
||||
import _ from './intl'
|
||||
import Component from './base-component'
|
||||
import propTypes from './prop-types'
|
||||
import { getXoServerTimezone } from './xo'
|
||||
import { Select } from './form'
|
||||
|
||||
const 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 {
|
||||
componentDidMount () {
|
||||
getXoServerTimezone.then(serverTimezone => {
|
||||
this.setState({
|
||||
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 { timezone, options } = this.state
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Select
|
||||
className='mb-1'
|
||||
onChange={this._onChange}
|
||||
options={options}
|
||||
placeholder={_('selectTimezone')}
|
||||
required={this.props.required}
|
||||
value={timezone}
|
||||
/>
|
||||
<div className='pull-right'>
|
||||
<ActionButton
|
||||
btnStyle='secondary'
|
||||
handler={this._useLocalTime}
|
||||
icon='time'
|
||||
>
|
||||
{_('timezonePickerUseLocalTime')}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
287
src/common/tooltip/get-position.js
Normal file
287
src/common/tooltip/get-position.js
Normal file
@@ -0,0 +1,287 @@
|
||||
// Source: https://github.com/wwayne/react-tooltip/blob/master/src/utils/getPosition.js
|
||||
|
||||
/**
|
||||
* Calculate the position of tooltip
|
||||
*
|
||||
* @params
|
||||
* - `e` {Event} the event of current mouse
|
||||
* - `target` {Element} the currentTarget of the event
|
||||
* - `node` {DOM} the react-tooltip object
|
||||
* - `place` {String} top / right / bottom / left
|
||||
* - `effect` {String} float / solid
|
||||
* - `offset` {Object} the offset to default position
|
||||
*
|
||||
* @return {Object
|
||||
* - `isNewState` {Bool} required
|
||||
* - `newState` {Object}
|
||||
* - `position` {OBject} {left: {Number}, top: {Number}}
|
||||
*/
|
||||
export default function (e, target, node, place, effect, offset) {
|
||||
const tipWidth = node.clientWidth
|
||||
const tipHeight = node.clientHeight
|
||||
const {mouseX, mouseY} = getCurrentOffset(e, target, effect)
|
||||
const defaultOffset = getDefaultPosition(effect, target.clientWidth, target.clientHeight, tipWidth, tipHeight)
|
||||
const {extraOffsetX, extraOffsetY} = calculateOffset(offset)
|
||||
|
||||
const windowWidth = window.innerWidth
|
||||
const windowHeight = window.innerHeight
|
||||
|
||||
const {parentTop, parentLeft} = getParent(target)
|
||||
|
||||
// Get the edge offset of the tooltip
|
||||
const getTipOffsetLeft = (place) => {
|
||||
const offsetX = defaultOffset[place].l
|
||||
return mouseX + offsetX + extraOffsetX
|
||||
}
|
||||
const getTipOffsetRight = (place) => {
|
||||
const offsetX = defaultOffset[place].r
|
||||
return mouseX + offsetX + extraOffsetX
|
||||
}
|
||||
const getTipOffsetTop = (place) => {
|
||||
const offsetY = defaultOffset[place].t
|
||||
return mouseY + offsetY + extraOffsetY
|
||||
}
|
||||
const getTipOffsetBottom = (place) => {
|
||||
const offsetY = defaultOffset[place].b
|
||||
return mouseY + offsetY + extraOffsetY
|
||||
}
|
||||
|
||||
// Judge if the tooltip has over the window(screen)
|
||||
const outsideVertical = () => {
|
||||
let result = false
|
||||
let newPlace
|
||||
if (getTipOffsetTop('left') < 0 &&
|
||||
getTipOffsetBottom('left') <= windowHeight &&
|
||||
getTipOffsetBottom('bottom') <= windowHeight) {
|
||||
result = true
|
||||
newPlace = 'bottom'
|
||||
} else if (getTipOffsetBottom('left') > windowHeight &&
|
||||
getTipOffsetTop('left') >= 0 &&
|
||||
getTipOffsetTop('top') >= 0) {
|
||||
result = true
|
||||
newPlace = 'top'
|
||||
}
|
||||
return {result, newPlace}
|
||||
}
|
||||
const outsideLeft = () => {
|
||||
let {result, newPlace} = outsideVertical() // Deal with vertical as first priority
|
||||
if (result && outsideHorizontal().result) {
|
||||
return {result: false} // No need to change, if change to vertical will out of space
|
||||
}
|
||||
if (!result && getTipOffsetLeft('left') < 0 && getTipOffsetRight('right') <= windowWidth) {
|
||||
result = true // If vertical ok, but let out of side and right won't out of side
|
||||
newPlace = 'right'
|
||||
}
|
||||
return {result, newPlace}
|
||||
}
|
||||
const outsideRight = () => {
|
||||
let {result, newPlace} = outsideVertical()
|
||||
if (result && outsideHorizontal().result) {
|
||||
return {result: false} // No need to change, if change to vertical will out of space
|
||||
}
|
||||
if (!result && getTipOffsetRight('right') > windowWidth && getTipOffsetLeft('left') >= 0) {
|
||||
result = true
|
||||
newPlace = 'left'
|
||||
}
|
||||
return {result, newPlace}
|
||||
}
|
||||
|
||||
const outsideHorizontal = () => {
|
||||
let result = false
|
||||
let newPlace
|
||||
if (getTipOffsetLeft('top') < 0 &&
|
||||
getTipOffsetRight('top') <= windowWidth &&
|
||||
getTipOffsetRight('right') <= windowWidth) {
|
||||
result = true
|
||||
newPlace = 'right'
|
||||
} else if (getTipOffsetRight('top') > windowWidth &&
|
||||
getTipOffsetLeft('top') >= 0 &&
|
||||
getTipOffsetLeft('left') >= 0) {
|
||||
result = true
|
||||
newPlace = 'left'
|
||||
}
|
||||
return {result, newPlace}
|
||||
}
|
||||
const outsideTop = () => {
|
||||
let {result, newPlace} = outsideHorizontal()
|
||||
if (result && outsideVertical().result) {
|
||||
return {result: false}
|
||||
}
|
||||
if (!result && getTipOffsetTop('top') < 0 && getTipOffsetBottom('bottom') <= windowHeight) {
|
||||
result = true
|
||||
newPlace = 'bottom'
|
||||
}
|
||||
return {result, newPlace}
|
||||
}
|
||||
const outsideBottom = () => {
|
||||
let {result, newPlace} = outsideHorizontal()
|
||||
if (result && outsideVertical().result) {
|
||||
return {result: false}
|
||||
}
|
||||
if (!result && getTipOffsetBottom('bottom') > windowHeight && getTipOffsetTop('top') >= 0) {
|
||||
result = true
|
||||
newPlace = 'top'
|
||||
}
|
||||
return {result, newPlace}
|
||||
}
|
||||
|
||||
// Return new state to change the placement to the reverse if possible
|
||||
const outsideLeftResult = outsideLeft()
|
||||
const outsideRightResult = outsideRight()
|
||||
const outsideTopResult = outsideTop()
|
||||
const outsideBottomResult = outsideBottom()
|
||||
|
||||
if (place === 'left' && outsideLeftResult.result) {
|
||||
return {
|
||||
isNewState: true,
|
||||
newState: {place: outsideLeftResult.newPlace}
|
||||
}
|
||||
} else if (place === 'right' && outsideRightResult.result) {
|
||||
return {
|
||||
isNewState: true,
|
||||
newState: {place: outsideRightResult.newPlace}
|
||||
}
|
||||
} else if (place === 'top' && outsideTopResult.result) {
|
||||
return {
|
||||
isNewState: true,
|
||||
newState: {place: outsideTopResult.newPlace}
|
||||
}
|
||||
} else if (place === 'bottom' && outsideBottomResult.result) {
|
||||
return {
|
||||
isNewState: true,
|
||||
newState: {place: outsideBottomResult.newPlace}
|
||||
}
|
||||
}
|
||||
|
||||
// Return tooltip offset position
|
||||
return {
|
||||
isNewState: false,
|
||||
position: {
|
||||
left: getTipOffsetLeft(place) - parentLeft,
|
||||
top: getTipOffsetTop(place) - parentTop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get current mouse offset
|
||||
const getCurrentOffset = (e, currentTarget, effect) => {
|
||||
const boundingClientRect = currentTarget.getBoundingClientRect()
|
||||
const targetTop = boundingClientRect.top
|
||||
const targetLeft = boundingClientRect.left
|
||||
const targetWidth = currentTarget.clientWidth
|
||||
const targetHeight = currentTarget.clientHeight
|
||||
|
||||
if (effect === 'float') {
|
||||
return {
|
||||
mouseX: e.clientX,
|
||||
mouseY: e.clientY
|
||||
}
|
||||
}
|
||||
return {
|
||||
mouseX: targetLeft + (targetWidth / 2),
|
||||
mouseY: targetTop + (targetHeight / 2)
|
||||
}
|
||||
}
|
||||
|
||||
// List all possibility of tooltip final offset
|
||||
// This is useful in judging if it is necessary for tooltip to switch position when out of window
|
||||
const getDefaultPosition = (effect, targetWidth, targetHeight, tipWidth, tipHeight) => {
|
||||
let top
|
||||
let right
|
||||
let bottom
|
||||
let left
|
||||
const disToMouse = 3
|
||||
const triangleHeight = 2
|
||||
const cursorHeight = 12 // Optimize for float bottom only, cause the cursor will hide the tooltip
|
||||
|
||||
if (effect === 'float') {
|
||||
top = {
|
||||
l: -(tipWidth / 2),
|
||||
r: tipWidth / 2,
|
||||
t: -(tipHeight + disToMouse + triangleHeight),
|
||||
b: -disToMouse
|
||||
}
|
||||
bottom = {
|
||||
l: -(tipWidth / 2),
|
||||
r: tipWidth / 2,
|
||||
t: disToMouse + cursorHeight,
|
||||
b: tipHeight + disToMouse + triangleHeight + cursorHeight
|
||||
}
|
||||
left = {
|
||||
l: -(tipWidth + disToMouse + triangleHeight),
|
||||
r: -disToMouse,
|
||||
t: -(tipHeight / 2),
|
||||
b: tipHeight / 2
|
||||
}
|
||||
right = {
|
||||
l: disToMouse,
|
||||
r: tipWidth + disToMouse + triangleHeight,
|
||||
t: -(tipHeight / 2),
|
||||
b: tipHeight / 2
|
||||
}
|
||||
} else if (effect === 'solid') {
|
||||
top = {
|
||||
l: -(tipWidth / 2),
|
||||
r: tipWidth / 2,
|
||||
t: -(targetHeight / 2 + tipHeight + triangleHeight),
|
||||
b: -(targetHeight / 2)
|
||||
}
|
||||
bottom = {
|
||||
l: -(tipWidth / 2),
|
||||
r: tipWidth / 2,
|
||||
t: targetHeight / 2,
|
||||
b: targetHeight / 2 + tipHeight + triangleHeight
|
||||
}
|
||||
left = {
|
||||
l: -(tipWidth + targetWidth / 2 + triangleHeight),
|
||||
r: -(targetWidth / 2),
|
||||
t: -(tipHeight / 2),
|
||||
b: tipHeight / 2
|
||||
}
|
||||
right = {
|
||||
l: targetWidth / 2,
|
||||
r: tipWidth + targetWidth / 2 + triangleHeight,
|
||||
t: -(tipHeight / 2),
|
||||
b: tipHeight / 2
|
||||
}
|
||||
}
|
||||
|
||||
return {top, bottom, left, right}
|
||||
}
|
||||
|
||||
// Consider additional offset into position calculation
|
||||
const calculateOffset = (offset) => {
|
||||
let extraOffsetX = 0
|
||||
let extraOffsetY = 0
|
||||
|
||||
if (Object.prototype.toString.apply(offset) === '[object String]') {
|
||||
offset = JSON.parse(offset.toString().replace(/'/g, '"'))
|
||||
}
|
||||
for (let key in offset) {
|
||||
if (key === 'top') {
|
||||
extraOffsetY -= parseInt(offset[key], 10)
|
||||
} else if (key === 'bottom') {
|
||||
extraOffsetY += parseInt(offset[key], 10)
|
||||
} else if (key === 'left') {
|
||||
extraOffsetX -= parseInt(offset[key], 10)
|
||||
} else if (key === 'right') {
|
||||
extraOffsetX += parseInt(offset[key], 10)
|
||||
}
|
||||
}
|
||||
|
||||
return {extraOffsetX, extraOffsetY}
|
||||
}
|
||||
|
||||
// Get the offset of the parent elements
|
||||
const getParent = (currentTarget) => {
|
||||
let currentParent = currentTarget
|
||||
while (currentParent) {
|
||||
if (currentParent.style.transform.length > 0) break
|
||||
currentParent = currentParent.parentElement
|
||||
}
|
||||
|
||||
const parentTop = currentParent && currentParent.getBoundingClientRect().top || 0
|
||||
const parentLeft = currentParent && currentParent.getBoundingClientRect().left || 0
|
||||
|
||||
return {parentTop, parentLeft}
|
||||
}
|
||||
@@ -1,45 +1,20 @@
|
||||
.container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.common {
|
||||
opacity: 0;
|
||||
transition: opacity .3s;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.container:hover .common {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
.tooltipEnabled {
|
||||
background-color: #222;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #fff;
|
||||
color: #fff;
|
||||
display: inline-block;
|
||||
font-size: 13px;
|
||||
margin-left: 0px;
|
||||
margin-top: 0px;
|
||||
opacity: 0.9;
|
||||
padding: 8px 21px;
|
||||
pointer-events: none;
|
||||
position: fixed;
|
||||
transition: opacity 0.3s ease-out, margin-top 0.3s ease-out, margin-left 0.3s ease-out;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
composes: common;
|
||||
|
||||
border-bottom: .5em solid rgba(0, 0, 0, .8);
|
||||
border-left: .5em solid transparent;
|
||||
border-right: .5em solid transparent;
|
||||
font-size: 1rem;
|
||||
left: 25%;
|
||||
margin-left: 1em;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
composes: common;
|
||||
|
||||
background: #333;
|
||||
background: rgba(0, 0, 0, .8);
|
||||
border-radius: .25em;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
left: 25%;
|
||||
margin-top: .5em;
|
||||
padding: .5em;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
min-width: fit-content;
|
||||
max-width: 20em;
|
||||
.tooltipDisabled {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1,30 +1,155 @@
|
||||
import classNames from 'classnames'
|
||||
import React, { PropTypes } from 'react'
|
||||
import isString from 'lodash/isString'
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
|
||||
import Component from '../base-component'
|
||||
import getPosition from './get-position'
|
||||
import propTypes from '../prop-types'
|
||||
|
||||
import styles from './index.css'
|
||||
|
||||
const Tooltip = ({
|
||||
children,
|
||||
className,
|
||||
content,
|
||||
style,
|
||||
tagName: Component = 'span'
|
||||
}) => (
|
||||
<Component className={classNames(className, styles.container)} style={style}>
|
||||
<div className={styles.arrow} />
|
||||
<div className={styles.tooltip}>
|
||||
{content}
|
||||
</div>
|
||||
{children}
|
||||
</Component>
|
||||
)
|
||||
// ===================================================================
|
||||
|
||||
Tooltip.propTypes = {
|
||||
children: PropTypes.node,
|
||||
className: PropTypes.string,
|
||||
content: PropTypes.any.isRequired,
|
||||
style: PropTypes.object,
|
||||
tagName: PropTypes.string
|
||||
let instance
|
||||
|
||||
export class TooltipViewer extends Component {
|
||||
constructor () {
|
||||
super()
|
||||
|
||||
this.state.place = 'top'
|
||||
}
|
||||
|
||||
componentDidMount () {
|
||||
if (instance) {
|
||||
throw new Error('Tooltip viewer is a singleton!')
|
||||
}
|
||||
instance = this
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
instance = undefined
|
||||
}
|
||||
|
||||
render () {
|
||||
const {
|
||||
className,
|
||||
content,
|
||||
place,
|
||||
show,
|
||||
style
|
||||
} = this.state
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(show ? styles.tooltipEnabled : styles.tooltipDisabled, className)}
|
||||
style={{
|
||||
marginTop: (place === 'top' && '-10px') || (place === 'bottom' && '10px'),
|
||||
marginLeft: (place === 'left' && '-10px') || (place === 'right' && '10px'),
|
||||
...style
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export { Tooltip as default }
|
||||
// ===================================================================
|
||||
|
||||
@propTypes({
|
||||
children: propTypes.oneOfType([
|
||||
propTypes.element,
|
||||
propTypes.string
|
||||
]),
|
||||
className: propTypes.string,
|
||||
content: propTypes.node,
|
||||
style: propTypes.object,
|
||||
tagName: propTypes.string
|
||||
})
|
||||
export default class Tooltip extends Component {
|
||||
componentDidMount () {
|
||||
this._addListeners()
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
this._removeListeners()
|
||||
}
|
||||
|
||||
componentWillReceiveProps (props) {
|
||||
if (props.children !== this.props.children) {
|
||||
this._removeListeners()
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate (prevProps) {
|
||||
if (prevProps.children !== this.props.children) {
|
||||
this._addListeners()
|
||||
}
|
||||
}
|
||||
|
||||
_addListeners () {
|
||||
const node = this._node = ReactDOM.findDOMNode(this)
|
||||
|
||||
node.addEventListener('mouseenter', this._showTooltip)
|
||||
node.addEventListener('mouseleave', this._hideTooltip)
|
||||
node.addEventListener('mousemove', this._updateTooltip)
|
||||
}
|
||||
|
||||
_removeListeners () {
|
||||
const node = this._node
|
||||
this._hideTooltip()
|
||||
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
|
||||
node.removeEventListener('mouseenter', this._showTooltip)
|
||||
node.removeEventListener('mouseleave', this._hideTooltip)
|
||||
node.removeEventListener('mousemove', this._updateTooltip)
|
||||
|
||||
this._node = null
|
||||
}
|
||||
|
||||
_showTooltip = () => {
|
||||
const { props } = this
|
||||
|
||||
instance.setState({
|
||||
className: props.className,
|
||||
content: props.content,
|
||||
show: true,
|
||||
style: props.style
|
||||
})
|
||||
}
|
||||
|
||||
_hideTooltip = () => {
|
||||
instance.setState({ show: false })
|
||||
}
|
||||
|
||||
_updateTooltip = event => {
|
||||
const node = ReactDOM.findDOMNode(instance)
|
||||
const result = getPosition(event, event.currentTarget, node, instance.state.place, 'solid', {})
|
||||
|
||||
if (result.isNewState) {
|
||||
return instance.setState(result.newState, () => this._updateTooltip(event))
|
||||
}
|
||||
|
||||
const { position } = result
|
||||
node.style.left = `${position.left}px`
|
||||
node.style.top = `${position.top}px`
|
||||
}
|
||||
|
||||
render () {
|
||||
const { children } = this.props
|
||||
|
||||
if (!children) {
|
||||
return <span />
|
||||
}
|
||||
|
||||
if (isString(children)) {
|
||||
return <span>{children}</span>
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
}
|
||||
|
||||
72
src/common/usage/index.js
Normal file
72
src/common/usage/index.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import _ from 'intl'
|
||||
import classNames from 'classnames'
|
||||
import React, { PropTypes, cloneElement } from 'react'
|
||||
import sum from 'lodash/sum'
|
||||
|
||||
import Tooltip from '../tooltip'
|
||||
|
||||
const Usage = ({ total, children }) => {
|
||||
const limit = total / 400
|
||||
const othersValues = React.Children.map(children, child => {
|
||||
const { value } = child.props
|
||||
return value < limit && value
|
||||
})
|
||||
const othersTotal = sum(othersValues)
|
||||
return <span className='usage'>
|
||||
{React.Children.map(children, (child, index) =>
|
||||
child.props.value > limit && cloneElement(child, { total })
|
||||
)}
|
||||
<Element
|
||||
others
|
||||
tooltip={_('others')}
|
||||
total={total}
|
||||
value={othersTotal}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
Usage.propTypes = {
|
||||
total: PropTypes.number.isRequired
|
||||
}
|
||||
export { Usage as default }
|
||||
|
||||
const Element = ({ highlight, href, others, tooltip, total, value }) => (
|
||||
<Tooltip content={tooltip}>
|
||||
<a
|
||||
href={href}
|
||||
className={classNames(
|
||||
'usage-element',
|
||||
highlight && 'usage-element-highlight',
|
||||
others && 'usage-element-others'
|
||||
)}
|
||||
style={{ width: (value / total) * 100 + '%' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
Element.propTypes = {
|
||||
highlight: PropTypes.bool,
|
||||
href: PropTypes.string,
|
||||
others: PropTypes.bool,
|
||||
tooltip: PropTypes.node,
|
||||
value: PropTypes.number.isRequired
|
||||
}
|
||||
export { Element as UsageElement }
|
||||
|
||||
export const Limits = ({ used, toBeUsed, limit }) => {
|
||||
const available = limit - used
|
||||
|
||||
return <span className='limits'>
|
||||
<span
|
||||
className='limits-used'
|
||||
style={{ width: ((used || 0) / limit) * 100 + '%' }}
|
||||
/>
|
||||
<span
|
||||
className={toBeUsed > available ? 'limits-over-used' : 'limits-to-be-used'}
|
||||
style={{ width: (Math.min((toBeUsed || 0), available) / limit) * 100 + '%' }}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
Limits.propTypes = {
|
||||
used: PropTypes.number,
|
||||
toBeUsed: PropTypes.number,
|
||||
limit: PropTypes.number.isRequired
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as actions from 'store/actions'
|
||||
import escapeRegExp from 'lodash/escapeRegExp'
|
||||
import every from 'lodash/every'
|
||||
import forEach from 'lodash/forEach'
|
||||
import getStream from 'get-stream'
|
||||
import humanFormat from 'human-format'
|
||||
import isArray from 'lodash/isArray'
|
||||
import isEmpty from 'lodash/isEmpty'
|
||||
@@ -13,11 +13,16 @@ import keys from 'lodash/keys'
|
||||
import map from 'lodash/map'
|
||||
import mapValues from 'lodash/mapValues'
|
||||
import React from 'react'
|
||||
import ReadableStream from 'readable-stream'
|
||||
import replace from 'lodash/replace'
|
||||
import { connect } from 'react-redux'
|
||||
|
||||
import _ from './intl'
|
||||
import * as actions from './store/actions'
|
||||
import BaseComponent from './base-component'
|
||||
import invoke from './invoke'
|
||||
import store from './store'
|
||||
import { getObject } from './selectors'
|
||||
|
||||
export const EMPTY_ARRAY = Object.freeze([ ])
|
||||
export const EMPTY_OBJECT = Object.freeze({ })
|
||||
@@ -46,6 +51,8 @@ export const propsEqual = (o1, o2, props) => {
|
||||
|
||||
// ===================================================================
|
||||
|
||||
// `subscriptions` can be a function if we want to ensure that the subscription
|
||||
// callbacks have been correctly initialized when there are circular dependencies
|
||||
export const addSubscriptions = subscriptions => Component => {
|
||||
class SubscriptionWrapper extends BaseComponent {
|
||||
constructor () {
|
||||
@@ -55,7 +62,7 @@ export const addSubscriptions = subscriptions => Component => {
|
||||
}
|
||||
|
||||
componentWillMount () {
|
||||
this._unsubscribes = map(subscriptions, (subscribe, prop) =>
|
||||
this._unsubscribes = map(isFunction(subscriptions) ? subscriptions() : subscriptions, (subscribe, prop) =>
|
||||
subscribe(value => this.setState({ [prop]: value }))
|
||||
)
|
||||
}
|
||||
@@ -178,11 +185,12 @@ export const firstDefined = function () {
|
||||
const n = arguments.length
|
||||
for (let i = 0; i < n; ++i) {
|
||||
const arg = arguments[i]
|
||||
if (arg != null) {
|
||||
if (arg !== undefined) {
|
||||
return arg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// Returns the current XOA Plan or the Plan name if number given
|
||||
@@ -207,7 +215,7 @@ export const getXoaPlan = plan => {
|
||||
export const mapPlus = (collection, cb) => {
|
||||
const result = []
|
||||
const push = ::result.push
|
||||
forEach(collection, value => cb(value, push))
|
||||
forEach(collection, (value, index) => cb(value, push, index))
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -220,10 +228,10 @@ export const noop = () => {}
|
||||
export const osFamily = invoke({
|
||||
centos: [ 'centos' ],
|
||||
debian: [ 'debian' ],
|
||||
docker: [ 'coreos' ],
|
||||
fedora: [ 'fedora' ],
|
||||
freebsd: [ 'freebsd' ],
|
||||
gentoo: [ 'gentoo' ],
|
||||
linux: [ 'coreos' ],
|
||||
'linux-mint': [ 'linux-mint' ],
|
||||
netbsd: [ 'netbsd' ],
|
||||
oracle: [ 'oracle' ],
|
||||
@@ -279,7 +287,7 @@ export const normalizeXenToolsStatus = status => {
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const _NotFound = () => <h1>Page not found</h1>
|
||||
const _NotFound = () => <h1>{_('errorPageNotFound')}</h1>
|
||||
|
||||
// Decorator to declare routes on a component.
|
||||
//
|
||||
@@ -355,6 +363,47 @@ export function rethrow (cb) {
|
||||
)
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
|
||||
export const resolveResourceSet = resourceSet => {
|
||||
if (!resourceSet) {
|
||||
return
|
||||
}
|
||||
|
||||
const { objects, ipPools, ...attrs } = resourceSet
|
||||
const resolvedObjects = {}
|
||||
const resolvedSet = {
|
||||
...attrs,
|
||||
missingObjects: [],
|
||||
objectsByType: resolvedObjects,
|
||||
ipPools
|
||||
}
|
||||
const state = store.getState()
|
||||
|
||||
forEach(objects, id => {
|
||||
const object = getObject(state, id, true) // true: useResourceSet to bypass permissions
|
||||
|
||||
// Error, missing resource.
|
||||
if (!object) {
|
||||
resolvedSet.missingObjects.push(id)
|
||||
return
|
||||
}
|
||||
|
||||
const { type } = object
|
||||
|
||||
if (!resolvedObjects[type]) {
|
||||
resolvedObjects[type] = [ object ]
|
||||
} else {
|
||||
resolvedObjects[type].push(object)
|
||||
}
|
||||
})
|
||||
|
||||
return resolvedSet
|
||||
}
|
||||
|
||||
export const resolveResourceSets = resourceSets =>
|
||||
map(resourceSets, resolveResourceSet)
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// Creates a string replacer based on a pattern and a list of rules
|
||||
@@ -380,3 +429,98 @@ export function buildTemplate (pattern, rules) {
|
||||
return isFunction(rule) ? rule(...params) : rule
|
||||
})
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
|
||||
export const streamToString = getStream
|
||||
|
||||
// ===================================================================
|
||||
|
||||
/* global FileReader */
|
||||
|
||||
// Creates a readable stream from a HTML file.
|
||||
export const htmlFileToStream = file => {
|
||||
const reader = new FileReader()
|
||||
const stream = new ReadableStream()
|
||||
let offset = 0
|
||||
|
||||
reader.onloadend = evt => {
|
||||
stream.push(evt.target.result)
|
||||
}
|
||||
reader.onerror = error => {
|
||||
stream.emit('error', error)
|
||||
}
|
||||
|
||||
stream._read = function (size) {
|
||||
if (offset >= file.size) {
|
||||
stream.push(null)
|
||||
} else {
|
||||
reader.readAsBinaryString(file.slice(offset, offset + size))
|
||||
offset += size
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import XoRemoteInput from './xo-remote-input'
|
||||
import XoRoleInput from './xo-role-input'
|
||||
import XoSrInput from './xo-sr-input'
|
||||
import XoSubjectInput from './xo-subject-input'
|
||||
import XoTagInput from './xo-tag-input'
|
||||
import XoVmInput from './xo-vm-input'
|
||||
import { getType, getXoType } from '../json-schema-input/helpers'
|
||||
|
||||
@@ -14,13 +15,14 @@ import { getType, getXoType } from '../json-schema-input/helpers'
|
||||
|
||||
const XO_TYPE_TO_COMPONENT = {
|
||||
host: XoHostInput,
|
||||
xoobject: XoHighLevelObjectInput,
|
||||
pool: XoPoolInput,
|
||||
remote: XoRemoteInput,
|
||||
role: XoRoleInput,
|
||||
sr: XoSrInput,
|
||||
subject: XoSubjectInput,
|
||||
vm: XoVmInput
|
||||
tag: XoTagInput,
|
||||
vm: XoVmInput,
|
||||
xoobject: XoHighLevelObjectInput
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
|
||||
@@ -14,6 +14,7 @@ export default class HighLevelObjectInput extends XoAbstractInput {
|
||||
<PrimitiveInputWrapper {...props}>
|
||||
<SelectHighLevelObject
|
||||
disabled={props.disabled}
|
||||
hasSelectAll
|
||||
multi={props.multi}
|
||||
onChange={props.onChange}
|
||||
ref='input'
|
||||
|
||||
@@ -14,6 +14,7 @@ export default class HostInput extends XoAbstractInput {
|
||||
<PrimitiveInputWrapper {...props}>
|
||||
<SelectHost
|
||||
disabled={props.disabled}
|
||||
hasSelectAll
|
||||
multi={props.multi}
|
||||
onChange={props.onChange}
|
||||
ref='input'
|
||||
|
||||
@@ -14,6 +14,7 @@ export default class PoolInput extends XoAbstractInput {
|
||||
<PrimitiveInputWrapper {...props}>
|
||||
<SelectPool
|
||||
disabled={props.disabled}
|
||||
hasSelectAll
|
||||
multi={props.multi}
|
||||
onChange={props.onChange}
|
||||
ref='input'
|
||||
|
||||
@@ -14,6 +14,7 @@ export default class RemoteInput extends XoAbstractInput {
|
||||
<PrimitiveInputWrapper {...props}>
|
||||
<SelectRemote
|
||||
disabled={props.disabled}
|
||||
hasSelectAll
|
||||
multi={props.multi}
|
||||
onChange={props.onChange}
|
||||
ref='input'
|
||||
|
||||
@@ -14,6 +14,7 @@ export default class RoleInput extends XoAbstractInput {
|
||||
<PrimitiveInputWrapper {...props}>
|
||||
<SelectRole
|
||||
disabled={props.disabled}
|
||||
hasSelectAll
|
||||
multi={props.multi}
|
||||
onChange={props.onChange}
|
||||
ref='input'
|
||||
|
||||
@@ -14,6 +14,7 @@ export default class SrInput extends XoAbstractInput {
|
||||
<PrimitiveInputWrapper {...props}>
|
||||
<SelectSr
|
||||
disabled={props.disabled}
|
||||
hasSelectAll
|
||||
multi={props.multi}
|
||||
onChange={props.onChange}
|
||||
ref='input'
|
||||
|
||||
@@ -14,6 +14,7 @@ export default class SubjectInput extends XoAbstractInput {
|
||||
<PrimitiveInputWrapper {...props}>
|
||||
<SelectSubject
|
||||
disabled={props.disabled}
|
||||
hasSelectAll
|
||||
multi={props.multi}
|
||||
onChange={props.onChange}
|
||||
ref='input'
|
||||
|
||||
27
src/common/xo-json-schema-input/xo-tag-input.js
Normal file
27
src/common/xo-json-schema-input/xo-tag-input.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react'
|
||||
import { SelectTag } from 'select-objects'
|
||||
|
||||
import XoAbstractInput from './xo-abstract-input'
|
||||
import { PrimitiveInputWrapper } from '../json-schema-input/helpers'
|
||||
|
||||
// ===================================================================
|
||||
|
||||
export default class TagInput extends XoAbstractInput {
|
||||
render () {
|
||||
const { props } = this
|
||||
|
||||
return (
|
||||
<PrimitiveInputWrapper {...props}>
|
||||
<SelectTag
|
||||
disabled={props.disabled}
|
||||
hasSelectAll
|
||||
multi={props.multi}
|
||||
onChange={props.onChange}
|
||||
ref='input'
|
||||
required={props.required}
|
||||
defaultValue={props.defaultValue}
|
||||
/>
|
||||
</PrimitiveInputWrapper>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export default class VmInput extends XoAbstractInput {
|
||||
<PrimitiveInputWrapper {...props}>
|
||||
<SelectVm
|
||||
disabled={props.disabled}
|
||||
hasSelectAll
|
||||
multi={props.multi}
|
||||
onChange={props.onChange}
|
||||
ref='input'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.dashedLine {
|
||||
stroke: black;
|
||||
stroke-dasharray: 4px 5px;
|
||||
stroke: black;
|
||||
stroke-dasharray: 4px 2px;
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
@@ -18,7 +25,7 @@ import styles from './index.css'
|
||||
const N_LABELS_X = 5
|
||||
|
||||
const LABEL_OFFSET_X = 40
|
||||
const LABEL_OFFSET_Y = 75
|
||||
const LABEL_OFFSET_Y = 85
|
||||
|
||||
// ===================================================================
|
||||
|
||||
@@ -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
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}))
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
Sparklines,
|
||||
SparklinesLine,
|
||||
SparklinesSpots
|
||||
SparklinesLine
|
||||
} from 'react-sparklines'
|
||||
|
||||
import propTypes from './prop-types'
|
||||
@@ -14,7 +13,8 @@ import {
|
||||
const STYLE = {}
|
||||
|
||||
const WIDTH = 120
|
||||
const HEIGHT = 40
|
||||
const HEIGHT = 20
|
||||
const STROKE_WIDTH = 0.5
|
||||
|
||||
// ===================================================================
|
||||
|
||||
@@ -27,7 +27,7 @@ const templateError =
|
||||
|
||||
export const CpuSparkLines = propTypes({
|
||||
data: propTypes.object.isRequired
|
||||
})(({ data }) => {
|
||||
})(({ data, width = WIDTH, height = HEIGHT, strokeWidth = STROKE_WIDTH }) => {
|
||||
const { cpus } = data.stats
|
||||
|
||||
if (!cpus) {
|
||||
@@ -35,16 +35,15 @@ export const CpuSparkLines = propTypes({
|
||||
}
|
||||
|
||||
return (
|
||||
<Sparklines style={STYLE} data={computeArraysAvg(cpus)} max={100} min={0} width={WIDTH} height={HEIGHT}>
|
||||
<SparklinesLine style={{ strokeWidth: 1, stroke: '#366e98', fill: '#366e98', fillOpacity: 0.5 }} color='#2598d9' />
|
||||
<SparklinesSpots />
|
||||
<Sparklines style={STYLE} data={computeArraysAvg(cpus)} max={100} min={0} width={width} height={height}>
|
||||
<SparklinesLine style={{ strokeWidth, stroke: '#366e98', fill: '#366e98', fillOpacity: 0.5 }} color='#2598d9' />
|
||||
</Sparklines>
|
||||
)
|
||||
})
|
||||
|
||||
export const MemorySparkLines = propTypes({
|
||||
data: propTypes.object.isRequired
|
||||
})(({ data }) => {
|
||||
})(({ data, width = WIDTH, height = HEIGHT, strokeWidth = STROKE_WIDTH }) => {
|
||||
const { memory, memoryUsed } = data.stats
|
||||
|
||||
if (!memory || !memoryUsed) {
|
||||
@@ -52,16 +51,15 @@ export const MemorySparkLines = propTypes({
|
||||
}
|
||||
|
||||
return (
|
||||
<Sparklines style={STYLE} data={memoryUsed} max={memory[memory.length - 1]} min={0} width={WIDTH} height={HEIGHT}>
|
||||
<SparklinesLine style={{ strokeWidth: 1, stroke: '#990822', fill: '#990822', fillOpacity: 0.5 }} color='#cc0066' />
|
||||
<SparklinesSpots />
|
||||
<Sparklines style={STYLE} data={memoryUsed} max={memory[memory.length - 1]} min={0} width={width} height={height}>
|
||||
<SparklinesLine style={{ strokeWidth, stroke: '#990822', fill: '#990822', fillOpacity: 0.5 }} color='#cc0066' />
|
||||
</Sparklines>
|
||||
)
|
||||
})
|
||||
|
||||
export const XvdSparkLines = propTypes({
|
||||
data: propTypes.object.isRequired
|
||||
})(({ data }) => {
|
||||
})(({ data, width = WIDTH, height = HEIGHT, strokeWidth = STROKE_WIDTH }) => {
|
||||
const { xvds } = data.stats
|
||||
|
||||
if (!xvds) {
|
||||
@@ -69,16 +67,15 @@ export const XvdSparkLines = propTypes({
|
||||
}
|
||||
|
||||
return (
|
||||
<Sparklines style={STYLE} data={computeObjectsAvg(xvds)} min={0} width={WIDTH} height={HEIGHT}>
|
||||
<SparklinesLine style={{ strokeWidth: 1, stroke: '#089944', fill: '#089944', fillOpacity: 0.5 }} color='#33cc33' />
|
||||
<SparklinesSpots />
|
||||
<Sparklines style={STYLE} data={computeObjectsAvg(xvds)} min={0} width={width} height={height}>
|
||||
<SparklinesLine style={{ strokeWidth, stroke: '#089944', fill: '#089944', fillOpacity: 0.5 }} color='#33cc33' />
|
||||
</Sparklines>
|
||||
)
|
||||
})
|
||||
|
||||
export const VifSparkLines = propTypes({
|
||||
data: propTypes.object.isRequired
|
||||
})(({ data }) => {
|
||||
})(({ data, width = WIDTH, height = HEIGHT, strokeWidth = STROKE_WIDTH }) => {
|
||||
const { vifs } = data.stats
|
||||
|
||||
if (!vifs) {
|
||||
@@ -86,16 +83,15 @@ export const VifSparkLines = propTypes({
|
||||
}
|
||||
|
||||
return (
|
||||
<Sparklines style={STYLE} data={computeObjectsAvg(vifs)} min={0} width={WIDTH} height={HEIGHT}>
|
||||
<SparklinesLine style={{ strokeWidth: 1, stroke: '#eca649', fill: '#eca649', fillOpacity: 0.5 }} color='#ffd633' />
|
||||
<SparklinesSpots />
|
||||
<Sparklines style={STYLE} data={computeObjectsAvg(vifs)} min={0} width={width} height={height}>
|
||||
<SparklinesLine style={{ strokeWidth, stroke: '#eca649', fill: '#eca649', fillOpacity: 0.5 }} color='#ffd633' />
|
||||
</Sparklines>
|
||||
)
|
||||
})
|
||||
|
||||
export const PifSparkLines = propTypes({
|
||||
data: propTypes.object.isRequired
|
||||
})(({ data }) => {
|
||||
})(({ data, width = WIDTH, height = HEIGHT, strokeWidth = STROKE_WIDTH }) => {
|
||||
const { pifs } = data.stats
|
||||
|
||||
if (!pifs) {
|
||||
@@ -103,16 +99,15 @@ export const PifSparkLines = propTypes({
|
||||
}
|
||||
|
||||
return (
|
||||
<Sparklines style={STYLE} data={computeObjectsAvg(pifs)} min={0} width={WIDTH} height={HEIGHT}>
|
||||
<SparklinesLine style={{ strokeWidth: 1, stroke: '#eca649', fill: '#eca649', fillOpacity: 0.5 }} color='#ffd633' />
|
||||
<SparklinesSpots />
|
||||
<Sparklines style={STYLE} data={computeObjectsAvg(pifs)} min={0} width={width} height={height}>
|
||||
<SparklinesLine style={{ strokeWidth, stroke: '#eca649', fill: '#eca649', fillOpacity: 0.5 }} color='#ffd633' />
|
||||
</Sparklines>
|
||||
)
|
||||
})
|
||||
|
||||
export const LoadSparkLines = propTypes({
|
||||
data: propTypes.object.isRequired
|
||||
})(({ data }) => {
|
||||
})(({ data, width = WIDTH, height = HEIGHT, strokeWidth = STROKE_WIDTH }) => {
|
||||
const { load } = data.stats
|
||||
|
||||
if (!load) {
|
||||
@@ -120,9 +115,8 @@ export const LoadSparkLines = propTypes({
|
||||
}
|
||||
|
||||
return (
|
||||
<Sparklines style={STYLE} data={load} min={0} width={WIDTH} height={HEIGHT}>
|
||||
<SparklinesLine style={{ strokeWidth: 1, stroke: '#33cc33', fill: '#33cc33', fillOpacity: 0.5 }} color='#33cc33' />
|
||||
<SparklinesSpots />
|
||||
<Sparklines style={STYLE} data={load} min={0} width={width} height={height}>
|
||||
<SparklinesLine style={{ strokeWidth, stroke: '#33cc33', fill: '#33cc33', fillOpacity: 0.5 }} color='#33cc33' />
|
||||
</Sparklines>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -34,6 +34,9 @@ const _computeArraysSum = arrays => {
|
||||
const _computeArraysAvg = arrays => {
|
||||
const sums = _computeArraysSum(arrays)
|
||||
|
||||
if (!arrays[0]) {
|
||||
return []
|
||||
}
|
||||
const n = arrays && arrays[0].length
|
||||
const m = arrays.length
|
||||
|
||||
|
||||
@@ -378,7 +378,7 @@ export default class XoWeekCharts extends Component {
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<p className='m-t-1'>
|
||||
<p className='mt-1'>
|
||||
{_('weeklyChartsScaleInfo')}
|
||||
{' '}
|
||||
<Toggle iconSize={1} icon='scale' className='btn btn-secondary' onChange={this._updateScale} />
|
||||
|
||||
@@ -165,15 +165,17 @@ export default class XoWeekHeatmap extends Component {
|
||||
<th><FormattedTime value={day.timestamp} {...DAY_TIME_FORMAT} /></th>
|
||||
{map(day.hours, (hour, key) => (
|
||||
<Tooltip
|
||||
className={styles.cell}
|
||||
key={key}
|
||||
style={{ background: hour ? hour.color : '#ffffff' }}
|
||||
tagName='td'
|
||||
content={hour
|
||||
? _('weekHeatmapData', { date: hour.date, value: this.props.cellRenderer(hour.value) })
|
||||
: _('weekHeatmapNoData')
|
||||
}
|
||||
/>
|
||||
key={key}
|
||||
>
|
||||
<td
|
||||
className={styles.cell}
|
||||
style={{ background: hour ? hour.color : '#ffffff' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
|
||||
59
src/common/xo/add-host-modal/index.js
Normal file
59
src/common/xo/add-host-modal/index.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import _ from 'intl'
|
||||
import BaseComponent from 'base-component'
|
||||
import React from 'react'
|
||||
import SingleLineRow from 'single-line-row'
|
||||
import { Col } from 'grid'
|
||||
import { connectStore } from 'utils'
|
||||
import { createCollectionWrapper, createGetObjectsOfType, createSelector } from 'selectors'
|
||||
import { forEach } from 'lodash'
|
||||
import { SelectHost } from 'select-objects'
|
||||
|
||||
@connectStore(() => ({
|
||||
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
|
||||
}
|
||||
|
||||
_getHostPredicate = createSelector(
|
||||
() => this.props.singleHosts,
|
||||
singleHosts => host => singleHosts[host.id]
|
||||
)
|
||||
|
||||
render () {
|
||||
return <div>
|
||||
<SingleLineRow>
|
||||
<Col size={6}>{_('addHostSelectHost')}</Col>
|
||||
<Col size={6}>
|
||||
<SelectHost
|
||||
onChange={this.linkState('host')}
|
||||
predicate={this._getHostPredicate()}
|
||||
value={this.state.host}
|
||||
/>
|
||||
</Col>
|
||||
</SingleLineRow>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
61
src/common/xo/add-user-filter-modal/index.js
Normal file
61
src/common/xo/add-user-filter-modal/index.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import keys from 'lodash/keys'
|
||||
import React from 'react'
|
||||
|
||||
import * as FormGrid from '../../form-grid'
|
||||
import _ from '../../intl'
|
||||
import Combobox from '../../combobox'
|
||||
import Component from '../../base-component'
|
||||
import propTypes from '../../prop-types'
|
||||
import { createSelector } from '../../selectors'
|
||||
|
||||
@propTypes({
|
||||
type: propTypes.string.isRequired,
|
||||
user: propTypes.object.isRequired,
|
||||
value: propTypes.string.isRequired
|
||||
})
|
||||
export default class SaveNewUserFilterModalBody extends Component {
|
||||
get value () {
|
||||
return this.state.name || ''
|
||||
}
|
||||
|
||||
_getFilterOptions = createSelector(
|
||||
tmp => (
|
||||
(tmp = this.props.user) &&
|
||||
(tmp = tmp.preferences) &&
|
||||
(tmp = tmp.filters) &&
|
||||
tmp[this.props.type]
|
||||
),
|
||||
keys
|
||||
)
|
||||
|
||||
render () {
|
||||
const { value } = this.props
|
||||
const options = this._getFilterOptions()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FormGrid.Row>
|
||||
<FormGrid.LabelCol>{_('filterName')}</FormGrid.LabelCol>
|
||||
<FormGrid.InputCol>
|
||||
<Combobox
|
||||
onChange={this.linkState('name')}
|
||||
options={options}
|
||||
value={this.state.name || ''}
|
||||
/>
|
||||
</FormGrid.InputCol>
|
||||
</FormGrid.Row>
|
||||
<FormGrid.Row>
|
||||
<FormGrid.LabelCol>{_('filterValue')}</FormGrid.LabelCol>
|
||||
<FormGrid.InputCol>
|
||||
<input
|
||||
className='form-control'
|
||||
disabled
|
||||
type='text'
|
||||
value={value}
|
||||
/>
|
||||
</FormGrid.InputCol>
|
||||
</FormGrid.Row>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import { Toggle } from '../../form'
|
||||
import { injectIntl } from 'react-intl'
|
||||
|
||||
class CopyVmModalBody extends Component {
|
||||
state = { compress: false }
|
||||
|
||||
get value () {
|
||||
const { state } = this
|
||||
return {
|
||||
|
||||
109
src/common/xo/create-bonded-network-modal/index.js
Normal file
109
src/common/xo/create-bonded-network-modal/index.js
Normal file
@@ -0,0 +1,109 @@
|
||||
import Component from 'base-component'
|
||||
import map from 'lodash/map'
|
||||
import React from 'react'
|
||||
import { createGetObject, createSelector } from 'selectors'
|
||||
import { getBondModes } from 'xo'
|
||||
import { injectIntl } from 'react-intl'
|
||||
|
||||
import _, { messages } from '../../intl'
|
||||
import { Col } from '../../grid'
|
||||
import { connectStore } from '../../utils'
|
||||
import { SelectPif } from '../../select-objects'
|
||||
import SingleLineRow from '../../single-line-row'
|
||||
|
||||
@connectStore(() => ({
|
||||
poolMaster: createSelector(
|
||||
createGetObject(
|
||||
(_, props) => props.pool
|
||||
),
|
||||
pool => pool.master
|
||||
)
|
||||
}), { withRef: true })
|
||||
class CreateBondedNetworkModalBody extends Component {
|
||||
componentWillMount () {
|
||||
getBondModes().then(
|
||||
bondModes => this.setState({ bondModes, bondMode: bondModes[0] })
|
||||
)
|
||||
}
|
||||
|
||||
_getPifPredicate = createSelector(
|
||||
() => this.props.poolMaster,
|
||||
hostId => pif =>
|
||||
pif.$host === hostId && pif.vlan === -1
|
||||
)
|
||||
|
||||
get value () {
|
||||
const { name, description, pifs, mtu, bondMode } = this.state
|
||||
return {
|
||||
pool: this.props.pool,
|
||||
name,
|
||||
description,
|
||||
pifs: map(pifs, pif => pif.id),
|
||||
mtu,
|
||||
bondMode
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const { formatMessage } = this.props.intl
|
||||
return <div>
|
||||
<SingleLineRow>
|
||||
<Col size={6}>{_('newNetworkInterface')}</Col>
|
||||
<Col size={6}>
|
||||
<SelectPif
|
||||
multi
|
||||
onChange={this.linkState('pifs')}
|
||||
predicate={this._getPifPredicate()}
|
||||
/>
|
||||
</Col>
|
||||
</SingleLineRow>
|
||||
|
||||
<SingleLineRow>
|
||||
<Col size={6}>{_('newNetworkName')}</Col>
|
||||
<Col size={6}>
|
||||
<input
|
||||
className='form-control'
|
||||
onChange={this.linkState('name')}
|
||||
type='text'
|
||||
/>
|
||||
</Col>
|
||||
</SingleLineRow>
|
||||
|
||||
<SingleLineRow>
|
||||
<Col size={6}>{_('newNetworkDescription')}</Col>
|
||||
<Col size={6}>
|
||||
<input
|
||||
className='form-control'
|
||||
onChange={this.linkState('description')}
|
||||
type='text'
|
||||
/>
|
||||
</Col>
|
||||
</SingleLineRow>
|
||||
|
||||
<SingleLineRow>
|
||||
<Col size={6}>{_('newNetworkMtu')}</Col>
|
||||
<Col size={6}>
|
||||
<input
|
||||
className='form-control'
|
||||
onChange={this.linkState('mtu')}
|
||||
placeholder={formatMessage(messages.newNetworkDefaultMtu)}
|
||||
type='text'
|
||||
/>
|
||||
</Col>
|
||||
</SingleLineRow>
|
||||
|
||||
<SingleLineRow>
|
||||
<Col size={6}>{_('newNetworkBondMode')}</Col>
|
||||
<Col size={6}>
|
||||
<select
|
||||
className='form-control'
|
||||
onChange={this.linkState('bondMode')}
|
||||
>
|
||||
{map(this.state.bondModes, mode => <option value={mode}>{mode}</option>)}
|
||||
</select>
|
||||
</Col>
|
||||
</SingleLineRow>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
export default injectIntl(CreateBondedNetworkModalBody, { withRef: true })
|
||||
@@ -20,7 +20,7 @@ class CreateNetworkModalBody extends Component {
|
||||
const { refs } = this
|
||||
const { container } = this.props
|
||||
return {
|
||||
pool: container === 'pool' ? container.id : container.$pool,
|
||||
pool: container.$pool,
|
||||
name: refs.name.value,
|
||||
description: refs.description.value,
|
||||
pif: refs.pif.value.id,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
66
src/common/xo/install-xosan-pack-modal/index.js
Normal file
66
src/common/xo/install-xosan-pack-modal/index.js
Normal file
@@ -0,0 +1,66 @@
|
||||
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 } from 'selectors'
|
||||
import {
|
||||
filter,
|
||||
forEach,
|
||||
map
|
||||
} from 'lodash'
|
||||
|
||||
const findLatestPack = packs => {
|
||||
let latestPack = packs[0]
|
||||
|
||||
forEach(packs, pack => {
|
||||
if (compareVersions(pack.version, latestPack.version) > 0) {
|
||||
latestPack = pack
|
||||
}
|
||||
})
|
||||
|
||||
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,
|
||||
xosanCatalog => findLatestPack(
|
||||
filter(xosanCatalog, (value, key) => key !== '_token' && value.type === 'iso')
|
||||
)
|
||||
)
|
||||
|
||||
get value () {
|
||||
return this._getXosanLatestPack()
|
||||
}
|
||||
|
||||
render () {
|
||||
const { hosts } = this.props
|
||||
const latestPack = this._getXosanLatestPack()
|
||||
|
||||
return <div>
|
||||
{_('xosanInstallPackOnHosts')}
|
||||
<ul>
|
||||
{map(hosts, host => <li key={host.id}>{host.name_label}</li>)}
|
||||
</ul>
|
||||
{latestPack && <div className='mt-1'>
|
||||
{_('xosanInstallPack', { pack: latestPack.name, version: latestPack.version })}
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import BaseComponent from 'base-component'
|
||||
import every from 'lodash/every'
|
||||
import forEach from 'lodash/forEach'
|
||||
import find from 'lodash/find'
|
||||
import map from 'lodash/map'
|
||||
import mapValues from 'lodash/mapValues'
|
||||
import React from 'react'
|
||||
import store from 'store'
|
||||
|
||||
import _ from '../../intl'
|
||||
import invoke from '../../invoke'
|
||||
@@ -22,8 +24,12 @@ import {
|
||||
import {
|
||||
createGetObjectsOfType,
|
||||
createPicker,
|
||||
createSelector
|
||||
createSelector,
|
||||
getObject
|
||||
} from '../../selectors'
|
||||
import {
|
||||
isSrShared
|
||||
} from 'xo'
|
||||
|
||||
import { isSrWritable } from '../'
|
||||
|
||||
@@ -59,6 +65,7 @@ import styles from './index.css'
|
||||
networks: getNetworks,
|
||||
pifs: getPifs,
|
||||
pools: getPools,
|
||||
vbds: getVbds,
|
||||
vdis: getVdis,
|
||||
vifs: getVifs
|
||||
}
|
||||
@@ -85,7 +92,26 @@ export default class MigrateVmModalBody extends BaseComponent {
|
||||
)
|
||||
)
|
||||
|
||||
this._getNetworkPredicate = createSelector(
|
||||
this._getTargetNetworkPredicate = createSelector(
|
||||
createPicker(
|
||||
() => this.props.pifs,
|
||||
() => this.state.host.$PIFs
|
||||
),
|
||||
pifs => {
|
||||
if (!pifs) {
|
||||
return false
|
||||
}
|
||||
|
||||
const networks = {}
|
||||
forEach(pifs, pif => {
|
||||
networks[pif.$network] = true
|
||||
})
|
||||
|
||||
return network => networks[network.id]
|
||||
}
|
||||
)
|
||||
|
||||
this._getMigrationNetworkPredicate = createSelector(
|
||||
createPicker(
|
||||
() => this.props.pifs,
|
||||
() => this.state.host.$PIFs
|
||||
@@ -118,7 +144,12 @@ export default class MigrateVmModalBody extends BaseComponent {
|
||||
}
|
||||
}
|
||||
|
||||
_getObject (id) {
|
||||
return getObject(store.getState(), id)
|
||||
}
|
||||
|
||||
_selectHost = host => {
|
||||
// No host selected
|
||||
if (!host) {
|
||||
this.setState({
|
||||
host: undefined,
|
||||
@@ -126,20 +157,40 @@ export default class MigrateVmModalBody extends BaseComponent {
|
||||
})
|
||||
return
|
||||
}
|
||||
const intraPool = this.props.vm.$pool === host.$pool
|
||||
|
||||
const { pools, vbds, vdis, vm } = this.props
|
||||
const intraPool = vm.$pool === host.$pool
|
||||
|
||||
// Intra-pool
|
||||
const defaultSr = pools[host.$pool].default_SR
|
||||
if (intraPool) {
|
||||
let doNotMigrateVdis
|
||||
if (vm.$container === host.id) {
|
||||
doNotMigrateVdis = true
|
||||
} else {
|
||||
const _doNotMigrateVdi = {}
|
||||
forEach(vbds, vbd => {
|
||||
if (vbd.VDI != null) {
|
||||
_doNotMigrateVdi[vbd.VDI] = isSrShared(this._getObject(this._getObject(vbd.VDI).$SR))
|
||||
}
|
||||
})
|
||||
doNotMigrateVdis = every(_doNotMigrateVdi)
|
||||
}
|
||||
|
||||
this.setState({
|
||||
doNotMigrateVdis,
|
||||
host,
|
||||
intraPool,
|
||||
mapVdisSrs: undefined,
|
||||
mapVdisSrs: doNotMigrateVdis ? undefined : mapValues(vdis, vdi => defaultSr),
|
||||
mapVifsNetworks: undefined,
|
||||
migrationNetwork: undefined
|
||||
})
|
||||
return
|
||||
}
|
||||
const { networks, pools, pifs, vdis, vifs } = this.props
|
||||
|
||||
// Inter-pool
|
||||
const { networks, pifs, vifs } = this.props
|
||||
const defaultMigrationNetworkId = find(pifs, pif => pif.$host === host.id && pif.management).$network
|
||||
const defaultSr = pools[host.$pool].default_SR
|
||||
|
||||
const defaultNetwork = invoke(() => {
|
||||
// First PIF with an IP.
|
||||
@@ -158,6 +209,7 @@ export default class MigrateVmModalBody extends BaseComponent {
|
||||
})
|
||||
|
||||
this.setState({
|
||||
doNotMigrateVdis: false,
|
||||
host,
|
||||
intraPool,
|
||||
mapVdisSrs: mapValues(vdis, vdi => defaultSr),
|
||||
@@ -171,6 +223,7 @@ export default class MigrateVmModalBody extends BaseComponent {
|
||||
render () {
|
||||
const { vdis, vifs, networks } = this.props
|
||||
const {
|
||||
doNotMigrateVdis,
|
||||
host,
|
||||
intraPool,
|
||||
mapVdisSrs,
|
||||
@@ -190,6 +243,28 @@ export default class MigrateVmModalBody extends BaseComponent {
|
||||
</Col>
|
||||
</SingleLineRow>
|
||||
</div>
|
||||
{host && !doNotMigrateVdis && <div className={styles.groupBlock}>
|
||||
<SingleLineRow>
|
||||
<Col>{_('migrateVmSelectSrs')}</Col>
|
||||
</SingleLineRow>
|
||||
<br />
|
||||
<SingleLineRow>
|
||||
<Col size={6}><span className={styles.listTitle}>{_('migrateVmName')}</span></Col>
|
||||
<Col size={6}><span className={styles.listTitle}>{_('migrateVmSr')}</span></Col>
|
||||
</SingleLineRow>
|
||||
{map(vdis, vdi => <div className={styles.listItem} key={vdi.id}>
|
||||
<SingleLineRow>
|
||||
<Col size={6}>{vdi.name_label}</Col>
|
||||
<Col size={6}>
|
||||
<SelectSr
|
||||
onChange={sr => this.setState({ mapVdisSrs: { ...mapVdisSrs, [vdi.id]: sr.id } })}
|
||||
predicate={this._getSrPredicate()}
|
||||
value={mapVdisSrs[vdi.id]}
|
||||
/>
|
||||
</Col>
|
||||
</SingleLineRow>
|
||||
</div>)}
|
||||
</div>}
|
||||
{intraPool !== undefined &&
|
||||
(!intraPool &&
|
||||
<div>
|
||||
@@ -199,34 +274,12 @@ export default class MigrateVmModalBody extends BaseComponent {
|
||||
<Col size={6}>
|
||||
<SelectNetwork
|
||||
onChange={this._selectMigrationNetwork}
|
||||
predicate={this._getNetworkPredicate()}
|
||||
predicate={this._getMigrationNetworkPredicate()}
|
||||
value={migrationNetworkId}
|
||||
/>
|
||||
</Col>
|
||||
</SingleLineRow>
|
||||
</div>
|
||||
<div className={styles.groupBlock}>
|
||||
<SingleLineRow>
|
||||
<Col>{_('migrateVmSelectSrs')}</Col>
|
||||
</SingleLineRow>
|
||||
<br />
|
||||
<SingleLineRow>
|
||||
<Col size={6}><span className={styles.listTitle}>{_('migrateVmName')}</span></Col>
|
||||
<Col size={6}><span className={styles.listTitle}>{_('migrateVmSr')}</span></Col>
|
||||
</SingleLineRow>
|
||||
{map(vdis, vdi => <div className={styles.listItem} key={vdi.id}>
|
||||
<SingleLineRow>
|
||||
<Col size={6}>{vdi.name_label}</Col>
|
||||
<Col size={6}>
|
||||
<SelectSr
|
||||
onChange={sr => this.setState({ mapVdisSrs: { ...mapVdisSrs, [vdi.id]: sr.id } })}
|
||||
predicate={this._getSrPredicate()}
|
||||
value={mapVdisSrs[vdi.id]}
|
||||
/>
|
||||
</Col>
|
||||
</SingleLineRow>
|
||||
</div>)}
|
||||
</div>
|
||||
<div className={styles.groupBlock}>
|
||||
<SingleLineRow>
|
||||
<Col>{_('migrateVmSelectNetworks')}</Col>
|
||||
@@ -242,7 +295,7 @@ export default class MigrateVmModalBody extends BaseComponent {
|
||||
<Col size={6}>
|
||||
<SelectNetwork
|
||||
onChange={network => this.setState({ mapVifsNetworks: { ...mapVifsNetworks, [vif.id]: network.id } })}
|
||||
predicate={this._getNetworkPredicate()}
|
||||
predicate={this._getTargetNetworkPredicate()}
|
||||
value={mapVifsNetworks[vif.id]}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
@@ -2,11 +2,14 @@ import BaseComponent from 'base-component'
|
||||
import every from 'lodash/every'
|
||||
import flatten from 'lodash/flatten'
|
||||
import forEach from 'lodash/forEach'
|
||||
import filter from 'lodash/filter'
|
||||
import find from 'lodash/find'
|
||||
import isEmpty from 'lodash/isEmpty'
|
||||
import map from 'lodash/map'
|
||||
import mapValues from 'lodash/mapValues'
|
||||
import React from 'react'
|
||||
import some from 'lodash/some'
|
||||
import store from 'store'
|
||||
|
||||
import _ from '../../intl'
|
||||
import invoke from '../../invoke'
|
||||
@@ -24,8 +27,12 @@ import {
|
||||
import {
|
||||
createGetObjectsOfType,
|
||||
createPicker,
|
||||
createSelector
|
||||
createSelector,
|
||||
getObject
|
||||
} from '../../selectors'
|
||||
import {
|
||||
isSrShared
|
||||
} from 'xo'
|
||||
|
||||
import { isSrWritable } from '../'
|
||||
|
||||
@@ -80,7 +87,26 @@ export default class MigrateVmsModalBody extends BaseComponent {
|
||||
)
|
||||
)
|
||||
|
||||
this._getNetworkPredicate = createSelector(
|
||||
this._getTargetNetworkPredicate = createSelector(
|
||||
createPicker(
|
||||
() => this.props.pifs,
|
||||
() => this.state.host.$PIFs
|
||||
),
|
||||
pifs => {
|
||||
if (!pifs) {
|
||||
return false
|
||||
}
|
||||
|
||||
const networks = {}
|
||||
forEach(pifs, pif => {
|
||||
networks[pif.$network] = true
|
||||
})
|
||||
|
||||
return network => networks[network.id]
|
||||
}
|
||||
)
|
||||
|
||||
this._getMigrationNetworkPredicate = createSelector(
|
||||
createPicker(
|
||||
() => this.props.pifs,
|
||||
() => this.state.host.$PIFs
|
||||
@@ -105,8 +131,8 @@ export default class MigrateVmsModalBody extends BaseComponent {
|
||||
}
|
||||
|
||||
get value () {
|
||||
const { vms } = this.props
|
||||
const { host } = this.state
|
||||
const vms = filter(this.props.vms, vm => vm.$container !== host.id)
|
||||
if (!host || isEmpty(vms)) {
|
||||
return { vms }
|
||||
}
|
||||
@@ -117,6 +143,9 @@ export default class MigrateVmsModalBody extends BaseComponent {
|
||||
vifsByVm
|
||||
} = this.props
|
||||
const {
|
||||
intraPool,
|
||||
doNotMigrateVdi,
|
||||
doNotMigrateVmVdis,
|
||||
migrationNetworkId,
|
||||
networkId,
|
||||
smartVifMapping,
|
||||
@@ -126,10 +155,14 @@ export default class MigrateVmsModalBody extends BaseComponent {
|
||||
// Map VM --> ( Map VDI --> SR )
|
||||
const mapVmsMapVdisSrs = {}
|
||||
forEach(vbdsByVm, (vbds, vm) => {
|
||||
if (doNotMigrateVmVdis[vm]) {
|
||||
return
|
||||
}
|
||||
const mapVdisSrs = {}
|
||||
forEach(vbds, vbd => {
|
||||
if (!vbd.is_cd_drive && vbd.VDI) {
|
||||
mapVdisSrs[vbd.VDI] = srId
|
||||
const vdi = vbd.VDI
|
||||
if (!vbd.is_cd_drive && vdi) {
|
||||
mapVdisSrs[vdi] = intraPool && doNotMigrateVdi[vdi] ? this._getObject(vdi).SR : srId
|
||||
}
|
||||
})
|
||||
mapVmsMapVdisSrs[vm] = mapVdisSrs
|
||||
@@ -146,6 +179,9 @@ export default class MigrateVmsModalBody extends BaseComponent {
|
||||
// Map VM --> ( Map VIF --> network )
|
||||
const mapVmsMapVifsNetworks = {}
|
||||
forEach(vms, vm => {
|
||||
if (vm.$pool === host.$pool) {
|
||||
return
|
||||
}
|
||||
const mapVifsNetworks = {}
|
||||
forEach(vifsByVm[vm.id], vif => {
|
||||
mapVifsNetworks[vif.id] = smartVifMapping
|
||||
@@ -154,15 +190,25 @@ export default class MigrateVmsModalBody extends BaseComponent {
|
||||
})
|
||||
mapVmsMapVifsNetworks[vm.id] = mapVifsNetworks
|
||||
})
|
||||
|
||||
// Map VM --> migration network
|
||||
const mapVmsMigrationNetwork = mapValues(doNotMigrateVmVdis, doNotMigrateVdis =>
|
||||
doNotMigrateVdis ? undefined : migrationNetworkId
|
||||
)
|
||||
|
||||
return {
|
||||
mapVmsMapVdisSrs,
|
||||
mapVmsMapVifsNetworks,
|
||||
migrationNetwork: migrationNetworkId,
|
||||
mapVmsMigrationNetwork,
|
||||
targetHost: host.id,
|
||||
vms
|
||||
}
|
||||
}
|
||||
|
||||
_getObject (id) {
|
||||
return getObject(store.getState(), id)
|
||||
}
|
||||
|
||||
_selectHost = host => {
|
||||
if (!host) {
|
||||
this.setState({ targetHost: undefined })
|
||||
@@ -171,11 +217,30 @@ export default class MigrateVmsModalBody extends BaseComponent {
|
||||
const { pools, pifs } = this.props
|
||||
const defaultMigrationNetworkId = find(pifs, pif => pif.$host === host.id && pif.management).$network
|
||||
const defaultSrId = pools[host.$pool].default_SR
|
||||
const doNotMigrateVmVdis = {}
|
||||
const doNotMigrateVdi = {}
|
||||
forEach(this.props.vbdsByVm, (vbds, vm) => {
|
||||
if (this._getObject(vm).$container === host.id) {
|
||||
doNotMigrateVmVdis[vm] = true
|
||||
return
|
||||
}
|
||||
const _doNotMigrateVdi = {}
|
||||
forEach(vbds, vbd => {
|
||||
if (vbd.VDI != null) {
|
||||
doNotMigrateVdi[vbd.VDI] = _doNotMigrateVdi[vbd.VDI] = isSrShared(this._getObject(this._getObject(vbd.VDI).$SR))
|
||||
}
|
||||
})
|
||||
doNotMigrateVmVdis[vm] = every(_doNotMigrateVdi)
|
||||
})
|
||||
const noVdisMigration = every(doNotMigrateVmVdis)
|
||||
this.setState({
|
||||
host,
|
||||
intraPool: every(this.props.vms, vm => vm.$pool === host.$pool),
|
||||
doNotMigrateVdi,
|
||||
doNotMigrateVmVdis,
|
||||
migrationNetworkId: defaultMigrationNetworkId,
|
||||
networkId: defaultMigrationNetworkId,
|
||||
noVdisMigration,
|
||||
smartVifMapping: true,
|
||||
srId: defaultSrId
|
||||
})
|
||||
@@ -191,6 +256,7 @@ export default class MigrateVmsModalBody extends BaseComponent {
|
||||
intraPool,
|
||||
migrationNetworkId,
|
||||
networkId,
|
||||
noVdisMigration,
|
||||
smartVifMapping,
|
||||
srId
|
||||
} = this.state
|
||||
@@ -214,17 +280,17 @@ export default class MigrateVmsModalBody extends BaseComponent {
|
||||
<Col size={6}>
|
||||
<SelectNetwork
|
||||
onChange={this._selectMigrationNetwork}
|
||||
predicate={this._getNetworkPredicate()}
|
||||
predicate={this._getMigrationNetworkPredicate()}
|
||||
value={migrationNetworkId}
|
||||
/>
|
||||
</Col>
|
||||
</SingleLineRow>
|
||||
</div>
|
||||
}
|
||||
{host && [
|
||||
{host && (!intraPool || !noVdisMigration) &&
|
||||
<div key='sr' style={LINE_STYLE}>
|
||||
<SingleLineRow>
|
||||
<Col size={6}>{_('migrateVmsSelectSr')}</Col>
|
||||
<Col size={6}>{!intraPool ? _('migrateVmsSelectSr') : _('migrateVmsSelectSrIntraPool')}</Col>
|
||||
<Col size={6}>
|
||||
<SelectSr
|
||||
onChange={this._selectSr}
|
||||
@@ -233,7 +299,9 @@ export default class MigrateVmsModalBody extends BaseComponent {
|
||||
/>
|
||||
</Col>
|
||||
</SingleLineRow>
|
||||
</div>,
|
||||
</div>
|
||||
}
|
||||
{host && !intraPool &&
|
||||
<div key='network' style={LINE_STYLE}>
|
||||
<SingleLineRow>
|
||||
<Col size={6}>{_('migrateVmsSelectNetwork')}</Col>
|
||||
@@ -241,7 +309,7 @@ export default class MigrateVmsModalBody extends BaseComponent {
|
||||
<SelectNetwork
|
||||
disabled={smartVifMapping}
|
||||
onChange={this._selectNetwork}
|
||||
predicate={this._getNetworkPredicate()}
|
||||
predicate={this._getTargetNetworkPredicate()}
|
||||
value={networkId}
|
||||
/>
|
||||
</Col>
|
||||
@@ -254,7 +322,7 @@ export default class MigrateVmsModalBody extends BaseComponent {
|
||||
</Col>
|
||||
</SingleLineRow>
|
||||
</div>
|
||||
]}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
58
src/common/xo/new-ssh-key-modal/index.js
Normal file
58
src/common/xo/new-ssh-key-modal/index.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import BaseComponent from 'base-component'
|
||||
import React from 'react'
|
||||
|
||||
import _ from '../../intl'
|
||||
import SingleLineRow from '../../single-line-row'
|
||||
import { Col } from '../../grid'
|
||||
import getEventValue from '../../get-event-value'
|
||||
|
||||
export default class NewSshKeyModalBody extends BaseComponent {
|
||||
get value () {
|
||||
return this.state
|
||||
}
|
||||
|
||||
_onKeyChange = event => {
|
||||
const key = getEventValue(event)
|
||||
const splitKey = key.split(' ')
|
||||
if (!this.state.title && splitKey.length === 3) {
|
||||
this.setState({ title: splitKey[2].split('\n')[0] })
|
||||
}
|
||||
this.setState({ key })
|
||||
}
|
||||
|
||||
render () {
|
||||
const {
|
||||
key,
|
||||
title
|
||||
} = this.state
|
||||
|
||||
return <div>
|
||||
<div className='pb-1'>
|
||||
<SingleLineRow>
|
||||
<Col size={4}>{_('title')}</Col>
|
||||
<Col size={8}>
|
||||
<input
|
||||
className='form-control'
|
||||
onChange={this.linkState('title')}
|
||||
type='text'
|
||||
value={title || ''}
|
||||
/>
|
||||
</Col>
|
||||
</SingleLineRow>
|
||||
</div>
|
||||
<div className='pb-1'>
|
||||
<SingleLineRow>
|
||||
<Col size={4}>{_('key')}</Col>
|
||||
<Col size={8}>
|
||||
<textarea
|
||||
className='form-control'
|
||||
onChange={this._onKeyChange}
|
||||
rows={10}
|
||||
value={key || ''}
|
||||
/>
|
||||
</Col>
|
||||
</SingleLineRow>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
23
src/common/xo/revert-snapshot-modal/index.js
Normal file
23
src/common/xo/revert-snapshot-modal/index.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import _ from 'intl'
|
||||
import BaseComponent from 'base-component'
|
||||
import React from 'react'
|
||||
|
||||
export default class RevertSnapshotModalBody extends BaseComponent {
|
||||
state = { snapshotBefore: true }
|
||||
|
||||
get value () {
|
||||
return this.state.snapshotBefore
|
||||
}
|
||||
|
||||
render () {
|
||||
return <div>
|
||||
<div>{_('revertVmModalMessage')}</div>
|
||||
<br />
|
||||
<label>
|
||||
<input type='checkbox' onChange={this.linkState('snapshotBefore')} checked={this.state.snapshotBefore} />
|
||||
{' '}
|
||||
{_('revertVmModalSnapshotBefore')}
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ export const getDefaultNetworkForVif = (vif, host, pifs, networks) => {
|
||||
let defaultNetwork
|
||||
forEach(host.$PIFs, pifId => {
|
||||
const pif = pifs[pifId]
|
||||
if (pif.ip && networks[pif.$network].name_label === nameLabel) {
|
||||
if (networks[pif.$network].name_label === nameLabel) {
|
||||
defaultNetwork = pif.$network
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,29 +5,42 @@ import Icon from './icon'
|
||||
import Link from './link'
|
||||
import propTypes from './prop-types'
|
||||
import { Card, CardHeader, CardBlock } from './card'
|
||||
import { getXoaPlan } from './utils'
|
||||
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,
|
||||
place
|
||||
}) => (
|
||||
<Card>
|
||||
children,
|
||||
isAdmin,
|
||||
place,
|
||||
required = available
|
||||
}) => process.env.XOA_PLAN < required
|
||||
? <Card>
|
||||
<CardHeader>{_('upgradeNeeded')}</CardHeader>
|
||||
<CardBlock className='text-xs-center'>
|
||||
<p>{_('availableIn', {plan: getXoaPlan(available)})}</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')}
|
||||
</a> {_('or')}
|
||||
<Link className='btn btn-success btn-lg' to={'/xoa-update'}>
|
||||
<Icon icon='plan-trial' /> {_('tryIt')}
|
||||
</Link>
|
||||
</p>
|
||||
</CardBlock>
|
||||
{isAdmin
|
||||
? <CardBlock className='text-xs-center'>
|
||||
<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')}
|
||||
</a> {_('or')}
|
||||
<Link className='btn btn-success btn-lg' to={'/xoa-update'}>
|
||||
<Icon icon='plan-trial' /> {_('tryIt')}
|
||||
</Link>
|
||||
</p>
|
||||
</CardBlock>
|
||||
: <CardBlock className='text-xs-center'>
|
||||
<p>{_('notAvailable')}</p>
|
||||
</CardBlock>
|
||||
}
|
||||
</Card>
|
||||
))
|
||||
: children
|
||||
)
|
||||
|
||||
export { Upgrade as default }
|
||||
|
||||
BIN
src/favicon.ico
Normal file
BIN
src/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user