Compare commits

...

149 Commits

Author SHA1 Message Date
Julien Fontanet
4f6ee34592 4.11.0 2015-12-22 13:05:56 +01:00
Olivier Lambert
3ae58a323e update changelog 2015-12-22 12:36:12 +01:00
Fabrice Marsaud
26b958c270 SR host names displayed when necessary 2015-12-22 12:00:15 +01:00
Olivier Lambert
12a4af5900 fix a broken link 2015-12-22 10:39:18 +01:00
Julien Fontanet
69479d538c Merge pull request #611 from vatesfr/abhamonr-incremental-backups-integration
Delta Backup is now known by xo-web.
2015-12-21 18:52:26 +01:00
wescoeur
829397dd5a Delta Backup is now known by xo-web. 2015-12-21 18:00:31 +01:00
Olivier Lambert
2bc89026db Merge pull request #612 from vatesfr/marsaudf-sr-list#601
All host SRs from the pool are shown for Halted VMs disk edition
2015-12-21 16:03:22 +01:00
Fabrice Marsaud
ebbc44d181 All host SRs from the pool are shwon for Halted VMs disk edition 2015-12-21 15:58:49 +01:00
Olivier Lambert
2228a1e36b update changelog 2015-12-21 12:37:33 +01:00
Olivier Lambert
a8cbf3e8ff Merge pull request #602 from vatesfr/pierre-plugin-config-detailed-errors
Plugin config: feedback on form filling errors
2015-12-21 12:02:57 +01:00
Olivier Lambert
fa32e3d734 Merge pull request #598 from vatesfr/marsaudf-disk-size-edition#587
Marsaudf disk size edition#587
2015-12-21 12:02:13 +01:00
Pierre
0d17148ff0 Minor fixes 2015-12-21 11:30:07 +01:00
Pierre
aa38411cf7 Checking titles for each config group. Displaying errors only for the concerned plugin 2015-12-21 11:11:04 +01:00
Olivier Lambert
4913c8699d Merge pull request #610 from vatesfr/cache-missingPatches-dashboard
Cache # of missing patches in dashboard (fix #609).
2015-12-21 10:42:36 +01:00
Julien Fontanet
1035a11487 Cache # of missing patches in dashboard (fix #609). 2015-12-21 10:38:31 +01:00
Olivier Lambert
15c2efe706 Merge pull request #607 from vatesfr/fix-removeListener-dashboard
Stop computing charts data when no longer on dashboard (fix #604).
2015-12-21 10:09:19 +01:00
Julien Fontanet
d7fd71bb62 Same fix for dataviz view. 2015-12-21 10:05:51 +01:00
Julien Fontanet
b11ee993fa Stop computing charts data when no longer on dashboard (fix #604). 2015-12-21 10:05:46 +01:00
Olivier Lambert
614aa7873c update changelog 2015-12-20 14:21:30 +01:00
Pierre
1adf31fe15 [WIP] Display field title when possible and multiple fixes. 2015-12-18 17:24:19 +01:00
Julien Fontanet
824ffd7b5b Merge pull request #603 from vatesfr/abhamonr-fix-remote-importVm
The vm import call use a sr instead of a host.
2015-12-18 17:10:11 +01:00
wescoeur
c31c6fdebb The vm import call use a sr instead of a host. 2015-12-18 17:03:59 +01:00
Pierre
83f3276429 Plugin config: feedback on form filling errors 2015-12-18 15:25:24 +01:00
Julien Fontanet
d21f68ce54 Merge pull request #586 from vatesfr/pierre-read-only-connection
Added read-only checkboxes in the interface
2015-12-18 12:03:44 +01:00
Pierre
18b1e1b133 Connection to a server in read-only mode. (Fix #439)
Checkboxes in Settings view to connect to a Xen Server in read-only mode and then to toggle mode while connected to the server.
2015-12-18 11:48:29 +01:00
Fabrice Marsaud
0edaa40052 Confirm modals before disk resizing 2015-12-18 10:18:02 +01:00
Fabrice Marsaud
627077c8f3 Better code for Human readable size input 2015-12-18 10:18:02 +01:00
Fabrice Marsaud
a897b1798d bytesToSize only alters numbers 2015-12-18 10:18:02 +01:00
Olivier Lambert
50e39993bf Merge pull request #599 from vatesfr/marsaudf-newmv-disk-size
HR size for new VM new disks
2015-12-17 10:59:50 +01:00
Fabrice Marsaud
5e397dd01e HR size for new VM new disks 2015-12-17 10:53:32 +01:00
Olivier Lambert
f57ff5d5e0 Merge pull request #593 from vatesfr/pierre-no-orderby-when-focus
Settings view: the servers list should not re-order while a field is being edited. Fix #594
2015-12-16 18:26:55 +01:00
Olivier Lambert
5c3e40917c Merge pull request #577 from vatesfr/olivierlambert-configdrive
Allow cloud drive usage for disk templates VMs
2015-12-16 18:20:13 +01:00
Olivier Lambert
90a2dc4581 Merge pull request #597 from vatesfr/marsaudf-disk-size-edition#587
Marsaudf disk size edition. Fix #587
2015-12-16 17:37:15 +01:00
Olivier Lambert
b64243fdd6 add parent :o 2015-12-16 17:12:51 +01:00
Fabrice Marsaud
42db87d305 resize disks in SR View 2015-12-16 16:43:39 +01:00
Fabrice Marsaud
e7ab1b589a resize disks in VM view 2015-12-16 16:17:15 +01:00
Fabrice Marsaud
e9979c9887 Human readable sizes for editing template disks on VM creation 2015-12-16 16:07:08 +01:00
Olivier Lambert
3bb9bb56f0 better placeholder 2015-12-16 16:07:08 +01:00
Olivier Lambert
5a99474c55 add stuff 2015-12-16 16:07:08 +01:00
Olivier Lambert
182ee6c25f add stuff 2015-12-16 16:07:08 +01:00
Fabrice Marsaud
4d3f0a06db Modfified template disk properties are stored for future update 2015-12-16 16:07:08 +01:00
Fabrice Marsaud
0e182c519b Config drive button looks better 2015-12-16 16:07:08 +01:00
Fabrice Marsaud
b1ee30ce7d cloud config message 2015-12-16 16:07:08 +01:00
Fabrice Marsaud
93ba764e23 Config drive can be toggled, modified template disks data are isolated in the controller 2015-12-16 16:07:08 +01:00
Olivier Lambert
433e17bb81 more comments 2015-12-16 16:07:08 +01:00
Olivier Lambert
61c09083ad more modifications 2015-12-16 16:07:08 +01:00
Olivier Lambert
018377e724 reorder the cloud config at the end of the vm creation process 2015-12-16 16:07:08 +01:00
Olivier Lambert
b76f9513ba various fixes 2015-12-16 16:07:08 +01:00
Olivier Lambert
40ebb7ba75 add a removed by error stuff 2015-12-16 16:07:08 +01:00
Olivier Lambert
a9e52e8954 remove preliminar work in existing VM view 2015-12-16 16:07:08 +01:00
Olivier Lambert
3c8876cac7 Allow cloud drive usage for disk templates VMs 2015-12-16 16:07:08 +01:00
Olivier Lambert
b7e005f9c7 fix an indentation problem 2015-12-16 16:06:37 +01:00
Olivier Lambert
e6fe0a19fa Merge pull request #596 from vatesfr/olivierlambert-clean-size-to-byte
clean size to byte filter
2015-12-16 16:04:07 +01:00
Olivier Lambert
fba11b6a44 fix a useless filter 2015-12-16 16:01:10 +01:00
Olivier Lambert
c270e7f5dd clean size to byte filter 2015-12-16 15:41:42 +01:00
Pierre
9ee00d345e Settings: the servers list should not re-order while a field is being edited. (Fix #594)
The angular `orderBy` is triggered when the server list is triggered ie every 3 seconds when every text fields are unfocused.
2015-12-16 13:47:13 +01:00
Julien Fontanet
0379fbc4eb Merge pull request #590 from vatesfr/pierre-no-refresh-when-focus
Servers should not refresh while a field is being edited
2015-12-16 13:44:43 +01:00
Olivier Lambert
9748a3ae91 display correct size in interface (binary scale). fix #592 2015-12-16 11:23:37 +01:00
Pierre
1881944748 Servers infos should not refresh while a field is being edited 2015-12-15 16:56:52 +01:00
Olivier Lambert
3721fa194c remove metadata export. Fix #580 2015-12-11 16:38:03 +01:00
Olivier Lambert
8c3fcad20b Merge pull request #574 from vatesfr/marsaudf-prevent-add-host#466
Check IP of a new server to avoid double connection. Fix #466
2015-12-10 14:02:41 +01:00
Fabrice Marsaud
decf373d0b Check IP of a new server to avoid double connection 2015-12-10 12:25:04 +01:00
Olivier Lambert
ff1d50f993 Merge pull request #573 from vatesfr/olivierlambert-set-default-sr
set default SR. Fix #572
2015-12-10 12:14:14 +01:00
Olivier Lambert
ef34204b59 set default SR. Fix #572 2015-12-10 12:10:47 +01:00
Olivier Lambert
270b636d80 Merge pull request #569 from vatesfr/pierre-users-cannot-add-tags-on-disks
Non-admin users don't see the '+' button to add a tag (Fix Issue #516)
2015-12-09 19:22:48 +01:00
Olivier Lambert
ac01da2ae9 Merge pull request #570 from vatesfr/marsaudf-run-job#562
Buttons to run jobs for one shot (backup or job manager). Fix #570
2015-12-09 19:17:13 +01:00
Fabrice Marsaud
0136310c54 Buttons to run jobs for one shot (backup or job manager) 2015-12-09 16:57:50 +01:00
Pierre
ecf4cf852e Non-admin users don't see the '+' button to add a tag (Issue #516) 2015-12-09 16:51:12 +01:00
Olivier Lambert
c66384adfb Merge pull request #566 from vatesfr/olivierlambert-recoveryStart
Generic recovery start (both PV and HVM compatible). Fix #564
2015-12-07 18:03:33 +01:00
Olivier Lambert
98bdda629d Order ISOs by their name. Fix #565 2015-12-07 17:28:48 +01:00
Olivier Lambert
a8286f9cba minor fix 2015-12-07 17:22:40 +01:00
Olivier Lambert
fa3db4fcf6 Generic recovery start (both PV and HVM) 2015-12-07 15:43:46 +01:00
Olivier Lambert
ddac0cfee1 Display failures on VM boot order modification. Fix #560 2015-12-07 13:48:32 +01:00
Olivier Lambert
9368673459 Display PV args for PV guest even if they are empty. Fix #557 2015-12-07 13:37:25 +01:00
Olivier Lambert
43dc999ab5 display boot order only for HVM guests. Fix #558 2015-12-07 13:32:21 +01:00
Olivier Lambert
3b7333e866 remove most of the left margin 2015-12-04 14:11:38 +01:00
Olivier Lambert
bc0ddbaf16 Merge pull request #554 from vatesfr/abhamonr-plugin-config-avoid-reset-form
Avoid plugin config form reset. Fix #529
2015-12-04 12:38:23 +01:00
Olivier Lambert
45f0ae7e1c Merge pull request #425 from vatesfr/pierre-search-bar
Improved search bar
2015-12-04 12:35:35 +01:00
Pierre
a521c4ae01 Clicking on the search button will always bring to the list view. Lag fix when the text field is emptied. Transparent background for filter menus. 2015-12-04 12:00:55 +01:00
Pierre
5b8238adeb 2 filter menus : 'Types' and 'States'. FontAwesome checkboxes. Bug fix. 2015-12-04 10:48:32 +01:00
Pierre
ec330474fa The 'Filters' menu is below the search bar and appears only in the list view. *disconnected filter shows hosts and SRs which have at least 1 PBD not attached. 2015-12-04 10:48:32 +01:00
Pierre
ece28904a8 All the checkboxes are unchecked when the search field is empty 2015-12-04 10:48:32 +01:00
Pierre
4f1c495afb Added *disconnected option. Added a 'Filters' dropdown menu in the search bar to add/remove options with a Github like behaviour 2015-12-04 10:48:32 +01:00
Pierre
5fdd27b7e6 Added option key-word *halted 2015-12-04 10:48:32 +01:00
Pierre
91f449af9a Search improvements
1) select several types of objects (eg: *vm *host)
2) combine types and states (eg: *vm *running)
3) negation is considered as an option
2015-12-04 10:48:32 +01:00
Pierre
efc0a0dfe3 Added '*running' option to show only running entities (for VMs and hosts). This option can be banned (statusrunning) to show only not running entities 2015-12-04 10:48:32 +01:00
Pierre
fee47baa66 Added key-words to ban some objects from search list (host, vm, sr, pool) + Back to tree view when search field is empty 2015-12-04 10:48:32 +01:00
wescoeur
0ad7bfc7e7 Avoid plugin config form reset. 2015-12-03 15:11:44 +01:00
Julien Fontanet
bd64143ae1 Merge pull request #551 from vatesfr/abhamonr-plugin-root-integer-properties
Root integer properties can be edited in plugins configuration form. …
2015-12-03 15:09:52 +01:00
wescoeur
ec982ba9a3 Root integer properties can be edited in plugins configuration form. fix #550 2015-12-03 15:03:47 +01:00
Julien Fontanet
6280f6ff98 Merge pull request #542 from vatesfr/abhamonr-purge-plugin-config
The plugins configurations can be cleaned.
2015-12-03 14:37:52 +01:00
Olivier Lambert
35d20390a9 hide non auhorized containers for VMs (host or pool). Fix #545 2015-12-03 14:23:32 +01:00
wescoeur
c487c5042f The plugins configurations can be cleaned. fix #539 2015-12-02 16:14:36 +01:00
Olivier Lambert
aaf7927aa2 Cloud config default SR. (Fix #548) 2015-11-30 18:45:29 +01:00
Julien Fontanet
3c677f3d21 Merge pull request #544 from vatesfr/abhamonr-plugin-config-boolean-default-value
Plugin config boolean properties have a default false value.
2015-11-27 17:01:22 +01:00
wescoeur
94eb76b3a6 Plugin config boolean properties have a default false value. fix #543 2015-11-27 16:49:46 +01:00
Julien Fontanet
a921cb2d0d 4.10.0 2015-11-27 14:35:50 +01:00
Olivier Lambert
f3aaa363d8 Merge pull request #541 from vatesfr/marsaudf-UI-fix
Minor UI fix
2015-11-27 13:39:20 +01:00
Fabrice Marsaud
45a79e1920 Minor UI fix 2015-11-27 13:35:27 +01:00
Olivier Lambert
6fd9b2a453 Merge pull request #493 from vatesfr/marsaudf-task-manager
Generic task manager
2015-11-27 12:03:01 +01:00
Olivier Lambert
01d8e89a71 add changelog 2015-11-27 11:58:00 +01:00
Fabrice Marsaud
c89fa63910 Minor UI fix 2015-11-27 11:25:23 +01:00
Fabrice Marsaud
9fc5c49dbf UI enhancements 2015-11-27 11:25:23 +01:00
Fabrice Marsaud
7dfc269df9 Enhanced UI inputs for XO object management 2015-11-27 11:25:18 +01:00
Fabrice Marsaud
76d0b397db Instant one shot for generic jobs 2015-11-27 11:24:25 +01:00
Fabrice Marsaud
5413f887af Bare generic job creation and scheduling 2015-11-27 11:24:13 +01:00
Julien Fontanet
b3d0c61f0e Merge pull request #540 from vatesfr/abhamonr-plugin-input-type-number
Plugin 'number' property use input number in config form (fix #538)
2015-11-27 10:49:32 +01:00
wescoeur
4ce0441d68 Plugin 'number' property use input number in config form 2015-11-27 10:42:45 +01:00
Julien Fontanet
72be34e18d Move clipboard to dev deps. 2015-11-27 10:04:43 +01:00
Julien Fontanet
d2961b7650 Merge pull request #537 from vatesfr/abhamonr-plugins-supports-numbers
Plugin config supports integer properties (fix #531).
2015-11-27 09:54:46 +01:00
wescoeur
fdca1bbf72 Plugin config supports integer properties. 2015-11-27 09:43:33 +01:00
Julien Fontanet
ab7a2f9dee Merge pull request #536 from vatesfr/abhamonr-plugin-boolean-checkbox
Plugin boolean properties use checkboxes (fix #528).
2015-11-27 09:42:06 +01:00
wescoeur
7b72857a3b Plugin boolean properties use checkboxes 2015-11-26 22:44:24 +01:00
Olivier Lambert
4787146658 Merge pull request #533 from vatesfr/marsaudf-backup-ui
better backup log display
2015-11-26 16:30:18 +01:00
Fabrice Marsaud
430f9356c3 Minor button fix 2015-11-26 16:27:40 +01:00
Fabrice Marsaud
70a3b3518f Better schedule state UI in overview 2015-11-26 16:25:00 +01:00
Fabrice Marsaud
c0944c17e0 better backup log display 2015-11-26 16:07:47 +01:00
Julien Fontanet
da1b2a91e7 Merge pull request #526 from vatesfr/pierre-console-keyboard-unfocus
Console has keyboard and mouse focus only when mouse is hovering
2015-11-26 15:59:48 +01:00
Pierre
aa27492713 Console catches keyboard and mouse inputs only when mouse is hovering.
Also, when the mouse enters the VM screen, the current active element is unfocused.
2015-11-26 15:44:01 +01:00
Julien Fontanet
afe589dec3 Merge pull request #527 from vatesfr/abhamonr-plugin-title-property
Support title property in plugin configuration schema
2015-11-26 15:25:25 +01:00
wescoeur
978d140c8f Support title property in plugin configuration schema 2015-11-26 14:31:32 +01:00
Olivier Lambert
2ce213b62c Merge pull request #525 from vatesfr/pierre-clipboard-management-through-console
Clipboard management through console
2015-11-26 11:35:57 +01:00
Pierre
7748266078 Clipboard support in console.
- From VM to client :
	1) Copy text in VM
	2) The text field (above the console) updates automatically with the VM's clipboard content
	3) Click on the 'Copy' button to get the text in the local clipboard
- From client to VM :
	1) Write text in the text field
	2) The VM's clipboard updates automatically with the new content
	3) Paste text anywhere in the VM
2015-11-26 11:23:40 +01:00
Olivier Lambert
83783d07a1 hide action panel for host or VM if only viewer 2015-11-25 14:38:47 +01:00
Olivier Lambert
49a1f2c7c5 Merge pull request #517 from vatesfr/marsaudf-disable-host-buttons#474
Disable host buttons relying on ACLs
2015-11-25 14:31:49 +01:00
Olivier Lambert
ddfc0151fc Merge pull request #515 from vatesfr/marsaudf-backup-display#512
Tag display for backup schedules in overview #512
2015-11-25 14:01:33 +01:00
Fabrice Marsaud
81c508e13c Host view OK 2015-11-25 10:25:10 +01:00
Fabrice Marsaud
7195cfc3cf First step 2015-11-24 17:31:26 +01:00
Fabrice Marsaud
93fe5e2cf7 PR feedback 2015-11-24 17:31:00 +01:00
Fabrice Marsaud
a2bf795d12 Tag display for backup schedules in overview 2015-11-24 17:31:00 +01:00
Julien Fontanet
c8d78f39e0 Upgrade npm to latest on Travis. 2015-11-24 17:17:58 +01:00
Fabrice Marsaud
d9ab8a1c8b Fix #508 2015-11-23 15:24:23 +01:00
Olivier Lambert
5125ad4889 Merge pull request #506 from vatesfr/pierre-emergency-host-shutdown
Emergency button in host view is now calling the server function
2015-11-20 17:32:17 +01:00
Olivier Lambert
951e85b04b Merge pull request #507 from vatesfr/olivierlambert-cloudconfig
CoreOS cloud config management during VM creation
2015-11-20 17:32:10 +01:00
Olivier Lambert
711d922695 CoreOS cloud config during VM creation 2015-11-20 17:10:10 +01:00
Pierre
3692ffcde7 Rename function : emergencyHostShutdown -> emergencyShutdownHost 2015-11-20 10:19:06 +01:00
Pierre
b049420c59 Emergency button in host view is now calling the server function (suspends all the VMs running on the host and then shuts the host down) 2015-11-19 17:07:10 +01:00
Olivier Lambert
241103c369 Merge pull request #501 from vatesfr/pierre-install-patches-on-all-pools
Created panel in dashboard
2015-11-19 14:49:59 +01:00
Pierre
2128367113 Update panel in dashboard. 2015-11-19 12:29:48 +01:00
Julien Fontanet
f555c8190d Revert "nvm (on Travis) does not use stable correctly."
This reverts commit f85dc3b7e7.
2015-11-18 17:32:54 +01:00
Pierre
d5df633def Removed some useless CSS 2015-11-18 17:13:54 +01:00
Olivier Lambert
fe7dc859e3 Merge pull request #499 from vatesfr/pierre-suspend-all-vms-and-shutdown-host
emergency shutdown feature in host view (suspend all VMs then shutdown)
2015-11-18 17:04:04 +01:00
Pierre
569c5046c6 Added an emergency button in Action panel (host view) : suspends all the VMs and shuts the host down. 2015-11-18 16:56:56 +01:00
Julien Fontanet
e0210ae2d8 Stable is the new stable branch. 2015-11-18 16:30:30 +01:00
Julien Fontanet
f85dc3b7e7 nvm (on Travis) does not use stable correctly. 2015-11-18 16:10:07 +01:00
Olivier Lambert
92d4363120 tree view improvements and fix 2015-11-17 15:18:57 +01:00
Olivier Lambert
6c69220de2 add start in recovery mode for HVM guests and support new API call setBootOrder() instead of bootOrder() 2015-11-17 14:59:26 +01:00
Julien Fontanet
3a1229b072 Only test on stable as there is just linting for now. 2015-11-16 16:59:55 +01:00
Olivier Lambert
45538c9f62 add quiesce display in VM view 2015-11-16 13:28:51 +01:00
76 changed files with 3669 additions and 675 deletions

View File

@@ -1,8 +1,10 @@
language: node_js
node_js:
- 'stable'
- '0.12'
# Use containers.
# http://docs.travis-ci.com/user/workers/container-based-infrastructure/
sudo: false
before_install:
- npm i -g npm

View File

@@ -1,5 +1,103 @@
# ChangeLog
## **4.11.0** (2015-12-22)
Delta backup, CloudInit...
### Enhancements
- Visible list of SR inside a VM [\#601](https://github.com/vatesfr/xo-web/issues/601)
- VDI move [\#591](https://github.com/vatesfr/xo-web/issues/591)
- Edit pre-existing disk configuration during VM creation [\#589](https://github.com/vatesfr/xo-web/issues/589)
- Allow disk size edition [\#587](https://github.com/vatesfr/xo-web/issues/587)
- Better VDI resize support [\#585](https://github.com/vatesfr/xo-web/issues/585)
- Remove manual VM export metadata in UI [\#580](https://github.com/vatesfr/xo-web/issues/580)
- Support import VM metadata [\#579](https://github.com/vatesfr/xo-web/issues/579)
- Set a default pool SR [\#572](https://github.com/vatesfr/xo-web/issues/572)
- ISOs should be sorted by name [\#565](https://github.com/vatesfr/xo-web/issues/565)
- Button to boot a VM from a disc once [\#564](https://github.com/vatesfr/xo-web/issues/564)
- Ability to boot a PV VM from a disc [\#563](https://github.com/vatesfr/xo-web/issues/563)
- Add an option to manually run backup jobs [\#562](https://github.com/vatesfr/xo-web/issues/562)
- backups to unmounted storage [\#561](https://github.com/vatesfr/xo-web/issues/561)
- Root integer properties cannot be edited in plugins configuration form [\#550](https://github.com/vatesfr/xo-web/issues/550)
- Generic CloudConfig drive [\#549](https://github.com/vatesfr/xo-web/issues/549)
- Auto-discovery of installed xo-server plugins [\#546](https://github.com/vatesfr/xo-web/issues/546)
- Hide info on flat view [\#545](https://github.com/vatesfr/xo-web/issues/545)
- Config plugin boolean properties must have a default value \(undefined prohibited\) [\#543](https://github.com/vatesfr/xo-web/issues/543)
- Present detailed errors on plugin configuration failures [\#530](https://github.com/vatesfr/xo-web/issues/530)
- Do not reset form on failures in plugins configuration [\#529](https://github.com/vatesfr/xo-web/issues/529)
- XMPP alert plugin [\#518](https://github.com/vatesfr/xo-web/issues/518)
- Hide tag adders depending on ACLs [\#516](https://github.com/vatesfr/xo-web/issues/516)
- Choosing a framework for xo-web 5 [\#514](https://github.com/vatesfr/xo-web/issues/514)
- Prevent adding a host in an existing XAPI connection [\#466](https://github.com/vatesfr/xo-web/issues/466)
- Read only connection to Xen servers/pools [\#439](https://github.com/vatesfr/xo-web/issues/439)
- generic notification system [\#391](https://github.com/vatesfr/xo-web/issues/391)
- Data architecture review [\#384](https://github.com/vatesfr/xo-web/issues/384)
- Make filtering easier to understand/add some "default" filters [\#207](https://github.com/vatesfr/xo-web/issues/207)
- Improve performance [\#148](https://github.com/vatesfr/xo-web/issues/148)
### Bug fixes
- VM metadata export should not require a snapshot [\#615](https://github.com/vatesfr/xo-web/issues/615)
- Missing patch for all hosts is continuously refreshed [\#609](https://github.com/vatesfr/xo-web/issues/609)
- Backup import memory issue [\#608](https://github.com/vatesfr/xo-web/issues/608)
- Host list missing patch is buggy [\#604](https://github.com/vatesfr/xo-web/issues/604)
- Servers infos should not been refreshed while a field is being edited [\#595](https://github.com/vatesfr/xo-web/issues/595)
- Servers list should not been re-order while a field is being edited [\#594](https://github.com/vatesfr/xo-web/issues/594)
- Correctly display size in interface \(binary scale\) [\#592](https://github.com/vatesfr/xo-web/issues/592)
- Display failures on VM boot order modification [\#560](https://github.com/vatesfr/xo-web/issues/560)
- `vm.setBootOrder\(\)` should throw errors on failures \(non-HVM VMs\) [\#559](https://github.com/vatesfr/xo-web/issues/559)
- Hide boot order form for non-HVM VMs [\#558](https://github.com/vatesfr/xo-web/issues/558)
- Allow editing PV args even when empty \(but only for PV VMs\) [\#557](https://github.com/vatesfr/xo-web/issues/557)
- Crashes when using legacy event system [\#556](https://github.com/vatesfr/xo-web/issues/556)
- XenServer patches check error for 6.1 [\#555](https://github.com/vatesfr/xo-web/issues/555)
- activation plugin xo-server-transport-email [\#553](https://github.com/vatesfr/xo-web/issues/553)
- Server error with JSON on 32 bits Dom0 [\#552](https://github.com/vatesfr/xo-web/issues/552)
- Cloud Config drive shouldn't be created on default SR [\#548](https://github.com/vatesfr/xo-web/issues/548)
- Deep properties cannot be edited in plugins configuration form [\#521](https://github.com/vatesfr/xo-web/issues/521)
- Aborted VM export should cancel the operation [\#490](https://github.com/vatesfr/xo-web/issues/490)
- VM missing with same UUID after an inter-pool migration [\#284](https://github.com/vatesfr/xo-web/issues/284)
## **4.10.0** (2015-11-27)
Job management, email notifications, CoreOS/Docker, Quiesce snapshots...
### Enhancements
- Job management ([xo-web#487](https://github.com/vatesfr/xo-web/issues/487))
- Patch upload on all connected servers ([xo-web#168](https://github.com/vatesfr/xo-web/issues/168))
- Emergency shutdown ([xo-web#185](https://github.com/vatesfr/xo-web/issues/185))
- CoreOS/docker template install ([xo-web#246](https://github.com/vatesfr/xo-web/issues/246))
- Email for backups ([xo-web#308](https://github.com/vatesfr/xo-web/issues/308))
- Console Clipboard ([xo-web#408](https://github.com/vatesfr/xo-web/issues/408))
- Logs from CLI ([xo-web#486](https://github.com/vatesfr/xo-web/issues/486))
- Save disconnected servers ([xo-web#489](https://github.com/vatesfr/xo-web/issues/489))
- Snapshot with quiesce ([xo-web#491](https://github.com/vatesfr/xo-web/issues/491))
- Start VM in reovery mode ([xo-web#495](https://github.com/vatesfr/xo-web/issues/495))
- Username in logs ([xo-web#498](https://github.com/vatesfr/xo-web/issues/498))
- Delete associated tokens with user ([xo-web#500](https://github.com/vatesfr/xo-web/issues/500))
- Validate plugin configuration ([xo-web#503](https://github.com/vatesfr/xo-web/issues/503))
- Avoid non configured plugins to be loaded ([xo-web#504](https://github.com/vatesfr/xo-web/issues/504))
- Verbose API logs if configured ([xo-web#505](https://github.com/vatesfr/xo-web/issues/505))
- Better backup overview ([xo-web#512](https://github.com/vatesfr/xo-web/issues/512))
- VM auto power on ([xo-web#519](https://github.com/vatesfr/xo-web/issues/519))
- Title property supported in config schema ([xo-web#522](https://github.com/vatesfr/xo-web/issues/522))
- Start VM export only when necessary ([xo-web#534](https://github.com/vatesfr/xo-web/issues/534))
- Input type should be number ([xo-web#538](https://github.com/vatesfr/xo-web/issues/538))
### Bug fixes
- Numbers/int support in plugins config ([xo-web#531](https://github.com/vatesfr/xo-web/issues/531))
- Boolean support in plugins config ([xo-web#528](https://github.com/vatesfr/xo-web/issues/528))
- Keyboard unusable outside console ([xo-web#513](https://github.com/vatesfr/xo-web/issues/513))
- UsernameField for SAML ([xo-web#513](https://github.com/vatesfr/xo-web/issues/513))
- Wrong display of "no plugin found" ([xo-web#508](https://github.com/vatesfr/xo-web/issues/508))
- Bower build error ([xo-web#488](https://github.com/vatesfr/xo-web/issues/488))
- VM cloning should require SR permission ([xo-web#472](https://github.com/vatesfr/xo-web/issues/472))
- Xen tools status ([xo-web#471](https://github.com/vatesfr/xo-web/issues/471))
- Can't delete ghost user ([xo-web#464](https://github.com/vatesfr/xo-web/issues/464))
- Stats with old versions of Node ([xo-web#463](https://github.com/vatesfr/xo-web/issues/463))
## **4.9.0** (2015-11-13)
Automated DR, restore backup, VM copy

View File

@@ -38,8 +38,8 @@ Otherwise, please consider using the [bugtracker of the general repository](http
## Process for new release
```bash
# Switch to the master branch.
git checkout master
# Switch to the stable branch.
git checkout stable
# Fetches latest changes.
git pull --ff-only
@@ -53,12 +53,12 @@ npm version minor
# Go back to the next-release branch.
git checkout next-release
# Fetches the last changes (the merge and version bump) from master to
# Fetches the last changes (the merge and version bump) from stable to
# next-release.
git merge --ff-only master
git merge --ff-only stable
# Push the changes on git.
git push --follow-tags origin master next-release
git push --follow-tags origin stable next-release
# Publish this release to npm.
npm publish

View File

@@ -28,6 +28,7 @@ import newVmState from './modules/new-vm'
import poolState from './modules/pool'
import settingsState from './modules/settings'
import srState from './modules/sr'
import taskScheduler from './modules/task-scheduler'
import treeState from './modules/tree'
import updater from './modules/updater'
import vmState from './modules/vm'
@@ -63,6 +64,7 @@ export default angular.module('xoWebApp', [
poolState,
settingsState,
srState,
taskScheduler,
treeState,
updater,
vmState,

View File

@@ -29,6 +29,7 @@ export default angular.module('backup.backup', [
this.ready = false
this.running = {}
this.comesForEditing = $stateParams.id
this.scheduleApi = {}
this.formData = {}
@@ -220,6 +221,16 @@ export default angular.module('backup.backup', [
})
}
this.run = schedule => {
this.running[schedule.id] = true
notify.info({
title: 'Run Job',
message: 'One shot running started. See overview for logs.'
})
const id = schedule.job
return xo.job.runSequence([id]).finally(() => delete this.running[schedule.id])
}
this.sanitizePath = (...paths) => (paths[0] && paths[0].charAt(0) === '/' && '/' || '') + filter(map(paths, s => s && filter(map(s.split('/'), trim)).join('/'))).join('/')
this.resetData = () => {

View File

@@ -108,7 +108,7 @@ form#backupform(ng-submit = 'ctrl.save(ctrl.formData.scheduleId, ctrl.formData.s
th.hidden-xs Remote
th.hidden-xs Depth
th.hidden-xs Scheduling
th.hidden-xs Only MetaData.hidden-xs
th.hidden-xs Only MetaData
th.hidden-xs Compression DISABLED
th Enabled now
th
@@ -141,4 +141,6 @@ form#backupform(ng-submit = 'ctrl.save(ctrl.formData.scheduleId, ctrl.formData.s
td.text-right
button.btn.btn-primary(type = 'button', ng-click = 'ctrl.edit(schedule)'): i.fa.fa-pencil
|  
button.btn.btn-warning(type = 'button', ng-click = 'ctrl.run(schedule)', ng-disabled = 'ctrl.running[schedule.id]'): i.fa.fa-play
|  
button.btn.btn-danger(type = 'button', ng-click = 'ctrl.delete(schedule)'): i.fa.fa-trash-o

View File

@@ -0,0 +1,255 @@
import angular from 'angular'
import filter from 'lodash.filter'
import find from 'lodash.find'
import forEach from 'lodash.foreach'
import map from 'lodash.map'
import prettyCron from 'prettycron'
import size from 'lodash.size'
import trim from 'lodash.trim'
import uiBootstrap from 'angular-ui-bootstrap'
import uiRouter from 'angular-ui-router'
import view from './view'
// ====================================================================
export default angular.module('backup.deltaBackup', [
uiRouter,
uiBootstrap
])
.config(function ($stateProvider) {
$stateProvider.state('backup.deltaBackup', {
url: '/delta-backup/:id',
controller: 'DeltaBackupCtrl as ctrl',
template: view
})
})
.controller('DeltaBackupCtrl', function ($scope, $stateParams, $interval, xo, xoApi, notify, selectHighLevelFilter, filterFilter) {
const JOBKEY = 'deltaBackup'
this.ready = false
this.running = {}
this.comesForEditing = $stateParams.id
this.scheduleApi = {}
this.formData = {}
const refreshRemotes = () => {
const selectRemoteId = this.formData.remote && this.formData.remote.id
return xo.remote.getAll()
.then(remotes => {
const r = {}
forEach(remotes, remote => {
r[remote.id] = remote
})
this.remotes = r
if (selectRemoteId) {
this.formData.remote = this.remotes[selectRemoteId]
}
})
}
const refreshSchedules = () => {
return xo.schedule.getAll()
.then(schedules => {
const s = {}
forEach(schedules, schedule => {
this.jobs && this.jobs[schedule.job] && this.jobs[schedule.job].key === JOBKEY && (s[schedule.id] = schedule)
})
this.schedules = s
})
}
const refreshJobs = () => {
return xo.job.getAll()
.then(jobs => {
const j = {}
forEach(jobs, job => {
j[job.id] = job
})
this.jobs = j
})
}
const refresh = () => refreshRemotes().then(refreshJobs).then(refreshSchedules)
this.getReady = () => refresh().then(() => this.ready = true)
this.getReady()
const interval = $interval(refresh, 5e3)
$scope.$on('$destroy', () => $interval.cancel(interval))
const toggleState = (toggle, state) => {
const selectedVms = this.formData.selectedVms.slice()
if (toggle) {
const vms = filterFilter(selectHighLevelFilter(this.objects), {type: 'VM'})
forEach(vms, vm => {
if (vm.power_state === state) {
(selectedVms.indexOf(vm) === -1) && selectedVms.push(vm)
}
})
this.formData.selectedVms = selectedVms
} else {
const keptVms = []
for (let index in this.formData.selectedVms) {
if (this.formData.selectedVms[index].power_state !== state) {
keptVms.push(this.formData.selectedVms[index])
}
}
this.formData.selectedVms = keptVms
}
}
this.toggleAllRunning = toggle => toggleState(toggle, 'Running')
this.toggleAllHalted = toggle => toggleState(toggle, 'Halted')
this.edit = schedule => {
const vms = filterFilter(selectHighLevelFilter(this.objects), {type: 'VM'})
const job = this.jobs[schedule.job]
const selectedVms = []
forEach(job.paramsVector.items[0].values, value => {
const vm = find(vms, vm => vm.id === value.vm)
vm && selectedVms.push(vm)
})
const tag = job.paramsVector.items[0].values[0].tag
const depth = job.paramsVector.items[0].values[0].depth
const cronPattern = schedule.cron
const remoteId = job.paramsVector.items[0].values[0].remote
this.resetData()
this.formData.selectedVms = selectedVms
this.formData.tag = tag
this.formData.depth = depth
this.formData.scheduleId = schedule.id
this.formData.remote = this.remotes[remoteId]
this.scheduleApi.setCron(cronPattern)
}
this.save = (id, vms, remoteId, tag, depth, cron, enabled) => {
if (!vms.length) {
notify.warning({
title: 'No Vms selected',
message: 'Choose VMs to backup'
})
return
}
const _save = (id === undefined) ? saveNew(vms, remoteId, tag, depth, cron, enabled) : save(id, vms, remoteId, tag, depth, cron)
return _save
.then(() => {
notify.info({
title: 'Backup',
message: 'Job schedule successfuly saved'
})
this.resetData()
})
.finally(refresh)
}
const save = (id, vms, remoteId, tag, depth, cron) => {
const schedule = this.schedules[id]
const job = this.jobs[schedule.job]
const values = []
forEach(vms, vm => {
values.push({
vm: vm.id,
remote: remoteId,
tag,
depth
})
})
job.paramsVector.items[0].values = values
return xo.job.set(job)
.then(response => {
if (response) {
return xo.schedule.set(schedule.id, undefined, cron, undefined)
} else {
notify.error({
title: 'Update schedule',
message: 'Job updating failed'
})
throw new Error('Job updating failed')
}
})
}
const saveNew = (vms, remoteId, tag, depth, cron, enabled) => {
const values = []
forEach(vms, vm => {
values.push({
vm: vm.id,
remote: remoteId,
tag,
depth
})
})
const job = {
type: 'call',
key: JOBKEY,
method: 'vm.rollingDeltaBackup',
paramsVector: {
type: 'crossProduct',
items: [{
type: 'set',
values
}]
}
}
return xo.job.create(job)
.then(jobId => xo.schedule.create(jobId, cron, enabled))
}
this.delete = schedule => {
let jobId = schedule.job
return xo.schedule.delete(schedule.id)
.then(() => xo.job.delete(jobId))
.finally(() => {
if (this.formData.scheduleId === schedule.id) {
this.resetData()
}
refresh()
})
}
this.run = schedule => {
this.running[schedule.id] = true
notify.info({
title: 'Run Job',
message: 'One shot running started. See overview for logs.'
})
const id = schedule.job
return xo.job.runSequence([id]).finally(() => delete this.running[schedule.id])
}
this.sanitizePath = (...paths) => (paths[0] && paths[0].charAt(0) === '/' && '/' || '') + filter(map(paths, s => s && filter(map(s.split('/'), trim)).join('/'))).join('/')
this.resetData = () => {
this.formData.allRunning = false
this.formData.allHalted = false
this.formData.selectedVms = []
this.formData.scheduleId = undefined
this.formData.tag = undefined
this.formData.path = undefined
this.formData.depth = undefined
this.formData.enabled = false
this.formData.remote = undefined
this.scheduleApi && this.scheduleApi.resetData && this.scheduleApi.resetData()
}
this.size = size
this.prettyCron = prettyCron.toString.bind(prettyCron)
if (!this.comesForEditing) {
refresh()
} else {
refresh()
.then(() => {
this.edit(this.schedules[this.comesForEditing])
delete this.comesForEditing
})
}
this.resetData()
this.objects = xoApi.all
})
// A module exports its name.
.name

View File

@@ -0,0 +1,132 @@
.panel.panel-default
p.page-title
i.fa.fa-download(style="color: #e25440;")
| Delta Backup
form#backupform(ng-submit = 'ctrl.save(ctrl.formData.scheduleId, ctrl.formData.selectedVms, ctrl.formData.remote.id, ctrl.formData.tag, ctrl.formData.depth, ctrl.formData.cronPattern, ctrl.formData.enabled)')
.grid-sm
.panel.panel-default
.panel-heading.panel-title
i.fa.xo-icon-vm
| VMs to backup
.panel-body.form-horizontal
.text-center(ng-if = '!ctrl.formData'): i.xo-icon-loading
.container-fluid(ng-if = 'ctrl.formData')
.alert.alert-info(ng-if = '!ctrl.formData.scheduleId') Creating New Backup
.alert.alert-warning(ng-if = 'ctrl.formData.scheduleId') Modifying Backup ID {{ ctrl.formData.scheduleId }}
.form-group
label.control-label.col-md-2(for = 'tag') Tag
.col-md-10
input#tag.form-control(form = 'backupform', ng-model = 'ctrl.formData.tag', placeholder = 'Back-up tag', required)
.form-group(ng-class = '{"has-warning": !ctrl.formData.selectedVms.length}')
label.control-label.col-md-2(for = 'vmlist') VMs
.col-md-8
ui-select(form = 'backupform', ng-model = 'ctrl.formData.selectedVms', multiple, close-on-select = 'false', required)
ui-select-match(placeholder = 'Choose VMs to backup')
i.xo-icon-working(ng-if="isVMWorking($item)")
i(class="xo-icon-{{$item.power_state | lowercase}}",ng-if="!isVMWorking($item)")
| {{$item.name_label}}
span(ng-if="$item.$container")
| ({{($item.$container | resolve).name_label}})
ui-select-choices(repeat = 'vm in ctrl.objects | selectHighLevel | filter:{type: "VM"} | filter:$select.search | orderBy:["$container", "name_label"] track by vm.id')
div
i.xo-icon-working(ng-if="isVMWorking(vm)", tooltip="{{vm.power_state}} and {{(vm.current_operations | map)[0]}}")
i(class="xo-icon-{{vm.power_state | lowercase}}",ng-if="!isVMWorking(vm)", tooltip="{{vm.power_state}}")
| {{vm.name_label}}
span(ng-if="vm.$container")
| ({{(vm.$container | resolve).name_label || ((vm.$container | resolve).master | resolve).name_label}})
.col-md-2
label(tooltip = 'select/deselect all running VMs', style = 'cursor: pointer')
input.hidden(form = 'backupform', type = 'checkbox', ng-model = 'ctrl.formData.allRunning', ng-change = 'ctrl.toggleAllRunning(ctrl.formData.allRunning)')
span.fa-stack
i.xo-icon-running.fa-stack-1x
i.fa.fa-circle-o.fa-stack-2x(ng-if = 'ctrl.formData.allRunning')
label(tooltip = 'select/deselect all halted VMs', style = 'cursor: pointer')
input.hidden(form = 'backupform', type = 'checkbox', ng-model = 'ctrl.formData.allHalted', ng-change = 'ctrl.toggleAllHalted(ctrl.formData.allHalted)')
span.fa-stack
i.xo-icon-halted.fa-stack-1x
i.fa.fa-circle-o.fa-stack-2x(ng-if = 'ctrl.formData.allHalted')
.form-group
label.control-label.col-md-2(for = 'depth') Depth
.col-md-10
input#depth.form-control(form = 'backupform', ng-model = 'ctrl.formData.depth', placeholder = 'How many backups to rollover', type = 'number', min = '1', required)
.form-group
label.control-label.col-md-2(for = 'remote') Remote
.col-md-10
select#remote.form-control(form = 'backupform', ng-options = 'remote.name group by remote.type for remote in ctrl.remotes', ng-model = 'ctrl.formData.remote' required)
option(value = ''): em -- Choose a file system remote point --
.form-group
.col-md-10.col-md-offset-2
a(ui-sref = 'backup.remote')
i.fa.fa-pencil
|   Manage your remote stores
.form-group(ng-if = '!ctrl.formData.scheduleId')
label.control-label.col-md-2(for = 'enabled')
input#enabled(form = 'backupform', ng-model = 'ctrl.formData.enabled', type = 'checkbox')
.help-block.col-md-10 Enable immediatly after creation
.panel.panel-default
.panel-heading.panel-title
i.fa.fa-clock-o
| Schedule
.panel-body.form-horizontal
.text-center(ng-if = '!ctrl.formData'): i.xo-icon-loading
xo-scheduler(data = 'ctrl.formData', api = 'ctrl.scheduleApi')
.grid-sm
.panel.panel-default
.panel-body
fieldset.center(ng-disabled = '!ctrl.ready')
button.btn.btn-lg.btn-primary(form = 'backupform', type = 'submit')
i.fa.fa-clock-o
|  
i.fa.fa-arrow-right
|  
i.fa.fa-database
|  Save 
|  
button.btn.btn-lg.btn-default(type = 'button', ng-click = 'ctrl.resetData()')
|  Reset 
.grid-sm
.panel.panel-default
.panel-heading.panel-title
i.fa.fa-list-ul
| Schedules
.panel-body
.text-center(ng-if = '!ctrl.schedules'): i.xo-icon-loading
.text-center(ng-if = 'ctrl.schedules && !ctrl.size(ctrl.schedules)') No scheduled jobs
table.table.table-hover(ng-if = 'ctrl.schedules && ctrl.size(ctrl.schedules)')
tr
th ID
th Tag
th.hidden-xs.hidden-sm VMs to backup
th.hidden-xs Remote
th.hidden-xs Depth
th.hidden-xs Scheduling
th Enabled now
th
tr(ng-repeat = 'schedule in ctrl.schedules | orderBy:"id":true track by schedule.id')
td {{ schedule.id }}
td {{ ctrl.jobs[schedule.job].paramsVector.items[0].values[0].tag }}
td.hidden-xs.hidden-sm
div(ng-if = 'ctrl.jobs[schedule.job].paramsVector.items[0].values.length == 1')
| {{ (ctrl.jobs[schedule.job].paramsVector.items[0].values[0].id | resolve).name_label }}
div(ng-if = 'ctrl.jobs[schedule.job].paramsVector.items[0].values.length > 1')
button.btn.btn-info(type = 'button', ng-click = 'unCollapsed = !unCollapsed')
| {{ ctrl.jobs[schedule.job].paramsVector.items[0].values.length }} VMs 
i.fa(ng-class = '{"fa-chevron-down": !unCollapsed, "fa-chevron-up": unCollapsed}')
div(collapse = '!unCollapsed')
br
ul.list-group
li.list-group-item(ng-repeat = 'item in ctrl.jobs[schedule.job].paramsVector.items[0].values')
span(ng-if = 'item.vm | resolve') {{ (item.vm | resolve).name_label }}
span(ng-if = '(item.vm | resolve).$container')  ({{ ((item.vm | resolve).$container | resolve).name_label }})
td.hidden-xs
strong: a(ui-sref = 'scheduler.remote') {{ ctrl.remotes[ctrl.jobs[schedule.job].paramsVector.items[0].values[0].remoteId].name }}
td.hidden-xs {{ ctrl.jobs[schedule.job].paramsVector.items[0].values[0].depth }}
td.hidden-xs {{ ctrl.prettyCron(schedule.cron) }}
td.text-center
i.fa.fa-check(ng-if = 'schedule.enabled')
td.text-right
button.btn.btn-primary(type = 'button', ng-click = 'ctrl.edit(schedule)'): i.fa.fa-pencil
|  
button.btn.btn-warning(type = 'button', ng-click = 'ctrl.run(schedule)', ng-disabled = 'ctrl.running[schedule.id]'): i.fa.fa-play
|  
button.btn.btn-danger(type = 'button', ng-click = 'ctrl.delete(schedule)'): i.fa.fa-trash-o

View File

@@ -28,6 +28,7 @@ export default angular.module('backup.disasterrecovery', [
this.ready = false
this.running = {}
this.comesForEditing = $stateParams.id
this.scheduleApi = {}
this.formData = {}
@@ -179,6 +180,16 @@ export default angular.module('backup.disasterrecovery', [
})
}
this.run = schedule => {
this.running[schedule.id] = true
notify.info({
title: 'Run Job',
message: 'One shot running started. See overview for logs.'
})
const id = schedule.job
return xo.job.runSequence([id]).finally(() => delete this.running[schedule.id])
}
this.inTargetPool = vm => vm.$poolId === (this.formData.selectedPool && this.formData.selectedPool.id)
this.resetData = () => {

View File

@@ -140,4 +140,6 @@ form#drform(ng-submit = 'ctrl.save(ctrl.formData.scheduleId, ctrl.formData.selec
td.text-right
button.btn.btn-primary(type = 'button', ng-click = 'ctrl.edit(schedule)'): i.fa.fa-pencil
|  
button.btn.btn-warning(type = 'button', ng-click = 'ctrl.run(schedule)', ng-disabled = 'ctrl.running[schedule.id]'): i.fa.fa-play
|  
button.btn.btn-danger(type = 'button', ng-click = 'ctrl.delete(schedule)'): i.fa.fa-trash-o

View File

@@ -1,16 +1,12 @@
import angular from 'angular'
import assign from 'lodash.assign'
import forEach from 'lodash.foreach'
import indexOf from 'lodash.indexof'
import later from 'later'
import moment from 'moment'
import prettyCron from 'prettycron'
import remove from 'lodash.remove'
import scheduler from 'scheduler'
import uiRouter from 'angular-ui-router'
later.date.localTime()
import backup from './backup'
import deltaBackup from './delta-backup'
import disasterRecovery from './disaster-recovery'
import management from './management'
import mount from './remote'
@@ -18,17 +14,18 @@ import restore from './restore'
import rollingSnapshot from './rolling-snapshot'
import view from './view'
import scheduler from './scheduler'
export default angular.module('backup', [
uiRouter,
backup,
deltaBackup,
disasterRecovery,
management,
mount,
restore,
rollingSnapshot
rollingSnapshot,
scheduler
])
.config(function ($stateProvider) {
$stateProvider.state('backup', {
@@ -49,302 +46,4 @@ export default angular.module('backup', [
})
})
.directive('xoScheduler', function () {
return {
restrict: 'E',
template: scheduler,
controller: 'XoScheduler as ctrl',
bindToController: true,
scope: {
data: '=',
api: '='
}
}
})
.controller('XoScheduler', function () {
this.init = () => {
let i, j
const minutes = []
for (i = 0; i < 6; i++) {
minutes[i] = []
for (j = 0; j < 10; j++) {
minutes[i].push(10 * i + j)
}
}
this.minutes = minutes
const hours = []
for (i = 0; i < 3; i++) {
hours[i] = []
for (j = 0; j < 8; j++) {
hours[i].push(8 * i + j)
}
}
this.hours = hours
const days = []
for (i = 0; i < 4; i++) {
days[i] = []
for (j = 1; j < 8; j++) {
days[i].push(7 * i + j)
}
}
days.push([29, 30, 31])
this.days = days
this.months = [
[
{v: 1, l: 'Jan'},
{v: 2, l: 'Feb'},
{v: 3, l: 'Mar'},
{v: 4, l: 'Apr'},
{v: 5, l: 'May'},
{v: 6, l: 'Jun'}
],
[
{v: 7, l: 'Jul'},
{v: 8, l: 'Aug'},
{v: 9, l: 'Sep'},
{v: 10, l: 'Oct'},
{v: 11, l: 'Nov'},
{v: 12, l: 'Dec'}
]
]
this.dayWeeks = [
{v: 0, l: 'Sun'},
{v: 1, l: 'Mon'},
{v: 2, l: 'Tue'},
{v: 3, l: 'Wed'},
{v: 4, l: 'Thu'},
{v: 5, l: 'Fri'},
{v: 6, l: 'Sat'}
]
this.resetData()
}
this.selectMinute = function (minute) {
if (this.isSelectedMinute(minute)) {
remove(this.data.minSelect, v => String(v) === String(minute))
} else {
this.data.minSelect.push(minute)
}
}
this.isSelectedMinute = function (minute) {
return indexOf(this.data.minSelect, minute) > -1 || indexOf(this.data.minSelect, String(minute)) > -1
}
this.selectHour = function (hour) {
if (this.isSelectedHour(hour)) {
remove(this.data.hourSelect, v => String(v) === String(hour))
} else {
this.data.hourSelect.push(hour)
}
}
this.isSelectedHour = function (hour) {
return indexOf(this.data.hourSelect, hour) > -1 || indexOf(this.data.hourSelect, String(hour)) > -1
}
this.selectDay = function (day) {
if (this.isSelectedDay(day)) {
remove(this.data.daySelect, v => String(v) === String(day))
} else {
this.data.daySelect.push(day)
}
}
this.isSelectedDay = function (day) {
return indexOf(this.data.daySelect, day) > -1 || indexOf(this.data.daySelect, String(day)) > -1
}
this.selectMonth = function (month) {
if (this.isSelectedMonth(month)) {
remove(this.data.monthSelect, v => String(v) === String(month))
} else {
this.data.monthSelect.push(month)
}
}
this.isSelectedMonth = function (month) {
return indexOf(this.data.monthSelect, month) > -1 || indexOf(this.data.monthSelect, String(month)) > -1
}
this.selectDayWeek = function (dayWeek) {
if (this.isSelectedDayWeek(dayWeek)) {
remove(this.data.dayWeekSelect, v => String(v) === String(dayWeek))
} else {
this.data.dayWeekSelect.push(dayWeek)
}
}
this.isSelectedDayWeek = function (dayWeek) {
return indexOf(this.data.dayWeekSelect, dayWeek) > -1 || indexOf(this.data.dayWeekSelect, String(dayWeek)) > -1
}
this.noMinutePlan = function (set = false) {
if (!set) {
// The last part (after &&) of this expression is reliable because we maintain the minSelect array with lodash.remove
return this.data.min === 'select' && this.data.minSelect.length === 1 && String(this.data.minSelect[0]) === '0'
} else {
this.data.minSelect = [0]
this.data.min = 'select'
return true
}
}
this.noHourPlan = function (set = false) {
if (!set) {
// The last part (after &&) of this expression is reliable because we maintain the hourSelect array with lodash.remove
return this.data.hour === 'select' && this.data.hourSelect.length === 1 && String(this.data.hourSelect[0]) === '0'
} else {
this.data.hourSelect = [0]
this.data.hour = 'select'
return true
}
}
this.resetData = () => {
this.data.minRange = 5
this.data.hourRange = 2
this.data.minSelect = [0]
this.data.hourSelect = []
this.data.daySelect = []
this.data.monthSelect = []
this.data.dayWeekSelect = []
this.data.min = 'select'
this.data.hour = 'all'
this.data.day = 'all'
this.data.month = 'all'
this.data.dayWeek = 'all'
this.data.cronPattern = '* * * * *'
this.data.summary = []
this.data.previewLimit = 0
this.update()
}
this.update = () => {
const d = this.data
const i = (d.min === 'all' && '*') ||
(d.min === 'range' && ('*/' + d.minRange)) ||
(d.min === 'select' && d.minSelect.join(',')) ||
'*'
const h = (d.hour === 'all' && '*') ||
(d.hour === 'range' && ('*/' + d.hourRange)) ||
(d.hour === 'select' && d.hourSelect.join(',')) ||
'*'
const dm = (d.day === 'all' && '*') ||
(d.day === 'select' && d.daySelect.join(',')) ||
'*'
const m = (d.month === 'all' && '*') ||
(d.month === 'select' && d.monthSelect.join(',')) ||
'*'
const dw = (d.dayWeek === 'all' && '*') ||
(d.dayWeek === 'select' && d.dayWeekSelect.join(',')) ||
'*'
this.data.cronPattern = i + ' ' + h + ' ' + dm + ' ' + m + ' ' + dw
const tabState = {
min: {
all: d.min === 'all',
range: d.min === 'range',
select: d.min === 'select'
},
hour: {
all: d.hour === 'all',
range: d.hour === 'range',
select: d.hour === 'select'
},
day: {
all: d.day === 'all',
range: d.day === 'range',
select: d.day === 'select'
},
month: {
all: d.month === 'all',
select: d.month === 'select'
},
dayWeek: {
all: d.dayWeek === 'all',
select: d.dayWeek === 'select'
}
}
this.tabs = tabState
this.summarize()
}
this.summarize = () => {
const schedule = later.parse.cron(this.data.cronPattern)
const occurences = later.schedule(schedule).next(25)
this.data.summary = []
forEach(occurences, occurence => {
this.data.summary.push(moment(occurence).format('LLLL'))
})
}
const cronToData = (data, cron) => {
const d = Object.create(null)
const cronItems = cron.split(' ')
if (cronItems[0] === '*') {
d.min = 'all'
} else if (cronItems[0].indexOf('/') !== -1) {
d.min = 'range'
const [, range] = cronItems[0].split('/')
d.minRange = range
} else {
d.min = 'select'
d.minSelect = cronItems[0].split(',')
}
if (cronItems[1] === '*') {
d.hour = 'all'
} else if (cronItems[1].indexOf('/') !== -1) {
d.hour = 'range'
const [, range] = cronItems[1].split('/')
d.hourRange = range
} else {
d.hour = 'select'
d.hourSelect = cronItems[1].split(',')
}
if (cronItems[2] === '*') {
d.day = 'all'
} else {
d.day = 'select'
d.daySelect = cronItems[2].split(',')
}
if (cronItems[3] === '*') {
d.month = 'all'
} else {
d.month = 'select'
d.monthSelect = cronItems[3].split(',')
}
if (cronItems[4] === '*') {
d.dayWeek = 'all'
} else {
d.dayWeek = 'select'
d.dayWeekSelect = cronItems[4].split(',')
}
assign(data, d)
}
this.prettyCron = prettyCron.toString.bind(prettyCron)
this.api.setCron = cron => {
cronToData(this.data, cron)
this.update()
}
this.api.resetData = this.resetData.bind(this)
this.init()
})
.name

View File

@@ -1,4 +1,5 @@
import angular from 'angular'
import filter from 'lodash.filter'
import forEach from 'lodash.foreach'
import prettyCron from 'prettycron'
import uiBootstrap from 'angular-ui-bootstrap'
@@ -20,16 +21,21 @@ export default angular.module('backup.management', [
})
})
.controller('ManagementCtrl', function ($scope, $state, $stateParams, $interval, xo, xoApi, notify, selectHighLevelFilter, filterFilter) {
this.running = {}
const mapJobKeyToState = {
'rollingSnapshot': 'rollingsnapshot',
'rollingBackup': 'backup',
'disasterRecovery': 'disasterrecovery'
deltaBackup: 'deltaBackup',
disasterRecovery: 'disasterrecovery',
rollingBackup: 'backup',
rollingSnapshot: 'rollingsnapshot',
__none: 'index'
}
const mapJobKeyToJobDisplay = {
'rollingSnapshot': 'Rolling Snapshot',
'rollingBackup': 'Backup',
'disasterRecovery': 'Disaster Recovery'
deltaBackup: 'Delta Backup',
disasterRecovery: 'Disaster Recovery',
rollingBackup: 'Backup',
rollingSnapshot: 'Rolling Snapshot',
__none: '[unknown]'
}
this.currentLogPage = 1
@@ -37,7 +43,7 @@ export default angular.module('backup.management', [
const refreshSchedules = () => {
xo.schedule.getAll()
.then(schedules => this.schedules = schedules)
.then(schedules => this.schedules = filter(schedules, schedule => this.jobs[schedule.job] && this.jobs[schedule.job].key in mapJobKeyToState))
xo.scheduler.getScheduleTable()
.then(table => this.scheduleTable = table)
}
@@ -45,12 +51,10 @@ export default angular.module('backup.management', [
const getLogs = () => {
xo.logs.get('jobs').then(logs => {
const viewLogs = {}
forEach(logs, (log, logKey) => {
const data = log.data
const [time] = logKey.split(':')
if (data.event === 'job.start') {
if (data.event === 'job.start' && data.key in mapJobKeyToState) {
viewLogs[logKey] = {
logKey,
jobId: data.jobId,
@@ -62,31 +66,33 @@ export default angular.module('backup.management', [
}
} else {
const runJobId = data.runJobId
const entry = viewLogs[runJobId]
if (!entry) {
return
}
if (data.event === 'job.end') {
const entry = viewLogs[runJobId]
if (data.error) {
entry.error = data.error
}
entry.end = time
entry.duration = time - entry.start
entry.status = 'Terminated'
} else if (data.event === 'jobCall.start') {
viewLogs[runJobId].calls[logKey] = {
entry.calls[logKey] = {
callKey: logKey,
params: resolveParams(data.params),
method: data.method,
time
}
} else if (data.event === 'jobCall.end') {
const call = viewLogs[runJobId].calls[data.runCallId]
const call = entry.calls[data.runCallId]
if (data.error) {
call.error = data.error
viewLogs[runJobId].hasErrors = true
entry.hasErrors = true
} else {
call.returnedValue = data.returnedValue
call.returnedValue = resolveReturn(data.returnedValue)
}
}
}
@@ -104,17 +110,24 @@ export default angular.module('backup.management', [
const resolveParams = params => {
for (let key in params) {
if (key === 'id') {
const xoObject = xoApi.get(params[key])
if (xoObject) {
params[xoObject.type] = xoObject.name_label
delete params[key]
}
const xoObject = xoApi.get(params[key])
if (xoObject) {
const newKey = xoObject.type || key
params[newKey] = xoObject.name_label || xoObject.name || params[key]
newKey !== key && delete params[key]
}
}
return params
}
const resolveReturn = returnValue => {
const xoObject = xoApi.get(returnValue)
let xoName = xoObject && (xoObject.name_label || xoObject.name)
xoName && (xoName += xoObject.type && ` (${xoObject.type})` || '')
returnValue = xoName || returnValue
return returnValue
}
this.prettyCron = prettyCron.toString.bind(prettyCron)
const refreshJobs = () => {
@@ -151,9 +164,19 @@ export default angular.module('backup.management', [
.finally(() => { this.working[id] = false })
.then(refreshSchedules)
}
this.resolveJobKey = schedule => mapJobKeyToState[this.jobs[schedule.job].key]
this.displayJobKey = schedule => mapJobKeyToJobDisplay[this.jobs[schedule.job].key]
this.run = schedule => {
this.running[schedule.id] = true
notify.info({
title: 'Run Job',
message: 'One shot running started. See overview for logs.'
})
const id = schedule.job
return xo.job.runSequence([id]).finally(() => delete this.running[schedule.id])
}
this.resolveJobKey = schedule => mapJobKeyToState[this.jobs[schedule.job] && this.jobs[schedule.job].key || '__none']
this.displayJobKey = schedule => mapJobKeyToJobDisplay[this.jobs[schedule.job] && this.jobs[schedule.job].key || '__none']
this.displayLogKey = log => mapJobKeyToJobDisplay[log.key]
this.resolveScheduleJobTag = schedule => this.jobs[schedule.job] && this.jobs[schedule.job].paramsVector && this.jobs[schedule.job].paramsVector.items[0].values[0].tag || schedule.id
this.collectionLength = col => Object.keys(col).length
this.working = {}

View File

@@ -16,25 +16,23 @@
td.text-center No scheduled jobs
table.table.table-hover(ng-if = 'ctrl.schedules && ctrl.collectionLength(ctrl.schedules)')
tr
th ID
th Job
th Scheduling
th Tag
th.hidden-xs Scheduling
th State
th
tr(ng-repeat = 'schedule in ctrl.schedules | orderBy:"id":true track by schedule.id')
td: a(ui-sref = 'backup.{{ctrl.resolveJobKey(schedule)}}({id: schedule.id})') {{ schedule.id }}
td {{ ctrl.displayJobKey(schedule) }}
td {{ ctrl.prettyCron(schedule.cron) }}
td: a(ui-sref = 'backup.{{ctrl.resolveJobKey(schedule)}}({id: schedule.id})') {{ ctrl.resolveScheduleJobTag(schedule) }}
td.hidden-xs {{ ctrl.prettyCron(schedule.cron) }}
td
span.text-success(ng-if = 'ctrl.scheduleTable[schedule.id] === true')
| enabled&nbsp;
i.fa.fa-cogs
span.text-muted(ng-if = 'ctrl.scheduleTable[schedule.id] === false') disabled
span.text-warning(ng-if = 'ctrl.scheduleTable[schedule.id] === undefined') ?
td.text-right
fieldset(ng-disabled = 'ctrl.working[schedule.id]')
button.btn.btn-success(ng-if = 'ctrl.scheduleTable[schedule.id] === false', type = 'button', ng-click = 'ctrl.enable(schedule.id)') Enable
button.btn.btn-warning(ng-if = 'ctrl.scheduleTable[schedule.id] === true', type = 'button', ng-click = 'ctrl.disable(schedule.id)') Disable
span.label.label-success.hidden-xs(ng-if = 'ctrl.scheduleTable[schedule.id] === true') enabled
span.label.label-default.hidden-xs(ng-if = 'ctrl.scheduleTable[schedule.id] === false') disabled
span.label.label-warning.hidden-xs(ng-if = 'ctrl.scheduleTable[schedule.id] === undefined') unknown
fieldset.pull-right(ng-disabled = 'ctrl.working[schedule.id]')
button.btn(ng-if = 'ctrl.scheduleTable[schedule.id] === false', type = 'button', ng-click = 'ctrl.enable(schedule.id)'): i.fa.fa-toggle-off
button.btn.btn-success(ng-if = 'ctrl.scheduleTable[schedule.id] === true', type = 'button', ng-click = 'ctrl.disable(schedule.id)'): i.fa.fa-toggle-on
| &nbsp;
button.btn.btn-warning(type = 'button', ng-click = 'ctrl.run(schedule)', ng-disabled = 'ctrl.running[schedule.id]'): i.fa.fa-play
.grid-sm
.panel.panel-default
.panel-heading.panel-title
@@ -53,8 +51,8 @@
tbody(ng-repeat = 'log in ctrl.logs | map | filter:ctrl.logSearch | orderBy:"-time" | slice:(ctrl.logPageSize * (ctrl.currentLogPage - 1)):(ctrl.logPageSize * ctrl.currentLogPage) track by log.logKey')
tr
td
| {{ log.jobId }}&nbsp;
button.btn.btn-sm(type = 'button', tooltip = 'See calls', ng-click = 'seeCalls = !seeCalls', ng-class = '{"btn-default": !log.hasErrors, "btn-danger": log.hasErrors}'): i.fa(ng-class = '{"fa-caret-down": !seeCalls, "fa-caret-up": seeCalls}')
| &nbsp;{{ log.jobId }}
td {{ ctrl.displayLogKey(log) }}
td {{ log.start | date:'medium' }}
td {{ log.end | date:'medium' }}
@@ -68,6 +66,7 @@
td(colspan = '6')
ul.list-group
li.list-group-item(ng-repeat = 'call in log.calls | map | orderBy:"-time" track by call.callKey')
strong.text-info {{ call.method }}:&#32;
span(ng-repeat = '(key, param) in call.params')
strong {{ key }}:
| &nbsp;{{ param }}&nbsp;

View File

@@ -1,4 +1,5 @@
import angular from 'angular'
import filter from 'lodash.filter'
import find from 'lodash.find'
import forEach from 'lodash.foreach'
import size from 'lodash.size'
@@ -20,19 +21,26 @@ export default angular.module('backup.restore', [
template: view
})
})
.controller('RestoreCtrl', function ($scope, $interval, xo, xoApi, notify, $upload) {
.controller('RestoreCtrl', function ($scope, $interval, xo, xoApi, notify, $upload, bytesToSizeFilter) {
this.loaded = {}
this.hosts = xoApi.getView('hosts')
const srs = xoApi.getView('SRs').all
this.bytesToSize = bytesToSizeFilter
this.isEmpty = backups => backups && !(Object.keys(backups.delta) || backups.other.length)
this.size = size
const refresh = () => {
return xo.remote.getAll()
.then(remotes => {
forEach(this.backUpRemotes, remote => {
if (remote.files) {
if (remote.backups) {
const freshRemote = find(remotes, {id: remote.id})
freshRemote && (freshRemote.files = remote.files)
freshRemote && (freshRemote.backups = remote.backups)
}
})
this.backUpRemotes = remotes
this.writable_SRs = filter(srs, (sr) => sr.content_type !== 'iso')
})
}
@@ -43,24 +51,67 @@ export default angular.module('backup.restore', [
$interval.cancel(interval)
})
const deltaBuilder = (backups, uuid, name, tag, value) => {
let deltaBackup = backups[uuid]
? backups[uuid]
: backups[uuid] = {}
deltaBackup = deltaBackup[name]
? deltaBackup[name]
: deltaBackup[name] = {}
deltaBackup = deltaBackup[tag]
? deltaBackup[tag]
: deltaBackup[tag] = []
deltaBackup.push(value)
}
this.list = id => {
return xo.remote.list(id)
.then(files => {
const remote = find(this.backUpRemotes, {id})
remote && (remote.files = files)
if (remote) {
const backups = remote.backups = {
delta: {},
other: []
}
forEach(files, file => {
const arr = /^vm_delta_(.*)_([^\/]+)\/([^_]+)_(.*)\.xva$/.exec(file)
if (arr) {
const [ path, tag, uuid, date, name ] = arr
const value = {
path: /^(vm_delta_.*)\.xva$/.exec(path)[1],
date
}
deltaBuilder(backups.delta, uuid, name, tag, value)
} else {
backups.other.push(file)
}
})
}
this.loaded[remote.id] = true
})
}
this.importVm = (id, file, host) => {
notify.info({
title: 'VM import started',
message: 'Starting the VM import'
})
return xo.remote.importVm(id, file, host)
const notification = {
title: 'VM import started',
message: 'Starting the VM import'
}
this.size = size
this.importBackup = (id, path, sr) => {
notify.info(notification)
return xo.vm.importBackup(id, path, sr)
}
this.importDeltaBackup = (id, path, sr) => {
notify.info(notification)
return xo.vm.importDeltaBackup(id, path, sr)
}
})
// A module exports its name.

View File

@@ -14,24 +14,55 @@
span(ng-if = '!remote.error') &nbsp;(disabled)
.panel-body(ng-if = 'remote.enabled')
.row
.col-sm-3
.col-sm-2
p
| {{ remote.name }}&nbsp;
button.btn.btn-default.pull-right(type = 'button', ng-click = 'ctrl.list(remote.id)'): i.fa(ng-class = '{"fa-eye": !ctrl.loaded[remote.id], "fa-refresh": ctrl.loaded[remote.id]}')
br
br
.col-sm-9
div(ng-if = 'ctrl.loaded[remote.id] && !ctrl.size(remote.files)') No backups available
div(ng-if = 'ctrl.size(remote.files)')
div(ng-repeat = 'file in remote.files')
| {{ file }}
.col-sm-10
div(ng-if = 'ctrl.loaded[remote.id] && ctrl.isEmpty(remote.backups)') No backups available
div(ng-if = 'ctrl.size(remote.backups.delta)')
div(ng-repeat = '(uuid, backups) in remote.backups.delta')
.row
.col-sm-2
| {{ uuid }}
.col-sm-10
div(ng-repeat = '(name, backups) in backups')
.row
.col-sm-2
| {{ name }}
.col-sm-10
div(ng-repeat = '(tag, backups) in backups')
.row
.col-sm-2
| {{ tag }}
.col-sm-10
div(ng-repeat = 'backup in backups')
| {{ backup.date | date:'medium' }}
span.pull-right.dropdown(dropdown)
button.btn.btn-default(type = 'button', dropdown-toggle)
| Import&nbsp;
span.caret
ul.dropdown-menu(role="menu")
li(ng-repeat = 'sr in ctrl.writable_SRs | orderBy:natural("name_label") track by sr.id')
a(xo-click = "ctrl.importDeltaBackup(remote.id, backup.path, sr.id)")
i.xo-icon-host.fa-fw
| To {{sr.name_label}} ({{sr.size - sr.usage | bytesToSize }})
span &nbsp;{{ (sr.$container | resolve).name_label }}
hr
hr
div(ng-if = 'ctrl.size(remote.backups.other)')
div(ng-repeat = 'backup in remote.backups.other')
| {{ backup }}
span.pull-right.dropdown(dropdown)
button.btn.btn-default(type = 'button', dropdown-toggle)
| Import&nbsp;
span.caret
ul.dropdown-menu(role="menu")
li(ng-repeat = 'h in ctrl.hosts.all | orderBy:natural("name_label") track by h.id')
a(xo-click = "ctrl.importVm(remote.id, file, h.id)")
li(ng-repeat = 'sr in ctrl.writable_SRs | orderBy:natural("name_label") track by sr.id')
a(xo-click = "ctrl.importBackup(remote.id, backup, sr.id)")
i.xo-icon-host.fa-fw
| To {{h.name_label}}
| To {{sr.name_label}} ({{sr.size - sr.usage | bytesToSize }})
span &nbsp;{{ (sr.$container | resolve).name_label }}
hr

View File

@@ -28,6 +28,7 @@ export default angular.module('backup.rollingSnapshot', [
this.ready = false
this.running = {}
this.comesForEditing = $stateParams.id
this.scheduleApi = {}
this.formData = {}
@@ -200,6 +201,16 @@ export default angular.module('backup.rollingSnapshot', [
})
}
this.run = schedule => {
this.running[schedule.id] = true
notify.info({
title: 'Run Job',
message: 'One shot running started. See overview for logs.'
})
const id = schedule.job
return xo.job.runSequence([id]).finally(() => delete this.running[schedule.id])
}
this.resetData = () => {
this.formData.allRunning = false
this.formData.allHalted = false

View File

@@ -114,4 +114,6 @@ form#snapform(ng-submit = 'ctrl.save(ctrl.formData.scheduleId, ctrl.formData.sel
td.text-right
button.btn.btn-primary(type = 'button', ng-click = 'ctrl.edit(schedule)'): i.fa.fa-pencil
| &nbsp;
button.btn.btn-warning(type = 'button', ng-click = 'ctrl.run(schedule)', ng-disabled = 'ctrl.running[schedule.id]'): i.fa.fa-play
| &nbsp;
button.btn.btn-danger(type = 'button', ng-click = 'ctrl.delete(schedule)'): i.fa.fa-trash-o

View File

@@ -17,6 +17,10 @@
a(ui-sref = '.backup')
i.fa.fa-fw.fa-download.fa-menu
span.menu-entry Backup
li
a(ui-sref = '.deltaBackup')
i.fa.fa-fw.fa-code-fork.fa-menu
span.menu-entry Delta Backup
li
a(ui-sref = '.restore')
i.fa.fa-fw.fa-upload.fa-menu

View File

@@ -1,6 +1,7 @@
angular = require 'angular'
forEach = require('lodash.foreach')
includes = require('lodash.includes')
Clipboard = require('clipboard')
isoDevice = require('iso-device')
@@ -89,5 +90,13 @@ module.exports = angular.module 'xoWebApp.console', [
$scope.insert = (disc_id) ->
xo.vm.insertCd id, disc_id, true
$scope.vmClipboard = ''
$scope.setClipboard = (text) ->
$scope.vmClipboard = text
$scope.$applyAsync()
clipboard = new Clipboard('.copy')
clipboard.on('error', (e) -> console.log('Clipboard', e))
# A module exports its name.
.name

View File

@@ -15,15 +15,22 @@
//- Toolbar
.list-group-item: .row.text-center
.col-sm-6: iso-device(ng-if = 'VM && SRs', vm = 'VM', srs = 'SRs')
.col-sm-3: button.btn.btn-default(
.col-sm-4: iso-device(ng-if = 'VM && SRs', vm = 'VM', srs = 'SRs')
.col-sm-2: button.btn.btn-default(
ng-click = 'vncRemote.sendCtrlAltDel()'
)
i.fa.fa-keyboard-o
| &nbsp;
| Ctrl+Alt+Del
.col-sm-4
.input-group
input#vm-clipboard.form-control(ng-model='vmClipboard' ng-change='vncRemote.pasteToClipboard(vmClipboard)')
span.input-group-btn
button.btn.btn-default.copy(data-clipboard-target='#vm-clipboard' tooltip="Copy text into local clipboard")
i.fa.fa-clipboard
| Copy
//- Action panel
.col-sm-3
.col-sm-2
.btn-group
button.btn.btn-default.inversed(
ng-if = "VM.power_state == ('Running' || 'Paused')"
@@ -50,5 +57,6 @@
.list-group-item
no-vnc(
url = '{{consoleUrl}}'
remote-control = 'vncRemote'
remote-control = 'vncRemote',
on-clipboard-change = 'setClipboard(clipboardContent)'
)

View File

@@ -115,9 +115,7 @@ export default angular.module('dashboard.dataviz', [
const debouncedPopulate = debounce(populateChartsData, 300, {leading: true, trailing: true})
debouncedPopulate()
xoApi.onUpdate(function () {
debouncedPopulate()
})
$scope.$on('$destroy', xoApi.onUpdate(debouncedPopulate))
})
.controller('DatavizStorageHierarchical', function DatavizStorageHierarchical (xoApi, $scope, $timeout, $interval, $state, bytesToSizeFilter) {
$scope.charts = {
@@ -255,9 +253,7 @@ export default angular.module('dashboard.dataviz', [
const debouncedPopulate = debounce(populateChartsData, 300, {leading: true, trailing: true})
debouncedPopulate()
xoApi.onUpdate(function () {
debouncedPopulate()
})
$scope.$on('$destroy', xoApi.onUpdate(debouncedPopulate))
})
.controller('DatavizRamHierarchical', function DatavizRamHierarchical (xoApi, $scope, $timeout, $state, bytesToSizeFilter) {
$scope.charts = {
@@ -361,9 +357,7 @@ export default angular.module('dashboard.dataviz', [
const debouncedPopulate = debounce(populateChartsData, 300, {leading: true, trailing: true})
debouncedPopulate()
xoApi.onUpdate(function () {
debouncedPopulate()
})
$scope.$on('$destroy', xoApi.onUpdate(debouncedPopulate))
})
// A module exports its name.
.name

View File

@@ -28,7 +28,7 @@ export default angular.module('dashboard.overview', [
template: view
})
})
.controller('Overview', function ($scope, $window, xoApi, xo, $timeout, bytesToSizeFilter) {
.controller('Overview', function ($scope, $window, xoApi, xo, $timeout, bytesToSizeFilter, modal) {
$window.bytesToSize = bytesToSizeFilter // FIXME dirty workaround to custom a Chart.js tooltip template
angular.extend($scope, {
pools: {
@@ -47,6 +47,33 @@ export default angular.module('dashboard.overview', [
cpu: [0, 0],
srs: []
})
$scope.installAllPatches = function () {
modal.confirm({
title: 'Install all the missing patches',
message: 'Are you sure you want to install all the missing patches? This could take a while...'
}).then(() =>
foreach($scope.pools.all, function (pool, pool_id) {
let pool_hosts = $scope.hostsByPool[pool_id]
foreach(pool_hosts, function (host, host_id) {
console.log('Installing all missing patches on host ', host_id)
xo.host.installAllPatches(host_id)
})
})
)
}
$scope.installHostPatches = function (hostId) {
modal.confirm({
title: 'Update host (' + $scope.nbUpdates[hostId] + ' patch(es))',
message: 'Are you sure you want to install all the missing patches on this host? This could take a while...'
}).then(() => {
console.log('Installing all missing patches on host ', hostId)
xo.host.installAllPatches(hostId)
})
}
const nbUpdates = $scope.nbUpdates = {}
function populateChartsData () {
let pools,
vmsByContainer,
@@ -74,11 +101,25 @@ export default angular.module('dashboard.overview', [
srs = []
// update vdi, set them to the right host
pools = xoApi.getView('pools')
$scope.pools = pools = xoApi.getView('pools')
srsByContainer = xoApi.getIndex('srsByContainer')
vmsByContainer = xoApi.getIndex('vmsByContainer')
hostsByPool = xoApi.getIndex('hostsByPool')
$scope.hostsByPool = hostsByPool = xoApi.getIndex('hostsByPool')
foreach(pools.all, function (pool, pool_id) {
let pool_hosts = hostsByPool[pool_id]
foreach(pool_hosts, function (host, host_id) {
if (host_id in nbUpdates) {
return
}
xo.host.listMissingPatches(host_id)
.then(result => {
nbUpdates[host_id] = result.length
}
)
})
})
foreach(pools.all, function (pool, pool_id) {
nb_pools++
@@ -129,9 +170,8 @@ export default angular.module('dashboard.overview', [
const debouncedPopulate = debounce(populateChartsData, 300, {leading: true, trailing: true})
debouncedPopulate()
xoApi.onUpdate(function () {
debouncedPopulate()
})
$scope.$on('$destroy', xoApi.onUpdate(debouncedPopulate))
})
.name

View File

@@ -100,4 +100,34 @@
.progress-condensed
.progress-bar(role="progressbar", aria-valuemin="0", aria-valuenow="{{SR.usage}}", aria-valuemax="{{SR.size}}", style="width: {{[SR.physical_usage, SR.size] | percentage}}", tooltip="Physical usage: {{[SR.physical_usage, SR.size] | percentage}}")
.progress-bar.progress-bar-info(role="progressbar", aria-valuemin="0", aria-valuenow="{{SR.physical_usage}}", aria-valuemax="{{SR.size}}", style="width: {{[(SR.usage-SR.physical_usage), SR.size] | percentage}}", tooltip="Allocated: {{[(SR.usage), SR.size] | percentage}}")
.grid-sm
.grid-cell
.panel.panel-default
.panel-heading.panel-title
i.fa.fa-refresh
| Updates
span.quick-edit(
tooltip="Update all"
ng-click="installAllPatches()"
)
i.fa.fa-download.fa-fw
.panel-body
table.table.table-hover
tr
th Pool
th Host
th Description
th Missing patches
th Install
tbody(ng-repeat="pool in pools.all | orderBy:'name_label'")
tr( ng-repeat="host in hostsByPool[pool.id]" ng-if="nbUpdates[host.id]")
td.oneliner
| {{ pool.name_label }}
td.oneliner
| {{ host.name_label }}
td.oneliner
| {{ host.name_description }}
td {{ nbUpdates[host.id] }}
td
button.btn.btn-success(ng-click="installHostPatches(host.id)" tooltip="Install {{ nbUpdates[host.id] }} patch(es)")
| Update host

View File

@@ -6,6 +6,8 @@ omit = require 'lodash.omit'
sum = require 'lodash.sum'
throttle = require 'lodash.throttle'
find = require 'lodash.find'
filter = require 'lodash.filter'
pluck = require 'lodash.pluck'
#=====================================================================
@@ -213,6 +215,14 @@ module.exports = angular.module 'xoWebApp.host', [
}).then ->
xo.host.stop id
$scope.emergencyShutdownHost = (hostId) ->
modal.confirm({
title: 'Shutdown host'
message: 'Are you sure you want to suspend all the VMs on this host and shut the host down?'
}).then ->
xo.host.emergencyShutdownHost hostId
$scope.saveHost = ($data) ->
{host} = $scope
{name_label, name_description, enabled} = $data
@@ -410,5 +420,23 @@ module.exports = angular.module 'xoWebApp.host', [
netOnly: false,
loadOnly: false
}
$scope.canAdmin = (id = undefined) ->
if id == undefined
id = $scope.host && $scope.host.id
return id && xoApi.canInteract(id, 'administrate') || false
$scope.canOperate = (id = undefined) ->
if id == undefined
id = $scope.host && $scope.host.id
return id && xoApi.canInteract(id, 'operate') || false
$scope.canView = (id = undefined) ->
if id == undefined
id = $scope.host && $scope.host.id
return id && xoApi.canInteract(id, 'view') || false
# A module exports its name.
.name

View File

@@ -13,8 +13,10 @@
.panel-heading.panel-title
i.fa.fa-cogs
| General
span.quick-edit(tooltip="Edit General settings", ng-click="hostSettings.$show()")
span.quick-edit(tooltip="Edit General settings", ng-click="hostSettings.$show()", ng-if = '!hostSettings.$visible && canAdmin()')
i.fa.fa-edit.fa-fw
span.quick-edit(ng-if="hostSettings.$visible", tooltip="Cancel Edition", ng-click="hostSettings.$cancel()")
i.fa.fa-undo.fa-fw
.panel-body
form(editable-form="", name="hostSettings", onbeforesave="saveHost($data)")
dl.dl-horizontal
@@ -186,7 +188,7 @@
i.xo-icon-loading
| &nbsp; Fetching stats...
//- Action panel
.grid-sm
.grid-sm(ng-if = 'canOperate()')
.panel.panel-default
.panel-heading.panel-title
i.fa.fa-flash
@@ -195,35 +197,39 @@
.grid-sm.grid--gutters
.grid.grid-cell
.grid-cell.btn-group
button.btn(tooltip="Add SR", tooltip-placement="top", type="button", style="width: 90%", xo-sref="SRs_new({container: host.id})")
button.btn(tooltip="Add SR", tooltip-placement="top", type="button", style="width: 90%", xo-sref="SRs_new({container: host.id})", ng-if = 'canAdmin()')
i.xo-icon-sr.fa-2x.fa-fw
.grid-cell.btn-group
button.btn(tooltip="Add VM", tooltip-placement="top", type="button", style="width: 90%", xo-sref="VMs_new({container: host.id})")
button.btn(tooltip="Add VM", tooltip-placement="top", type="button", style="width: 90%", xo-sref="VMs_new({container: host.id})", ng-if = 'canAdmin()')
i.xo-icon-vm.fa-2x.fa-fw
.grid-cell.btn-group
button.btn(tooltip="Reboot host", tooltip-placement="top", type="button", style="width: 90%", xo-click="rebootHost(host.id)")
button.btn(tooltip="Reboot host", tooltip-placement="top", type="button", style="width: 90%", xo-click="rebootHost(host.id)", ng-if = 'canOperate()')
i.fa.fa-refresh.fa-2x.fa-fw
.grid-cell.btn-group
button.btn(tooltip="Shutdown host", tooltip-placement="top", type="button", style="width: 90%", xo-click="shutdownHost(host.id)")
button.btn(tooltip="Shutdown host", tooltip-placement="top", type="button", style="width: 90%", xo-click="shutdownHost(host.id)", ng-if = 'canOperate()')
i.fa.fa-power-off.fa-2x.fa-fw
.grid-cell.btn-group
button.btn(tooltip="Suspend all VMs and shutdown host", tooltip-placement="top", type="button", style="width: 90%", xo-click="emergencyShutdownHost(host.id)", ng-if = 'canOperate()')
i.fa.fa-exclamation-triangle.fa-2x.fa-fw
.grid.grid-cell
.grid-cell.btn-group(ng-if="host.enabled")
button.btn(tooltip="Disable host", tooltip-placement="top", type="button", style="width: 90%", xo-click="disableHost(host.id)")
button.btn(tooltip="Disable host", tooltip-placement="top", type="button", style="width: 90%", xo-click="disableHost(host.id)", ng-if = 'canAdmin()')
i.fa.fa-times-circle.fa-2x.fa-fw
.grid-cell.btn-group(ng-if="!host.enabled")
button.btn(tooltip="Enable host", tooltip-placement="top", type="button", style="width: 90%", xo-click="enableHost(host.id)")
button.btn(tooltip="Enable host", tooltip-placement="top", type="button", style="width: 90%", xo-click="enableHost(host.id)", ng-if = 'canAdmin()')
i.fa.fa-check-circle.fa-2x.fa-fw
.grid.grid-cell
.grid-cell.btn-group
button.btn(tooltip="Restart toolstack", tooltip-placement="top", type="button", style="width: 90%", xo-click="restartToolStack(host.id)")
button.btn(tooltip="Restart toolstack", tooltip-placement="top", type="button", style="width: 90%", xo-click="restartToolStack(host.id)", ng-if = 'canAdmin()')
i.fa.fa-retweet.fa-2x.fa-fw
.grid-cell.btn-group(ng-if="pool.name_label && (hostsByPool[pool.id] | count)>1")
button.btn(tooltip="Remove from pool", tooltip-placement="top", style="width: 90%", type="button", xo-click="pool_removeHost(host.id)")
button.btn(tooltip="Remove from pool", tooltip-placement="top", style="width: 90%", type="button", xo-click="pool_removeHost(host.id)", ng-if = 'canAdmin()')
i.fa.fa-cloud-upload.fa-2x.fa-fw
.grid-cell.btn-group.dropdown(
ng-if="pool.name_label && (hostsByPool[pool.id] | count)==1"
dropdown
)
button.btn.dropdown-toggle(
ng-if = 'canAdmin()'
dropdown-toggle
tooltip="Move host to another pool"
tooltip-placement="top"
@@ -238,10 +244,11 @@
i.xo-icon-host.fa-fw
| To {{p.name_label}}
.grid-cell.btn-group(ng-if="!pool.name_label")
button.btn(tooltip="Add to pool", tooltip-placement="top", style="width: 90%", type="button", xo-click="pool_addHost(host.id)")
button.btn(tooltip="Add to pool", tooltip-placement="top", style="width: 90%", type="button", xo-click="pool_addHost(host.id)", ng-if = 'canAdmin()')
i.fa.fa-cloud-download.fa-2x.fa-fw
.grid-cell.btn-group(style="margin-bottom: 0.5em")
button.btn(
ng-if = 'canAdmin()'
tooltip="Import VM"
tooltip-placement="top"
type="button"
@@ -305,14 +312,14 @@
td(ng-if="SRsToPBDs[SR.id].attached")
span.label.label-success Connected
span.pull-right.btn-group.quick-buttons
a(tooltip="Disconnect this SR", xo-click="disconnectPBD(SRsToPBDs[SR.id].id)")
a(tooltip="Disconnect this SR", xo-click="disconnectPBD(SRsToPBDs[SR.id].id)", ng-if = 'canAdmin()')
i.fa.fa-unlink.fa-lg
td(ng-if="!SRsToPBDs[SR.id].attached")
span.label.label-default Disconnected
span.pull-right.btn-group.quick-buttons
a(tooltip="Reconnect this SR", xo-click="connectPBD(SRsToPBDs[SR.id].id)")
a(tooltip="Reconnect this SR", xo-click="connectPBD(SRsToPBDs[SR.id].id)", ng-if = 'canAdmin()')
i.fa.fa-link.fa-lg
a(tooltip="Forget this SR", xo-click="removePBD(SRsToPBDs[SR.id].id)")
a(tooltip="Forget this SR", xo-click="removePBD(SRsToPBDs[SR.id].id)", ng-if = 'canAdmin()')
i.fa.fa-ban.fa-lg
//- Local SR
//- TODO: migrate to SRs and not PBDs when implemented in xo-server spec
@@ -330,14 +337,14 @@
td(ng-if="SRsToPBDs[SR.id].attached")
span.label.label-success Connected
span.pull-right.btn-group.quick-buttons
a(tooltip="Disconnect this SR", xo-click="disconnectPBD(SRsToPBDs[SR.id].id)")
a(tooltip="Disconnect this SR", xo-click="disconnectPBD(SRsToPBDs[SR.id].id)", ng-if = 'canAdmin()')
i.fa.fa-unlink.fa-lg
td(ng-if="!SRsToPBDs[SR.id].attached")
span.label.label-default Disconnected
span.pull-right.btn-group.quick-buttons
a(tooltip="Reconnect this SR", xo-click="connectPBD(SRsToPBDs[SR.id].id)")
a(tooltip="Reconnect this SR", xo-click="connectPBD(SRsToPBDs[SR.id].id)", ng-if = 'canAdmin()')
i.fa.fa-link.fa-lg
a(tooltip="Forget this SR", xo-click="removePBD(SRsToPBDs[SR.id].id)")
a(tooltip="Forget this SR", xo-click="removePBD(SRsToPBDs[SR.id].id)", ng-if = 'canAdmin()')
i.fa.fa-ban.fa-lg
//- Networks/Interfaces panel
.grid-sm
@@ -355,7 +362,7 @@
th.col-md-1 Link status
tr(ng-repeat="PIF in host.$PIFs | resolve | orderBy:natural('name_label') track by PIF.id")
td
| {{PIF.device}}
| {{PIF.device}}&nbsp;
span.label.label-primary(ng-if="PIF.management") XAPI
td
span(ng-if="PIF.vlan > -1")
@@ -368,23 +375,23 @@
td(ng-if="PIF.attached")
span.label.label-success Connected
span.pull-right.btn-group.quick-buttons
a(tooltip="Disconnect this interface", xo-click="disconnectPIF(PIF.id)")
a(tooltip="Disconnect this interface", xo-click="disconnectPIF(PIF.id)", ng-if = 'canAdmin()')
i.fa.fa-unlink.fa-lg
td(ng-if="!PIF.attached")
span.label.label-default Disconnected
span.pull-right.btn-group.quick-buttons
a(tooltip="Connect this interface", xo-click="connectPIF(PIF.id)")
a(tooltip="Connect this interface", xo-click="connectPIF(PIF.id)", ng-if = 'canAdmin()')
i.fa.fa-link.fa-lg
a(tooltip="Remove this interface", xo-click="removePIF(PIF.id)")
a(tooltip="Remove this interface", xo-click="removePIF(PIF.id)", ng-if = 'canAdmin()')
i.fa.fa-trash-o.fa-lg
.text-right
button.btn(type="button", ng-class = '{"btn-success": creatingNetwork, "btn-primary": !creatingNetwork}', ng-click="creatingNetwork = !creatingNetwork")
button.btn(type="button", ng-class = '{"btn-success": creatingNetwork, "btn-primary": !creatingNetwork}', ng-click="creatingNetwork = !creatingNetwork", ng-hide = '!canAdmin()', ng-disabled = '!canAdmin()')
i.fa.fa-plus(ng-if = '!creatingNetwork')
i.fa.fa-minus(ng-if = 'creatingNetwork')
| Create Network
br
form.form-inline.text-right#createNetworkForm(ng-if = 'creatingNetwork', name = 'createNetworkForm', ng-submit = 'createNetwork(newNetworkName, newNetworkDescription, newNetworkPIF, newNetworkMTU, newNetworkVlan)')
fieldset(ng-attr-disabled = '{{ createNetworkWaiting ? true : undefined }}')
fieldset(ng-disabled = 'createNetworkWaiting || !canAdmin()')
.form-group
label(for = 'newNetworkPIF') Interface&nbsp;
select.form-control(ng-model = 'newNetworkPIF', ng-change = 'updateMTU(newNetworkPIF)', ng-options='(PIF | resolve).device for PIF in host.$PIFs')
@@ -439,7 +446,7 @@
| {{task.progress*100 | number:1}}%
td.oneliner
| {{task.name_label}}
span.pull-right.btn-group.quick-buttons
span.pull-right.btn-group.quick-buttons(ng-if = 'canAdmin()')
a(xo-click="cancelTask(task.id)")
i.fa.fa-times.fa-lg(tooltip="Cancel this task")
a(xo-click="destroyTask(task.id)")
@@ -451,7 +458,7 @@
.panel-heading.panel-title
i.fa.fa-comments
| Logs
span.quick-edit(ng-if="host.messages | isNotEmpty", tooltip="Remove all logs", ng-click="deleteAllLog()")
span.quick-edit(ng-if="(host.messages | isNotEmpty) && canAdmin()", tooltip="Remove all logs", ng-click="deleteAllLog()")
i.fa.fa-trash-o.fa-fw
.panel-body
p.center(ng-if="host.messages | isEmpty") No recent logs
@@ -462,7 +469,7 @@
td {{message.time*1e3 | date:"medium"}}
td
| {{message.name}}
span.pull-right.btn-group.quick-buttons
span.pull-right.btn-group.quick-buttons(ng-if = 'canAdmin()')
a(xo-click="deleteLog(message.id)")
i.fa.fa-trash-o.fa-lg(tooltip="Remove this log entry")
.center(ng-if = '(host.messages | count) > 5 || currentLogPage > 1')
@@ -475,7 +482,7 @@
| Patches
span.quick-edit(ng-click="listMissingPatches(host.id)", tooltip="Check for updates")
i.fa.fa-question-circle
span.quick-edit(ng-click="installAllPatches(host.id)", tooltip="Install all the missing patches", style="margin-right:5px")
span.quick-edit(ng-click="installAllPatches(host.id)", tooltip="Install all the missing patches", style="margin-right:5px", ng-if = 'canAdmin()')
i.fa.fa-download
.panel-body
table.table.table-hover(ng-if="poolPatches || updates")
@@ -494,8 +501,9 @@
td.oneliner {{patch.date | date:"medium"}}
td -
td
span(ng-click="installPatch(host.id, patch.uuid)", tooltip="Click to install the patch on this host")
span(ng-click="installPatch(host.id, patch.uuid)", tooltip="Click to install the patch on this host", ng-if = 'canAdmin()')
span.label.label-danger Missing
span.label.label-danger(ng-if = '!canAdmin()') Missing
tr(ng-repeat="patch in poolPatches | map | slice:(5*(currentPatchPage-1)):(5*currentPatchPage)")
td.oneliner {{patch.name}}
td.oneliner {{patch.description}}
@@ -505,8 +513,10 @@
td
span(ng-if="isPoolPatchApplied(patch)")
span.label.label-success Applied
span(ng-click="installPatch(host.id, patch.uuid)", ng-if="!isPoolPatchApplied(patch)", tooltip="Click to apply the patch on this host")
span.label.label-warning Not applied
span(ng-if="!isPoolPatchApplied(patch)")
span(ng-click="installPatch(host.id, patch.uuid)", tooltip="Click to apply the patch on this host", ng-if = 'canAdmin()')
span.label.label-warning Not applied
span.label.label-warning(ng-if = '!canAdmin()') Not applied
.center(ng-if = '(poolPatches | count) > 5 || currentPatchPage > 1')
pagination(boundary-links="true", total-items="poolPatches | count", ng-model="$parent.currentPatchPage", items-per-page="5", max-size="5", class="pagination-sm", previous-text="<", next-text=">", first-text="<<", last-text=">>")

View File

@@ -1,6 +1,7 @@
import angular from 'angular'
import uiRouter from 'angular-ui-router'
import xoTag from 'tag'
import includes from 'lodash.includes'
import xoApi from 'xo-api'
@@ -20,7 +21,7 @@ export default angular.module('xoWebApp.list', [
template: view
})
})
.controller('ListCtrl', function (xoApi) {
.controller('ListCtrl', function (xoApi, $scope, $rootScope) {
this.hosts = xoApi.getView('host')
this.pools = xoApi.getView('pool')
this.SRs = xoApi.getView('SR')
@@ -29,7 +30,84 @@ export default angular.module('xoWebApp.list', [
this.hostsByPool = xoApi.getIndex('hostsByPool')
this.runningHostsByPool = xoApi.getIndex('runningHostsByPool')
this.vmsByContainer = xoApi.getIndex('vmsByContainer')
})
$scope.canView = function (id) {
return xoApi.canInteract(id, 'view')
}
$scope.shouldAppear = (obj) => {
// States
const powerState = obj.power_state
// If there is a search option on the power state (running or halted),
// then objects that do not have a power_state (eg: SRs) are not displayed
if (($scope.states['running'] !== 2 || $scope.states['halted'] !== 2) && !powerState) return false
if (powerState) {
if ($scope.states[powerState.toLowerCase()] === 0) return false
if (($scope.states['running'] === 1 || $scope.states['halted'] === 1) &&
$scope.states[powerState.toLowerCase()] !== 1) return false
}
if ($scope.states['disconnected'] !== 2 && !obj.$PBDs) return false
let disconnected = false
if (obj.$PBDs) {
for (const id of obj.$PBDs) {
const pbd = xoApi.get(id)
disconnected |= !pbd.attached
}
if ($scope.states['disconnected'] === 0 && disconnected) return false
if ($scope.states['disconnected'] === 1 && !disconnected) return false
}
// Types
if ($scope.types[obj.type.toLowerCase()] === 0) return false
if ($scope.types[obj.type.toLowerCase()] === 2 && includes($scope.types, 1)) return false
return true
}
const _initOptions = () => {
$scope.types = {
'host': 2,
'pool': 2,
'sr': 2,
'vm': 2
}
$scope.states = {
'running': 2,
'halted': 2,
'disconnected': 2
}
}
_initOptions()
$scope.parsedListFilter = $scope.listFilter
$rootScope.searchParse = () => {
let keyWords = []
const words = $scope.listFilter ? $scope.listFilter.split(' ') : ['']
_initOptions()
for (const word of words) {
let isOption = word.charAt(0) === '*'
const isNegation = word.charAt(0) === '!'
// as long as there is a '!', it is an option. ie !vm <=> !*vm
isOption = isOption || isNegation
let option = (isNegation ? word.substring(1, word.length) : word).toLowerCase()
option = option.charAt(0) === '*' ? option.substring(1, option.length) : option
if (!isOption) {
if (option !== '') keyWords.push(option)
} else {
if ($scope.types.hasOwnProperty(option)) {
$scope.types[option] = isNegation ? 0 : 1
} else if ($scope.states.hasOwnProperty(option)) {
$scope.states[option] = isNegation ? 0 : 1
}
}
}
$scope.parsedListFilter = keyWords.join(' ')
}
$scope.onClick = (type) => {
$rootScope.options[type.toLowerCase()] = !$rootScope.options[type.toLowerCase()]
$rootScope.updateListFilter(type.toLowerCase())
}
})
// A module exports its name.
.name

View File

@@ -1,7 +1,44 @@
.sub-bar
.grid(style="margin-left:1em")
.btn-group.dropdown(dropdown)
a.btn.navbar-btn.dropdown-toggle.filter(dropdown-toggle)
| Types
i.fa.fa-caret-down
ul.dropdown-menu.inverse(role="menu" style="color:white")
li(
ng-repeat = "type in ['VM', 'SR', 'Host', 'Pool']"
ng-click='onClick(type)'
)
| &nbsp
label(ng-click)
i.fa.fa-square-o(ng-if='!options[type.toLowerCase()]')
i.fa.fa-check-square-o(ng-if='options[type.toLowerCase()]')
| {{type}}
.btn-group.dropdown(dropdown)
a.btn.navbar-btn.dropdown-toggle.filter(dropdown-toggle)
| States
i.fa.fa-caret-down
ul.dropdown-menu.inverse(role="menu" style="color:white")
li(
ng-repeat = "state in ['Running', 'Halted', 'Disconnected']"
ng-click='onClick(state)'
)
| &nbsp
label(ng-click)
i.fa.fa-square-o(ng-if='!options[state.toLowerCase()]')
i.fa.fa-check-square-o(ng-if='options[state.toLowerCase()]')
| {{state}}
//- TODO: print a message when no entries.
//- FIXME: Ugly trick to force the results to be under the sub bar.
div(style="margin-top: 50px; visibility: hidden; height: 0") .
//- If it's a (named) pool.
.grid.flat-object(ng-repeat="pool in list.pools.all | xoHideUnauthorized | filter:listFilter | orderBy:natural('name_label') track by pool.id", ng-if="pool.name_label", xo-sref="pools_view({id: pool.id})")
.grid.flat-object(
ng-repeat="pool in list.pools.all | xoHideUnauthorized | filter:parsedListFilter | orderBy:natural('name_label') track by pool.id"
ng-if="pool.name_label && shouldAppear(pool)"
xo-sref="pools_view({id: pool.id})"
)
//- Icon.
.grid-cell.flat-cell.flat-cell-type
i.xo-icon-pool
@@ -43,7 +80,11 @@
//- /Properties & tags.
//- /Pool.
//- If it's a host.
.grid.flat-object(ng-repeat="host in list.hosts.all | xoHideUnauthorized | filter:listFilter | orderBy:natural('name_label') track by host.id", xo-sref="hosts_view({id: host.id})")
.grid.flat-object(
ng-repeat="host in list.hosts.all | xoHideUnauthorized | filter:parsedListFilter | orderBy:natural('name_label') track by host.id"
ng-if="shouldAppear(host)"
xo-sref="hosts_view({id: host.id})"
)
//- Icon.
.grid-cell.flat-cell.flat-cell-type
i.xo-icon-host(class="xo-color-{{host.power_state | lowercase}}")
@@ -76,7 +117,11 @@
//- /Properties & tags.
//- /Host.
//- If it's a VM.
.grid.flat-object(ng-repeat="VM in list.VMs.all | xoHideUnauthorized | filter:listFilter | orderBy:natural('name_label') track by VM.id", xo-sref="VMs_view({id: VM.id})")
.grid.flat-object(
ng-repeat="VM in list.VMs.all | xoHideUnauthorized | filter:parsedListFilter | orderBy:natural('name_label') track by VM.id"
ng-if="shouldAppear(VM)"
xo-sref="VMs_view({id: VM.id})"
)
//- Icon.
.grid-cell.flat-cell.flat-cell-type
i.xo-icon-vm(class="xo-color-{{VM.power_state | lowercase}}")
@@ -94,14 +139,14 @@
| {{VM.CPUs.number}} vCPUs
.grid-cell.flat-cell
| {{VM.memory.size | bytesToSize}} RAM
.grid-cell.flat-cell(ng-init="container = (VM.$container | resolve)")
.grid-cell.flat-cell(ng-init="container = (VM.$container | resolve)", ng-if="canView((VM.$container | resolve).id)")
div(ng-if="'pool' === container.type")
| Resident on:
a(ui-sref="pools_view({id: container.id})") {{container.name_label}}
div(ng-if="'host' === container.type", ng-init="pool = (container.$poolId | resolve)")
| Resident on:
a(ui-sref="hosts_view({id: container.id})") {{container.name_label}}
small(ng-if="pool.name_label")
small(ng-if="pool.name_label && canView(pool.id)")
| (
a(ui-sref="pools_view({id: pool.id})") {{pool.name_label}}
| )
@@ -116,7 +161,11 @@
//- /Properties & tags.
//- /VM.
//- If it's a SR.
.grid.flat-object(ng-repeat="SR in list.SRs.all | xoHideUnauthorized | filter:listFilter | orderBy:natural('name_label') track by SR.id", xo-sref="SRs_view({id: SR.id})")
.grid.flat-object(
ng-repeat="SR in list.SRs.all | xoHideUnauthorized | filter:parsedListFilter | orderBy:natural('name_label') track by SR.id"
ng-if="shouldAppear(SR)"
xo-sref="SRs_view({id: SR.id})"
)
//- Icon.
.grid-cell.flat-cell.flat-cell-type
i.xo-icon-sr

View File

@@ -3,6 +3,7 @@ import uiRouter from 'angular-ui-router'
import updater from '../updater'
import xoServices from 'xo-services'
import includes from 'lodash.includes'
import view from './view'
@@ -14,7 +15,7 @@ export default angular.module('xoWebApp.navbar', [
updater,
xoServices
])
.controller('NavbarCtrl', function ($state, xoApi, xo, $scope, updater) {
.controller('NavbarCtrl', function ($state, xoApi, xo, $scope, updater, $rootScope) {
this.updater = updater
// TODO: It would make sense to inject xoApi in the scope.
Object.defineProperties(this, {
@@ -31,9 +32,82 @@ export default angular.module('xoWebApp.navbar', [
}
// When a searched is entered, we must switch to the list view if
// necessary.
this.ensureListView = function () {
$state.go('list')
// necessary. When the text field is empty again, we must swith
// to tree view
let timeout
$scope.ensureListView = function (listFilter) {
clearTimeout(timeout)
timeout = window.setTimeout(function () {
$state.go('list').then(() =>
$rootScope.searchParse(),
$scope.updateOptions()
)
}, 400)
}
const _isOption = function (word, option) {
if (word === '*' + option || word === '!' + option || word === '!*' + option) {
return true
}
return false
}
const _removeOption = function (option) {
if (!$scope.$root.listFilter) {
return
}
const words = $scope.$root.listFilter.split(' ')
$scope.$root.listFilter = ''
for (const word of words) {
if (!_isOption(word, option) && word !== '') {
$scope.$root.listFilter += word + ' '
}
}
}
const _addOption = function (option) {
if (!$scope.$root.listFilter) {
$scope.$root.listFilter = '*' + option + ' '
return
}
const words = $scope.$root.listFilter.split(' ')
if (!includes(words, '*' + option) && !includes(words, '!' + option) && !includes(words, '!*' + option)) {
if ($scope.$root.listFilter.charAt($scope.$root.listFilter.length - 1) !== ' ') {
$scope.$root.listFilter += ' '
}
$scope.$root.listFilter += '*' + option + ' '
}
}
$rootScope.options = {
'vm': false,
'sr': false,
'host': false,
'pool': false,
'running': false,
'halted': false,
'disconnected': false
}
// Checkboxes --> Text
// Update text field after a checkbox has been clicked
$rootScope.updateListFilter = function (option) {
if ($rootScope.options[option]) {
_addOption(option)
} else {
_removeOption(option)
}
$scope.ensureListView($scope.$root.listFilter)
}
// Text --> Checkboxes
// Update checkboxes after the text field has been changed
$scope.updateOptions = function () {
const words = $scope.$root.listFilter ? $scope.$root.listFilter.split(' ') : ['']
for (const opt in $rootScope.options) {
$rootScope.options[opt] = false
for (let word of words) {
if (_isOption(word, opt)) {
$rootScope.options[opt] = true
}
}
}
}
this.tasks = xoApi.getView('runningTasks')

View File

@@ -21,12 +21,12 @@ nav.navbar.navbar-inverse.navbar-fixed-top(role = 'navigation')
type = 'text'
placeholder = ''
ng-model = '$root.listFilter'
ng-change = 'navbar.ensureListView()'
ng-change = 'ensureListView($root.listFilter)'
)
span.input-group-btn
button.btn.btn-search(
type = 'button'
ng-click = 'navbar.ensureListView()'
ng-click = 'ensureListView($root.listFilter)'
)
i.fa.fa-search
//- /Search form.
@@ -92,15 +92,15 @@ nav.navbar.navbar-inverse.navbar-fixed-top(role = 'navigation')
a(ui-sref="dashboard.index")
i.fa.fa-dashboard
| Dashboard
//- li.disabled(ui-sref-active="active")
//- a(ui-sref="graph")
//- i.fa.fa-sitemap
//- | Graphs view
li.divider
li(ng-class = '{ disabled: navbar.user.permission !== "admin" }')
a(ui-sref = 'backup.index')
i.fa.fa-archive
| Backup
li
a(ui-sref = 'taskscheduler.index')
i.fa.fa-cogs
| Job Manager
li.divider
li(
ui-sref-active = 'active'

View File

@@ -11,16 +11,28 @@ module.exports = angular.module 'xoWebApp.newVm', [
.config ($stateProvider) ->
$stateProvider.state 'VMs_new',
url: '/vms/new/:container'
controller: 'NewVmsCtrl'
controller: 'NewVmsCtrl as ctrl'
template: require './view'
.controller 'NewVmsCtrl', (
$scope, $stateParams, $state
xoApi, xo
bytesToSizeFilter, sizeToBytesFilter
bytesToSizeFilter
notify
) ->
{get} = xoApi
$scope.configDriveActive = false
existingDisks = {}
$scope.saveChange = (position, propertyName, value) ->
if not existingDisks[position]?
existingDisks[position] = {}
existingDisks[position][propertyName] = value
$scope.initExistingDiskSizes = (template) ->
sizes = {}
forEach xoApi.get(template.$VBDs), (VBD) ->
sizes[VBD.position] = bytesToSizeFilter xoApi.get(VBD.VDI).size
$scope.existingDiskSizes = sizes
{get} = xoApi
removeItems = do ->
splice = Array::splice.call.bind Array::splice
(array, index, n) -> splice array, index, n ? 1
@@ -103,7 +115,6 @@ module.exports = angular.module 'xoWebApp.newVm', [
default_SR = get pool.default_SR
default_SR = if default_SR then default_SR.id else ''
)
$scope.availableMethods = {}
$scope.CPUs = ''
$scope.pv_args = ''
@@ -114,9 +125,11 @@ module.exports = angular.module 'xoWebApp.newVm', [
$scope.name_description = ''
$scope.name_label = ''
$scope.template = ''
$scope.firstSR = ''
$scope.VDIs = []
$scope.VIFs = []
$scope.isDiskTemplate = false
$scope.cloudConfigSshKey = ''
$scope.addVIF = do ->
id = 0
@@ -150,6 +163,8 @@ module.exports = angular.module 'xoWebApp.newVm', [
# When the selected template changes, updates other variables.
$scope.$watch 'template', (template) ->
return unless template
# After each template change, initialize coreOsCloudConfig to empty
$scope.coreOsCloudConfig = ''
{install_methods} = template.template_info
availableMethods = $scope.availableMethods = Object.create null
@@ -171,8 +186,14 @@ module.exports = angular.module 'xoWebApp.newVm', [
else $scope.isDiskTemplate = false
for VDI in VDIs
VDI.id = VDI_id++
VDI.size = bytesToSizeFilter VDI.size
VDI.SR or= default_SR
VDI.size = bytesToSizeFilter VDI.size
# if the template is labeled CoreOS
# we'll use config drive setup
if template.name_label == 'CoreOS'
return xo.vm.getCloudInitConfig template.id
.then (result) ->
$scope.coreOsCloudConfig = result
$scope.createVM = ->
{
@@ -191,14 +212,16 @@ module.exports = angular.module 'xoWebApp.newVm', [
# Does not edit the displayed data directly.
VDIs = cloneDeep VDIs
for VDI, index in VDIs
# store the first VDI's SR for later use (e.g: coreOsCloudConfig)
if VDI.id == 0
$scope.firstSR = VDI.SR or default_SR
# Removes the dummy identifier used for AngularJS.
delete VDI.id
# Adds the device number based on the index.
VDI.device = "#{index}"
# Transforms the size from human readable format to bytes.
VDI.size = sizeToBytesFilter VDI.size
# TODO: handles invalid values.
# Does not edit the displayed data directly.
@@ -240,6 +263,7 @@ module.exports = angular.module 'xoWebApp.newVm', [
template: template.id
VDIs
VIFs
existingDisks
}
# TODO:
@@ -249,7 +273,6 @@ module.exports = angular.module 'xoWebApp.newVm', [
title: 'VM creation'
message: 'VM creation started'
}
xoApi.call('vm.create', data).then (id) ->
# If nothing to sets, just stops.
return id unless CPUs or name_description or memory
@@ -266,12 +289,27 @@ module.exports = angular.module 'xoWebApp.newVm', [
data.pv_args = pv_args
if memory
memory = sizeToBytesFilter memory
# FIXME: handles invalid entries.
data.memory = memory
xoApi.call('vm.set', data).then -> id
return xoApi.call('vm.set', data).then -> id
.then (id) ->
# If a CloudConfig drive needs to be created
if $scope.coreOsCloudConfig
# Use the CoreOS specific Cloud Config creation
xo.vm.createCloudInitConfigDrive(id, $scope.firstSR, $scope.coreOsCloudConfig, true).then ->
xo.docker.register id
if $scope.configDriveActive
# User creation is less universal...
# $scope.cloudContent = '#cloud-config\nhostname: ' + name_label + '\nusers:\n - name: olivier\n sudo: ALL=(ALL) NOPASSWD:ALL\n groups: sudo\n shell: /bin/bash\n ssh_authorized_keys:\n - ' + $scope.cloudConfigSshKey + '\n'
# So keep it basic for now: hostname and ssh key
$scope.cloudContent = '#cloud-config\nhostname: ' + name_label + '\nssh_authorized_keys:\n - ' + $scope.cloudConfigSshKey + '\n'
# The first SR for a template with an existing disk
$scope.firstSR = (get (get template.$VBDs[0]).VDI).$SR
# Use the generic CloudConfig creation
xo.vm.createCloudInitConfigDrive(id, $scope.firstSR, $scope.cloudContent)
# Send the client on the VM view
$state.go 'VMs_view', { id }
.catch (error) ->
notify.error {

View File

@@ -2,11 +2,11 @@
.panel.panel-default
p.page-title
i.xo-icon-vm
| Create VM on&nbsp;
| Create VM on
a(ng-if="'pool' === container.type", ui-sref="pools_view({id: container.id})")
| {{container.name_label}}
| {{container.name_label}}
a(ng-if="'host' === container.type", ui-sref="hosts_view({id: container.id})")
| {{container.name_label}}
| {{container.name_label}}
//- Add server panel
form.form-horizontal(ng-submit="createVM()")
.grid
@@ -18,7 +18,7 @@ form.form-horizontal(ng-submit="createVM()")
.form-group
label.col-sm-3.control-label Template
.col-sm-9
select.form-control(ng-model="template", ng-options="template.name_label for template in templates | orderBy:natural('name_label') track by template.id", required="")
select.form-control(ng-model="template", ng-options="template.name_label for template in templates | orderBy:natural('name_label') track by template.id", required="", ng-change = 'initExistingDiskSizes(template)')
.form-group
label.col-sm-3.control-label Name
.col-sm-9
@@ -47,12 +47,24 @@ form.form-horizontal(ng-submit="createVM()")
| Template info
.panel-body
p.center This template will create automatically a VM with:
.col-md-6
ul(ng-repeat="VIF in template.VIFs | resolve | orderBy:natural('device') track by VIF.id")
li Interface \#{{VIF.device}} (MTU {{VIF.MTU}}) on {{(VIF.$network | resolve).name_label}}
.col-md-6
ul(ng-repeat = 'VBD in (template.$VBDs | resolve) track by VBD.id')
li Disk {{(VBD.VDI | resolve).name_label}} ({{(VBD.VDI | resolve).size | bytesToSize}}) on {{((VBD.VDI | resolve).$SR | resolve).name_label}}
.panel.panel-default
.panel-heading.panel-title
i.fa.fa-cloud
| Config Drive
span.pull-right
label(style = 'cursor: pointer;')
input.hidden(type = 'checkbox', ng-model = '$parent.configDriveActive')
i.fa(ng-class = '{"fa-toggle-on": $parent.configDriveActive, "text-success": $parent.configDriveActive, "fa-toggle-off": !$parent.configDriveActive}', style = 'font-size: 1.5em;')
.panel-body
fieldset(ng-disabled = '!$parent.configDriveActive')
.form-group
label.col-sm-3.control-label SSH Key
.col-sm-9
input.form-control(type="text", placeholder="ssh-rsa AAAA.... you@machine", ng-model="$parent.cloudConfigSshKey", name="cloudConfigSshKey", required)
.grid(ng-if="!isDiskTemplate")
//- Install panel
.panel.panel-default
@@ -154,8 +166,20 @@ form.form-horizontal(ng-submit="createVM()")
i.fa.fa-plus
| Add interface
//- end of misc and interface panel
//- Cloud config panel
.grid(ng-if = 'cloudConfig')
.panel.panel-default
.panel-heading.panel-title
i.fa.fa-cloud
| Cloud config
.pull-right.small
button.btn.btn-default(type = 'button', ng-click = 'isExpanded = !isExpanded'): i.fa(ng-class = '{"fa-plus": !isExpanded, "fa-minus": isExpanded}')
.panel-body
textarea.form-control(rows="20", collapse= '!isExpanded', ng-model='$parent.cloudConfig', name='cloudConfig')
| {{cloudConfig}}
//- Disk panel
.grid(ng-if="!isDiskTemplate")
.grid
.panel.panel-default
.panel-heading.panel-title
i.xo-icon-disk
@@ -170,13 +194,25 @@ form.form-horizontal(ng-submit="createVM()")
th.col-md-4 Description
th.col-md-1 &#160;
//- Buttons
tr(ng-repeat="VBD in (template.$VBDs | resolve) track by VBD.id", ng-if="isDiskTemplate")
td
select.form-control(ng-model="(VBD.VDI | resolve).$SR", ng-options="SR.id as (SR.name_label + ' (' + (SR.size - SR.usage | bytesToSize) + ' free)') for SR in (writable_SRs | orderBy:natural('name_label'))", ng-change = 'saveChange(VBD.position, "$SR", (VBD.VDI | resolve).$SR)')
td.text-center
i.fa.fa-check(ng-if = 'VBD.bootable')
td
input.form-control(type="text", ng-model="existingDiskSizes[VBD.position]", ng-readonly = '!configDriveActive', ng-change = 'saveChange(VBD.position, "size", (existingDiskSizes[VBD.position]))', placeholder = "Size e.g 128 MiB, 8 GiB, 2 TiB...")
td
input.form-control(type="text", placeholder="Name of this virtual disk", ng-model="(VBD.VDI | resolve).name_label", ng-change = 'saveChange(VBD.position, "name_label", (VBD.VDI | resolve).name_label)')
td
input.form-control(type="text", placeholder="Description of this virtual disk", ng-model="(VBD.VDI | resolve).name_description", ng-change = 'saveChange(VBD.position, "name_description", (VBD.VDI | resolve).name_description)')
td
tr(ng-repeat="VDI in VDIs track by VDI.id")
td
select.form-control(ng-model="VDI.SR", ng-options="SR.id as (SR.name_label + ' (' + (SR.size - SR.usage | bytesToSize) + ' free)') for SR in (writable_SRs | orderBy:natural('name_label'))")
td.text-center
input(type="checkbox", ng-model="VDI.bootable")
td
input.form-control(type="text", ng-model="VDI.size", required="")
input.form-control(type="text", ng-model="VDI.size", required="", placeholder = "Size e.g 128 MiB, 8 GiB, 2 TiB...")
td
input.form-control(type="text", placeholder="Name of this virtual disk", ng-model="VDI.name_label")
td
@@ -230,6 +266,10 @@ form.form-horizontal(ng-submit="createVM()")
p.center.big(tooltip="Network interfaces")
| {{(VIFs.length) || (template.VIFs.length) || 0}}x&nbsp;
i.xo-icon-network
p.center(ng-if="isDiskTemplate")
| Cloud configuration is&nbsp;
strong.text-success(ng-if = 'configDriveActive') enabled.
strong.text-danger(ng-if = '!configDriveActive') disabled.
p.center
button.btn.btn-lg.btn-primary(type="submit")
i.fa.fa-play

View File

@@ -75,6 +75,16 @@ export default angular.module('xoWebApp.pool', [
})
}
$scope.setDefaultSr = function (id) {
let {pool} = $scope
return modal.confirm({
title: 'Set default SR',
message: 'Are you sure you want to set this SR as default?'
}).then(function () {
return xo.pool.setDefaultSr(pool.id, id)
})
}
$scope.deleteLog = function (id) {
console.log('Remove log', id)
return xo.log.delete(id)

View File

@@ -114,11 +114,14 @@
th Type
th Size
th.col-md-4 Physical/Allocated usage
th.col-md-1 Action
tr(
ng-repeat="SR in srs | map | orderBy:natural('name_label') track by SR.id"
xo-sref="SRs_view({id: SR.id})"
)
td.oneliner {{SR.name_label}}
td.oneliner
| {{SR.name_label}}&nbsp;
span.label.label-primary(ng-if="SR.id === pool.default_SR") Default SR
td.oneliner {{SR.name_description}}
td {{SR.SR_type}}
td {{SR.size | bytesToSize}}
@@ -126,6 +129,10 @@
.progress-condensed
.progress-bar(role="progressbar", aria-valuemin="0", aria-valuenow="{{SR.usage}}", aria-valuemax="{{SR.size}}", style="width: {{[SR.physical_usage, SR.size] | percentage}}", tooltip="Physical usage: {{[SR.physical_usage, SR.size] | percentage}}")
.progress-bar.progress-bar-info(role="progressbar", aria-valuemin="0", aria-valuenow="{{SR.physical_usage}}", aria-valuemax="{{SR.size}}", style="width: {{[(SR.usage-SR.physical_usage), SR.size] | percentage}}", tooltip="Allocated: {{[(SR.usage), SR.size] | percentage}}")
td
span.pull-right.btn-group.quick-buttons
a(ng-if="SR.id !== pool.default_SR", xo-click="setDefaultSr(SR.id)")
i.fa.fa-hdd-o.fa-lg(tooltip="Set as default SR")
//- Logs panel
.grid-sm

View File

@@ -26,12 +26,12 @@ function loadDefaults (schema, configuration) {
}
forEach(schema.properties, (item, key) => {
if (item.type === 'boolean' && !(key in configuration)) { // String default values are used as placeholders in view
configuration[key] = item && item.default
configuration[key] = Boolean(item && item.default)
}
})
}
function cleanUpConfiguration (schema, configuration) {
function cleanUpConfiguration (schema, configuration, dump = {}) {
if (!schema || !configuration) {
return
}
@@ -44,20 +44,25 @@ function cleanUpConfiguration (schema, configuration) {
}
function keepItem (item) {
if (item === undefined || item === null || item === '' || (Array.isArray(item) && item.length === 0) || item.__use === false) {
return false
} else {
return true
}
return !(item == null || item === '' || (Array.isArray(item) && item.length === 0))
}
forEach(configuration, (item, key) => {
item = sanitizeItem(item)
configuration[key] = item
dump[key] = item
if (!keepItem(item) || !schema.properties || !(key in schema.properties)) {
delete configuration[key]
} else if (schema.properties && schema.properties[key] && schema.properties[key].type === 'object') {
cleanUpConfiguration(schema.properties[key], item)
delete dump[key]
} else if (schema.properties && schema.properties[key]) {
const type = schema.properties[key].type
if (type === 'integer' || type === 'number') {
dump[key] = +dump[key]
} else if (type === 'object') {
dump[key] = {}
cleanUpConfiguration(schema.properties[key], item, dump[key])
}
}
})
}
@@ -80,7 +85,7 @@ export default angular.module('settings.plugins', [
template: view
})
})
.controller('SettingsPlugins', function (xo, notify) {
.controller('SettingsPlugins', function (xo, notify, modal) {
this.disabled = {}
const refreshPlugins = () => xo.plugin.get().then(plugins => {
@@ -100,21 +105,51 @@ export default angular.module('settings.plugins', [
this.disabled[id] = true
return xo.plugin[method](...args)
.finally(() => {
return refreshPlugins()
.then(() => this.disabled[id] = false)
this.disabled[id] = false
})
}
this.isRequired = isRequired
this.isPassword = isPassword
this.configure = (plugin) => {
cleanUpConfiguration(plugin.configurationSchema, plugin.configuration)
_execPluginMethod(plugin.id, 'configure', plugin.id, plugin.configuration)
const newConfiguration = {}
plugin.errors = []
cleanUpConfiguration(plugin.configurationSchema, plugin.configuration, newConfiguration)
_execPluginMethod(plugin.id, 'configure', plugin.id, newConfiguration)
.then(() => notify.info({
title: 'Plugin configuration',
message: 'Successfully saved'
}))
.catch(err => {
forEach(err.data, data => {
const fieldPath = data.field.split('.').slice(1)
const fieldPathTitles = []
let groupObject = plugin.configurationSchema
forEach(fieldPath, groupName => {
groupObject = groupObject.properties[groupName]
fieldPathTitles.push(groupObject.title || groupName)
})
plugin.errors.push(`${fieldPathTitles.join(' > ')} ${data.message}`)
})
})
}
this.purgeConfiguration = (plugin) => {
modal.confirm({
title: 'Purge configuration',
message: 'Are you sure you want to purge this configuration ?'
}).then(() => {
_execPluginMethod(plugin.id, 'purgeConfiguration', plugin.id).then(() => {
notify.info({
title: 'Purge configuration',
message: 'This plugin config is now purged.'
})
})
})
}
this.toggleAutoload = (plugin) => {
let method
if (!plugin._autoload && plugin.autoload) {
@@ -172,7 +207,7 @@ export default angular.module('settings.plugins', [
}
})
.directive('objectInput', () => {
.directive('confObjectInput', () => {
return {
restrict: 'E',
template: objectInputView,
@@ -181,12 +216,12 @@ export default angular.module('settings.plugins', [
schema: '=',
required: '='
},
controller: 'ObjectInput as ctrl',
controller: 'ConfObjectInput as ctrl',
bindToController: true
}
})
.controller('ObjectInput', function ($scope, xo, xoApi) {
.controller('ConfObjectInput', function ($scope, xo, xoApi) {
const prepareModel = () => {
if (this.model === undefined || this.model === null) {
this.model = {

View File

@@ -5,10 +5,25 @@
fieldset(ng-disabled = '!ctrl.required && !ctrl.model.__use', ng-hide = '!ctrl.required && !ctrl.model.__use')
ul(style = 'padding-left: 0;')
li.list-group-item(ng-repeat = '(key, value) in ctrl.schema.properties track by key')
.input-group
.input-group(ng-if = 'value.type != "boolean"')
span.input-group-addon
| {{key}}
| {{value.title || key}}
span.text-warning(ng-if = 'ctrl.isRequired(key, ctrl.schema)') *
input.form-control.input-sm(ng-if = '!ctrl.isPassword(key)', type = 'text', ng-model = 'ctrl.model[key]', ng-required = 'ctrl.isRequired(key, ctrl.schema)')
input.form-control.input-sm(ng-if = 'ctrl.isPassword(key)', type = 'password', ng-model = 'ctrl.model[key]', ng-required = 'ctrl.isRequired(key, ctrl.schema)')
input.form-control.input-sm(
ng-if = 'value.type != "number" && value.type != "integer"',
type = '{{ctrl.isPassword(key) ? "password" : "text"}}',
ng-model = 'ctrl.model[key]',
ng-required = 'ctrl.isRequired(key, ctrl.schema)'
)
input.form-control.input-sm(
ng-if = 'value.type == "number" || value.type == "integer"',
type = 'number',
ng-model = 'ctrl.model[key]',
ng-required = 'ctrl.isRequired(key, ctrl.schema)'
)
.form-inline(ng-if = 'value.type == "boolean"')
.checkbox.small('style="color: #31708F;"') {{value.title || key}}&nbsp;:&nbsp;
label('style="color: #A7AFB0;"')
i.fa.fa-2x(ng-class = '{"fa-toggle-on": ctrl.model[key], "fa-toggle-off": !ctrl.model[key]}')
input.hidden(type = 'checkbox', ng-model = 'ctrl.model[key]')
.help-block(ng-bind-html = 'ctrl.schema.properties[key].description | md2html')

View File

@@ -6,7 +6,7 @@
.grid-sm
.panel.panel-default
.panel-body
p.text-center(ng-if = '!ctrl.plugins || !crtl.plugins.length') No plugins found
p.text-center(ng-if = '!ctrl.plugins || !ctrl.plugins.length') No plugins found
div(ng-repeat = 'plugin in ctrl.plugins | orderBy:"name" track by plugin.id')
h3.form-inline.clearfix
span.text-info {{ plugin.name }}&nbsp;
@@ -30,19 +30,28 @@
form.form-horizontal(ng-if = 'plugin.configurationSchema', ng-submit = 'ctrl.configure(plugin)')
fieldset(ng-disabled = 'ctrl.disabled[plugin.id]')
.form-group(ng-repeat = '(key, prop) in plugin.configurationSchema.properties')
label.col-md-2.control-label
| {{key}}
label.col-md-2.control-label
| {{prop.title || key}}
span.text-warning(ng-if = 'ctrl.isRequired(key, plugin.configurationSchema)') *
.col-md-5
input.form-control(ng-if = 'prop.type === "string" && !ctrl.isPassword(key)', type = 'text', ng-model = 'plugin.configuration[key]', ng-required = 'ctrl.isRequired(key, plugin.configurationSchema)', placeholder = '{{ plugin.configurationSchema.properties[key].default }}')
input.form-control(ng-if = 'prop.type === "string" && ctrl.isPassword(key)', type = 'password', ng-model = 'plugin.configuration[key]', ng-required = 'ctrl.isRequired(key, plugin.configurationSchema)')
input.form-control(ng-if = 'prop.type === "integer" || prop.type === "number"', type = 'number', ng-model = 'plugin.configuration[key]', ng-required = 'ctrl.isRequired(key, plugin.configurationSchema)', placeholder = '{{ plugin.configurationSchema.properties[key].default }}')
input.form-control(ng-if = 'prop.type === "string"', type = '{{ ctrl.isPassword(key) ? "password" : "text" }}', ng-model = 'plugin.configuration[key]', ng-required = 'ctrl.isRequired(key, plugin.configurationSchema)')
multi-string-input(ng-if = 'prop.type === "array" && prop.items.type === "string"', model = 'plugin.configuration[key]')
.checkbox(ng-if = 'prop.type === "boolean"'): label: input(type = 'checkbox', ng-model = 'plugin.configuration[key]')
object-input(ng-if = 'prop.type === "object"', model = 'plugin.configuration[key]', schema = 'prop', required = 'ctrl.isRequired(key, plugin.configurationSchema)')
conf-object-input(ng-if = 'prop.type === "object"', model = 'plugin.configuration[key]', schema = 'prop', required = 'ctrl.isRequired(key, plugin.configurationSchema)')
.col-md-5
span.help-block(ng-bind-html = 'prop.description | md2html')
.form-group
.col-md-offset-2.col-md-10.text-danger(ng-repeat = "err in plugin.errors")
| {{ err }}
.form-group
.col-md-offset-2.col-md-10
button.btn.btn-primary(type = 'submit')
| Save configuration&nbsp;
i.fa.fa-floppy-o
.btn-toolbar
.btn-group
button.btn.btn-primary(type = 'submit')
| Save configuration&nbsp;
i.fa.fa-floppy-o
.btn-group
button.btn.btn-danger(type = 'button' ng-click = 'ctrl.purgeConfiguration(plugin)')
| Purge configuration&nbsp;
i.fa.fa-trash-o

View File

@@ -1,4 +1,6 @@
import angular from 'angular'
import forEach from 'lodash.foreach'
import includes from 'lodash.includes'
import uiRouter from 'angular-ui-router'
import uiSelect from 'angular-ui-select'
@@ -26,18 +28,28 @@ export default angular.module('settings.servers', [
template: view
})
})
.controller('SettingsServers', function ($scope, $interval, servers, xoApi, xo, notify) {
this.servers = servers
.controller('SettingsServers', function ($scope, $rootScope, $interval, $filter, servers, xoApi, xo, notify) {
const orderBy = $filter('orderBy')
this.servers = orderBy(servers, $rootScope.natural('host'))
$scope.readOnly = {}
forEach(this.servers, (server) => {
$scope.readOnly[server.id] = Boolean(server.readOnly)
})
const selected = this.selectedServers = {}
const newServers = this.newServers = []
const refreshServers = () => {
xo.server.getAll().then(servers => {
this.servers = servers
this.servers = orderBy(servers, $rootScope.natural('host'))
})
}
const refreshServersIfUnfocused = () => {
if (!$scope.isFocused) {
refreshServers()
}
}
const interval = $interval(refreshServers, 10e3)
const interval = $interval(refreshServersIfUnfocused, 10e3)
$scope.$on('$destroy', () => {
$interval.cancel(interval)
})
@@ -73,6 +85,9 @@ export default angular.module('settings.servers', [
this.addServer()
this.saveServers = () => {
const addresses = []
forEach(xoApi.getView('host').all, host => addresses.push(host.address))
const newServers = this.newServers
const servers = this.servers
const updateServers = []
@@ -87,6 +102,7 @@ export default angular.module('settings.servers', [
if (!server.password) {
delete server.password
}
server.readOnly = $scope.readOnly[id]
xo.server.set(server)
delete server.password
updateServers.push(server)
@@ -94,17 +110,26 @@ export default angular.module('settings.servers', [
}
for (let i = 0, len = newServers.length; i < len; i++) {
const server = newServers[i]
const {host, username, password} = server
const {host, username, password, readOnly} = server
if (!host) {
continue
}
if (includes(addresses, host)) {
notify.warning({
title: 'Server already connected',
message: `You are already connected to ${host}`
})
continue
}
xo.server.add({
host,
username,
password,
readOnly,
autoConnect: false
}).then(function (id) {
server.id = id
$scope.readOnly[id] = Boolean(readOnly)
xo.server.connect(id).catch(error => {
notify.error({
title: 'Server connection error',

View File

@@ -10,11 +10,12 @@
tr
th.col-md-5 Host
th.col-md-2 User
th.col-md-3 Password
th.col-md-2 Password
th.col-md-1.text.center Actions
th.col-md-1.text.center Read only
th.col-md-1.text-center
i.fa.fa-trash-o.fa-lg(tooltip="Forget server")
tr(ng-repeat="server in ctrl.servers | orderBy:natural('host') track by server.id")
tr(ng-repeat="server in ctrl.servers track by server.id")
td
.input-group
span.input-group-addon.hidden-xs(ng-if="server.status === 'connected'")
@@ -23,11 +24,27 @@
i.xo-icon-failure.fa-lg(tooltip="Disconnected")
span.input-group-addon.hidden-xs(ng-if="server.status === 'connecting'")
i.fa.fa-cog.fa-lg.fa-spin(tooltip="Connecting...")
input.form-control(type="text", ng-model="server.host")
input.form-control(
type="text",
ng-model="server.host",
ng-focus="$parent.isFocused = true",
ng-blur="$parent.isFocused = false"
)
td
input.form-control(type="text", ng-model="server.username")
input.form-control(
type="text",
ng-model="server.username",
ng-focus="$parent.isFocused = true",
ng-blur="$parent.isFocused = false"
)
td
input.form-control(type="password", ng-model="server.password", placeholder="Fill to change the password")
input.form-control(
type="password",
ng-model="server.password",
placeholder="Fill to change the password",
ng-focus="$parent.isFocused = true",
ng-blur="$parent.isFocused = false"
)
td.text-center
button.btn.btn-default(
ng-if="server.status === 'disconnected'",
@@ -43,6 +60,8 @@
tooltip="Disconnect this server"
)
i.fa.fa-unlink
td.text-center
input(type="checkbox", ng-model="readOnly[server.id]")
td.text-center
input(type="checkbox", ng-model="ctrl.selectedServers[server.id]")
tr(ng-repeat="server in ctrl.newServers")
@@ -67,6 +86,8 @@
placeholder="password"
)
td &#160;
td.text-center
input( type="checkbox", ng-model="server.readOnly")
td &#160;
p.text-center
button.btn.btn-primary(type="submit")

View File

@@ -44,7 +44,15 @@ export default angular.module('xoWebApp.sr', [
let {get} = xoApi
$scope.$watch(() => xoApi.get($stateParams.id), function (SR) {
const VDIs = []
if (SR) {
forEach(SR.VDIs, vdi => {
vdi = xoApi.get(vdi)
vdi && VDIs.push({...vdi, size: bytesToSizeFilter(vdi.size)})
})
}
$scope.SR = SR
$scope.VDIs = VDIs
})
$scope.saveSR = function ($data) {
@@ -176,6 +184,7 @@ export default angular.module('xoWebApp.sr', [
$scope.saveDisks = function (data) {
// Group data by disk.
let disks = {}
let sizeChanges = false
forEach(data, function (value, key) {
let i = key.indexOf('/')
@@ -185,27 +194,50 @@ export default angular.module('xoWebApp.sr', [
;(disks[id] || (disks[id] = {}))[prop] = value
})
let promises = []
forEach(disks, function (attributes, id) {
// Keep only changed attributes.
let disk = get(id)
forEach(attributes, function (value, name) {
if (value === disk[name]) {
delete attributes[name]
}
})
if (!isEmpty(attributes)) {
// Inject id.
attributes.id = id
// Ask the server to update the object.
promises.push(xoApi.call('vdi.set', attributes))
if (attributes.size !== bytesToSizeFilter(disk.size)) { // /!\ attributes are provided by a modified copy of disk
sizeChanges = true
return false
}
})
return $q.all(promises)
let promises = []
const preCheck = sizeChanges ? modal.confirm({
title: 'Disk resizing',
message: 'Growing the size of a disk is not reversible'
}) : $q.resolve()
return preCheck
.then(() => {
forEach(disks, function (attributes, id) {
let disk = get(id)
// Resize disks
if (attributes.size !== bytesToSizeFilter(disk.size)) { // /!\ attributes are provided by a modified copy of disk
promises.push(xo.disk.resize(id, attributes.size))
}
delete attributes.size
// Keep only changed attributes.
forEach(attributes, function (value, name) {
if (value === disk[name]) {
delete attributes[name]
}
})
if (!isEmpty(attributes)) {
// Inject id.
attributes.id = id
// Ask the server to update the object.
promises.push(xoApi.call('vdi.set', attributes))
}
})
return $q.all(promises)
})
}
})

View File

@@ -8,8 +8,10 @@
.panel-heading.panel-title
i.fa.fa-cogs
| General
span.quick-edit(tooltip="Edit General settings", ng-click="srSettings.$show()")
span.quick-edit(tooltip="Edit General settings", ng-click="srSettings.$show()", ng-if = '!srSettings.$visible')
i.fa.fa-edit.fa-fw
span.quick-edit(tooltip="Cancel Edition", ng-click="srSettings.$cancel()", ng-if = 'srSettings.$visible')
i.fa.fa-undo.fa-fw
.panel-body
form(editable-form="", name="srSettings", onbeforesave="saveSR($data)")
dl.dl-horizontal
@@ -109,8 +111,18 @@
.panel-heading.panel-title
i.xo-icon-disk
| Virtual disks
span.quick-edit(tooltip="Edit disks", ng-click="disksForm.$show()")
span.quick-edit(
ng-if="!disksForm.$visible"
tooltip="Edit disks"
ng-click="disksForm.$show()"
)
i.fa.fa-edit.fa-fw
span.quick-edit(
ng-if="disksForm.$visible"
tooltip="Cancel Edition"
ng-click="disksForm.$cancel()"
)
i.fa.fa-undo.fa-fw
span.quick-edit(tooltip="Rescan", ng-click="rescanSr(SR.id)")
i.fa.fa-refresh.fa-fw
.panel-body
@@ -121,7 +133,7 @@
th Tags
th Size
th Virtual Machine:
tr(ng-repeat="VDI in SR.VDIs | resolve | vdiFilter:vdiSearch | orderBy:natural('name_label') | slice:(10*(currentVDIPage-1)):(10*currentVDIPage)")
tr(ng-repeat="VDI in VDIs | vdiFilter:vdiSearch | orderBy:natural('name_label') | slice:(10*(currentVDIPage-1)):(10*currentVDIPage)")
td.oneliner
span(
editable-text="VDI.name_label"
@@ -138,9 +150,11 @@
td
xo-tag(object = 'VDI')
td
//- FIXME: should be editable, but the server needs first
//- to accept a human readable string.
| {{VDI.size | bytesToSize}}
span(
editable-text="VDI.size"
e-name = '{{VDI.id}}/size'
)
| {{VDI.size}}
td.oneliner {{((VDI.$VBDs[0] | resolve).VM | resolve).name_label}}
span.pull-right.btn-group.quick-buttons
a(ng-if="(VDI.$VBDs[0] | resolve).attached", xo-click="disconnectVBD(VDI.$VBDs[0])")
@@ -152,8 +166,8 @@
.input-group
.input-group-addon: i.fa.fa-filter
input.form-control(type = 'text', ng-model = 'vdiSearch', placeholder = 'Enter your search here')
.center(ng-if = '(SR.VDIs | resolve | vdiFilter:vdiSearch).length > 10 || currentVDIPage > 1')
pagination(boundary-links="true", total-items="(SR.VDIs | resolve | vdiFilter:vdiSearch).length", ng-model="$parent.currentVDIPage", items-per-page="10", max-size="5", class="pagination-sm", previous-text="<", next-text=">", first-text="<<", last-text=">>")
.center(ng-if = '(VDIs | vdiFilter:vdiSearch).length > 10 || currentVDIPage > 1')
pagination(boundary-links="true", total-items="(VDIs | vdiFilter:vdiSearch).length", ng-model="$parent.currentVDIPage", items-per-page="10", max-size="5", class="pagination-sm", previous-text="<", next-text=">", first-text="<<", last-text=">>")
.btn-form(ng-show="disksForm.$visible")
p.center
button.btn.btn-default(
@@ -189,7 +203,7 @@
span.label.label-success Connected
span.pull-right.btn-group.quick-buttons
a(xo-click="disconnectPBD(PBD.id)")
i.fa.fa-unlink.fa-lg(tooltip="Disconnect to this host")
i.fa.fa-unlink.fa-lg(tooltip="Disconnect from this host")
td(ng-if="!PBD.attached")
span.label.label-default Disconnected
span.pull-right.btn-group.quick-buttons

View File

@@ -0,0 +1,41 @@
import angular from 'angular'
import later from 'later'
import scheduler from 'scheduler'
import uiRouter from 'angular-ui-router'
later.date.localTime()
import job from './job'
import overview from './overview'
import schedule from './schedule'
import view from './view'
export default angular.module('taskScheduler', [
uiRouter,
scheduler,
job,
overview,
schedule
])
.config(function ($stateProvider) {
$stateProvider.state('taskscheduler', {
abstract: true,
data: {
requireAdmin: true
},
template: view,
url: '/taskscheduler'
})
// Redirect to default sub-state.
$stateProvider.state('taskscheduler.index', {
url: '',
controller: function ($state) {
$state.go('taskscheduler.overview')
}
})
})
.name

View File

@@ -0,0 +1,19 @@
.form-group(ng-if = 'ctrl.active()')
label.col-md-2.control-label(ng-if = 'ctrl.key')
| {{ ctrl.key }}
span.text-warning(ng-if = 'ctrl.isRequired()') &nbsp;*
div(ng-class = '{"col-md-10": ctrl.key, "col-md-12": !ctrl.key}')
ul(style = 'padding-left: 0;')
li.list-group-item.clearfix(ng-repeat = 'item in ctrl.model track by $index')
| {{item}}
a.pull-right(ng-click = 'ctrl.remove($index)'): i.fa.fa-times
form(ng-submit = 'ctrl.add(ctrl.newItem); ctrl.newItem = ""')
.input-group
input.form-control.input-sm(ng-if = 'ctrl.getType(ctrl.property.items) === "string"', type = 'text', ng-model = 'ctrl.newItem', ng-required = '!param.optional')
input.form-control.input-sm(ng-if = 'ctrl.getType(ctrl.property.items) === "integer"', type = 'number', step = '1', ng-model = 'ctrl.newItem', ng-required = '!param.optional')
input.form-control.input-sm(ng-if = 'ctrl.getType(ctrl.property.items) === "number"', type = 'number', step = 'any', ng-model = 'ctrl.newItem', ng-required = '!param.optional')
span.input-group-addon(ng-if = 'ctrl.getType(ctrl.property.items) === "boolean"')
input(type = 'checkbox', ng-model = 'ctrl.newItem')
input.form-control.input-sm(ng-if = 'ctrl.getType(ctrl.property.items) === "boolean"', disabled)
span.input-group-btn
button.btn.btn-primary.btn-sm(type = 'submit') Add

View File

@@ -0,0 +1,6 @@
.form-group(ng-if = 'ctrl.active()')
label.col-md-2.control-label(ng-if = 'ctrl.key')
| {{ ctrl.key }}
span.text-warning(ng-if = 'ctrl.isRequired()') &nbsp;*
div(ng-class = '{"col-md-10": ctrl.key, "col-md-12": !ctrl.key}')
input(form = '{{ ctrl.form }}', type = 'checkbox', ng-model = 'ctrl.model')

View File

@@ -0,0 +1,15 @@
.form-group(ng-if = 'ctrl.active()')
label.col-md-2.control-label(ng-if = 'ctrl.key')
| Hosts
span.text-warning(ng-if = 'ctrl.isRequired()') &nbsp;*
div(ng-class = '{"col-md-10": ctrl.key, "col-md-12": !ctrl.key}')
ui-select(form = 'ctrl.form', ng-model = 'ctrl.intraModel', multiple, close-on-select = 'false', ng-required = 'ctrl.isRequired()', on-remove = 'ctrl.exportRemove($item)', on-select = 'ctrl.exportSelect($item)')
ui-select-match(placeholder = 'Choose hosts')
i(class = 'xo-icon-{{$item.type | lowercase}}')
| {{$item.name_label}}
span(ng-if = '$item.$container') &nbsp;({{ ($item.$container | resolve).name_label }})
ui-select-choices(repeat = 'object in ctrl.objects | selectHighLevel | filter:{type: "host"} | filter:$select.search | orderBy:["$container", "name_label"] track by object.id')
div
i(class = 'xo-icon-{{object.type | lowercase}}')
| &nbsp;{{ object.name_label }}
span(ng-if = 'object.$container') &nbsp;({{ (object.$container | resolve).name_label || ((object.$container | resolve).master | resolve).name_label }})

View File

@@ -0,0 +1,827 @@
import angular from 'angular'
import assign from 'lodash.assign'
import cloneDeep from 'lodash.clonedeep'
import find from 'lodash.find'
import forEach from 'lodash.foreach'
import includes from 'lodash.includes'
import map from 'lodash.map'
import mapValues from 'lodash.mapvalues'
import remove from 'lodash.remove'
import trim from 'lodash.trim'
import uiRouter from 'angular-ui-router'
import uiBootstrap from 'angular-ui-bootstrap'
import Bluebird from 'bluebird'
Bluebird.longStackTraces()
import arrayInputView from './array-input-view'
import booleanInputView from './boolean-input-view'
import hostInputView from './host-input-view'
import integerInputView from './integer-input-view'
import numberInputView from './number-input-view'
import objectInputView from './object-input-view'
import poolInputView from './pool-input-view'
import srInputView from './sr-input-view'
import stringInputView from './string-input-view'
import view from './view'
import vmInputView from './vm-input-view'
import xoEntityInputView from './xo-entity-input-view'
import xoObjectInputView from './xo-object-input-view'
import xoRoleInputView from './xo-role-input-view'
// ====================================================================
const JOB_KEY = 'genericTask'
const jobCompliantMethods = [
'acl.add',
'acl.remove',
'host.detach',
'host.disable',
'host.enable',
'host.installAllPatches',
'host.restart',
'host.restartAgent',
'host.set',
'host.start',
'host.stop',
'job.runSequence',
'vm.attachDisk',
'vm.backup',
'vm.clone',
'vm.convert',
'vm.copy',
'vm.creatInterface',
'vm.delete',
'vm.migrate',
'vm.migrate_pool',
'vm.restart',
'vm.resume',
'vm.revert',
'vm.rollingBackup',
'vm.rollingDrCopy',
'vm.rollingSnapshot',
'vm.set',
'vm.setBootOrder',
'vm.snapshot',
'vm.start',
'vm.stop',
'vm.suspend'
]
const getType = function (param) {
if (!param) {
return
}
if (Array.isArray(param.type)) {
if (includes(param.type, 'integer')) {
return 'integer'
} else if (includes(param.type, 'number')) {
return 'number'
} else {
return 'string'
}
}
return param.type
}
const isRequired = function (param) {
if (!param) {
return
}
return (!param.optional && !(includes(['boolean', 'array'], getType(param))))
}
/**
* Takes care of unfilled not-required data and unwanted white-spaces
*/
const cleanUpData = function (data) {
if (!data) {
return
}
function sanitizeItem (item) {
if (typeof item === 'string') {
item = trim(item)
}
return item
}
function keepItem (item) {
if ((item === undefined) || (item === null) || (item === '') || (Array.isArray(item) && item.length === 0) || item.__use === false) {
return false
} else {
return true
}
}
forEach(data, (item, key) => {
item = sanitizeItem(item)
data[key] = item
if (!keepItem(item)) {
delete data[key]
} else if (typeof item === 'object') {
cleanUpData(item)
}
})
delete data.__use
return data
}
/**
* Tries extracting XO Object targeted property
*/
const reduceXoObject = function (value, propertyName = 'id') {
return value && value[propertyName] || value
}
/**
* Adapts all data "arrayed" by UI-multiple-selectors to job's cross-product trick
*/
const dataToParamVectorItems = function (params, data) {
const items = []
forEach(params, (param, name) => {
if (Array.isArray(data[name]) && getType(param) !== 'array') {
const values = []
if (data[name].length === 1) { // One value, no need to engage cross-product
data[name] = data[name].pop()
} else {
forEach(data[name], value => {
values.push({[name]: reduceXoObject(value, name)})
})
if (values.length) { // No values at all
items.push({
type: 'set',
values
})
}
delete data[name]
}
}
})
if (Object.keys(data).length) {
items.push({
type: 'set',
values: [mapValues(data, reduceXoObject)]
})
}
return items
}
const actionGroup = {
group: undefined,
get: function () {
return this.group
},
set: function (group) {
this.group = group
}
}
const _initXoObjectInput = function () {
if (this.model === undefined) {
this.model = []
}
if (!Array.isArray(this.model)) {
this.model = [this.model]
}
this.intraModel = map(this.model, value => find(this.objects, object => object.id === value) || value)
}
const _exportRemove = function (removedItem) {
remove(this.model, item => item === reduceXoObject(removedItem))
}
const _exportSelect = function (addedItem) {
const addOn = reduceXoObject(addedItem)
if (!find(this.model, item => item === addOn)) {
this.model.push(addOn)
}
}
export default angular.module('xoWebApp.taskscheduler.job', [
uiRouter,
uiBootstrap
])
.config(function ($stateProvider) {
$stateProvider.state('taskscheduler.job', {
url: '/job/:id',
controller: 'JobCtrl as ctrl',
template: view
})
})
.controller('JobCtrl', function ($scope, xo, xoApi, notify, $stateParams) {
this.scheduleApi = {}
this.formData = {}
this.running = {}
this.ready = false
let comesForEditing = $stateParams.id
this.resetData = () => {
this.formData = {}
}
this.resetForm = () => {
this.resetData()
this.editedJobId = undefined
this.jobName = undefined
this.selectedAction = undefined
}
this.resetForm()
$scope.$watch(() => this.selectedAction, newAction => actionGroup.set(newAction && newAction.group))
const loadActions = () => xoApi.call('system.getMethodsInfo')
.then(response => {
const actions = []
for (let method in response) {
if (includes(jobCompliantMethods, method)) {
let [group, command] = method.split('.')
response[method].properties = response[method].params
response[method].type = 'object'
delete response[method].params
actions.push({
method,
group,
command,
info: response[method]
})
}
}
this.actions = actions
this.ready = true
})
const loadJobs = () => xo.job.getAll().then(jobs => {
const j = {}
forEach(jobs, job => {
if (job.key === JOB_KEY) {
j[job.id] = job
}
})
this.jobs = j
})
const refresh = () => loadJobs()
const getReady = () => loadActions().then(refresh).then(() => this.ready = true)
getReady().then(() => {
if (comesForEditing) {
this.edit(comesForEditing)
comesForEditing = undefined
}
})
const saveNew = (name, action, data) => {
const job = {
type: 'call',
name,
key: JOB_KEY,
method: action.method,
paramsVector: {
type: 'crossProduct',
items: dataToParamVectorItems(action.info.properties, data)
}
}
return xo.job.create(job)
}
const save = (id, name, action, data) => {
const job = this.jobs[id]
job.name = name
job.method = action.method
job.paramsVector = {
type: 'crossProduct',
items: dataToParamVectorItems(action.info.properties, data)
}
return xo.job.set(job)
}
this.save = (id, name, action, data) => {
const dataClone = cleanUpData(cloneDeep(data))
const saved = (id !== undefined) ? save(id, name, action, dataClone) : saveNew(name, action, dataClone)
return saved
.then(() => this.resetForm())
.finally(refresh)
}
this.edit = id => {
this.resetForm()
try {
const job = this.jobs[id]
if (job) {
this.editedJobId = id
this.jobName = job.name
this.selectedAction = find(this.actions, action => action.method === job.method)
const data = {}
const paramsVector = job.paramsVector
if (paramsVector) {
if (paramsVector.type !== 'crossProduct') {
throw new Error(`Unknown parameter-vector type ${paramsVector.type}`)
}
forEach(paramsVector.items, item => {
if (item.type !== 'set') {
throw new Error(`Unknown parameter-vector item type ${item.type}`)
}
if (item.values.length === 1) {
assign(data, item.values[0])
} else {
forEach(item.values, valueItem => {
forEach(valueItem, (value, key) => {
if (data[key] === undefined) {
data[key] = []
}
data[key].push(value)
})
})
}
})
}
this.formData = data
}
} catch (error) {
this.resetForm()
notify.error({
title: 'Unhandled Job',
message: error.message
})
}
}
this.delete = id => xo.job.delete(id).then(refresh).then(() => {
if (id === this.editedJobId) {
this.resetForm()
}
})
this.run = id => {
this.running[id] = true
notify.info({
title: 'Run Job',
message: 'One shot running started. See overview for logs.'
})
return xo.job.runSequence([id]).finally(() => delete this.running[id])
}
})
.directive('stringInput', function () {
return {
restrict: 'E',
scope: {
model: '=',
form: '=',
key: '=',
property: '='
},
bindToController: true,
controller: function () {
this.isRequired = () => isRequired(this.property)
this.active = () => getType(this.property) === 'string' && !includes(['id', 'host', 'host_id', 'target_host_id', 'sr', 'target_sr_id', 'vm', 'pool', 'subject', 'object', 'action'], this.key)
},
controllerAs: 'ctrl',
template: stringInputView
}
})
.directive('booleanInput', function () {
return {
restrict: 'E',
scope: {
model: '=',
form: '=',
key: '=',
property: '='
},
bindToController: true,
controller: function () {
this.isRequired = () => isRequired(this.property)
this.active = () => getType(this.property) === 'boolean'
},
controllerAs: 'ctrl',
template: booleanInputView
}
})
.directive('integerInput', function () {
return {
restrict: 'E',
scope: {
model: '=',
form: '=',
key: '=',
property: '='
},
bindToController: true,
controller: function () {
this.isRequired = () => isRequired(this.property)
this.active = () => getType(this.property) === 'integer'
},
controllerAs: 'ctrl',
template: integerInputView
}
})
.directive('numberInput', function () {
return {
restrict: 'E',
scope: {
model: '=',
form: '=',
key: '=',
property: '='
},
bindToController: true,
controller: function () {
this.isRequired = () => isRequired(this.property)
this.active = () => getType(this.property) === 'number'
},
controllerAs: 'ctrl',
template: numberInputView
}
})
.directive('arrayInput', function ($compile) {
return {
restrict: 'E',
scope: {
model: '=',
form: '=',
key: '=',
property: '='
},
controller: 'ArrayInput as ctrl',
bindToController: true,
link: function (scope, element, attrs) {
const updateElement = () => {
if (scope.ctrl.property.items) {
element.append(arrayInputView)
}
$compile(element.contents())(scope)
}
updateElement()
}
}
})
.controller('ArrayInput', function ($scope) {
this.isRequired = () => false
this.getType = getType
this.active = () => getType(this.property) === 'array'
this.add = value => {
const type = getType(this.property.items)
switch (type) {
case 'boolean':
value = Boolean(value)
break
case 'string':
value = trim(value)
break
}
this.model.push(value)
}
this.remove = index => this.model.splice(index, 1)
const init = () => {
if (this.model === undefined || this.model === null) {
this.model = []
}
}
if (this.active()) {
init()
if (!Array.isArray(this.model)) {
throw new Error('arrayInput directive model must be an array')
}
$scope.$watch(() => this.model, init)
}
})
.directive('objectInput', function ($compile) {
return {
restrict: 'E',
scope: {
model: '=',
form: '=',
key: '=',
property: '='
},
controller: 'ObjectInput as ctrl',
bindToController: true,
link: function (scope, element, attrs) {
const updateElement = () => {
if (scope.ctrl.property.properties) {
element.append(objectInputView)
}
$compile(element.contents())(scope)
}
updateElement()
}
}
})
.controller('ObjectInput', function ($scope) {
this.isRequired = () => isRequired(this.property)
this.active = () => getType(this.property) === 'object' && (this.key !== 'object' || actionGroup.get() !== 'acl')
const init = () => {
if (this.model === undefined || this.model === null) {
this.model = {
__use: this.isRequired()
}
}
if (typeof this.model !== 'object' || Array.isArray(this.model)) {
throw new Error('objectInput directive model must be a plain object')
}
const use = this.model.__use
delete this.model.__use
this.model.__use = Object.keys(this.model).length > 0 || use
forEach(this.property.properties, (property, key) => {
if (getType(property) === 'boolean') {
this.model[key] = Boolean(this.model[key])
}
})
}
if (this.active()) {
init()
$scope.$watch(() => this.model, (newVal, oldVal) => {
if (newVal !== oldVal) {
init()
}
})
}
})
.directive('vmInput', function () {
return {
restrict: 'E',
scope: {
form: '=',
key: '=',
property: '=',
model: '='
},
controller: 'VmInput as ctrl',
bindToController: true,
template: vmInputView
}
})
.controller('VmInput', function ($scope, xoApi) {
this.objects = xoApi.all
this.isRequired = () => isRequired(this.property)
this.active = () => getType(this.property) === 'string' && (this.key === 'vm' || (actionGroup.get() === 'vm' && this.key === 'id'))
this.init = _initXoObjectInput
this.exportRemove = _exportRemove
this.exportSelect = _exportSelect
if (this.active()) {
this.init()
$scope.$watch(() => this.model, (newVal, oldVal) => {
if (newVal !== oldVal) {
this.init()
}
})
}
})
.directive('hostInput', function () {
return {
restrict: 'E',
scope: {
form: '=',
key: '=',
property: '=',
model: '='
},
controller: 'HostInput as ctrl',
bindToController: true,
template: hostInputView
}
})
.controller('HostInput', function ($scope, xoApi) {
this.objects = xoApi.all
this.isRequired = () => isRequired(this.property)
this.active = () => getType(this.property) === 'string' && (includes(['host', 'host_id', 'target_host_id'], this.key) || (actionGroup.get() === 'host' && this.key === 'id'))
this.init = _initXoObjectInput
this.exportRemove = _exportRemove
this.exportSelect = _exportSelect
if (this.active()) {
this.init()
$scope.$watch(() => this.model, (newVal, oldVal) => {
if (newVal !== oldVal) {
this.init()
}
})
}
})
.directive('srInput', function () {
return {
restrict: 'E',
scope: {
form: '=',
key: '=',
property: '=',
model: '='
},
controller: 'SrInput as ctrl',
bindToController: true,
template: srInputView
}
})
.controller('SrInput', function ($scope, xoApi) {
this.objects = xoApi.all
this.isRequired = () => isRequired(this.property)
this.active = () => getType(this.property) === 'string' && includes(['sr', 'sr_id', 'target_sr_id'], this.key)
this.init = _initXoObjectInput
this.exportRemove = _exportRemove
this.exportSelect = _exportSelect
if (this.active()) {
this.init()
$scope.$watch(() => this.model, (newVal, oldVal) => {
if (newVal !== oldVal) {
this.init()
}
})
}
})
.directive('poolInput', function () {
return {
restrict: 'E',
scope: {
form: '=',
key: '=',
property: '=',
model: '='
},
controller: 'PoolInput as ctrl',
bindToController: true,
template: poolInputView
}
})
.controller('PoolInput', function ($scope, xoApi) {
this.objects = xoApi.all
this.isRequired = () => isRequired(this.property)
this.active = () => getType(this.property) === 'string' && includes(['pool', 'pool_id', 'target_pool_id'], this.key)
this.init = _initXoObjectInput
this.exportRemove = _exportRemove
this.exportSelect = _exportSelect
if (this.active()) {
this.init()
$scope.$watch(() => this.model, (newVal, oldVal) => {
if (newVal !== oldVal) {
this.init()
}
})
}
})
.directive('xoEntityInput', function () {
return {
restrict: 'E',
scope: {
form: '=',
key: '=',
property: '=',
model: '='
},
controller: 'XoEntityInput as ctrl',
bindToController: true,
template: xoEntityInputView
}
})
.controller('XoEntityInput', function ($scope, xo) {
this.ready = false
this.isRequired = () => isRequired(this.property)
this.active = () => this.ready && getType(this.property) === 'string' && this.key === 'subject' && actionGroup.get() === 'acl'
this.init = _initXoObjectInput
this.exportRemove = _exportRemove
this.exportSelect = _exportSelect
Bluebird.props({
users: xo.user.getAll(),
groups: xo.group.getAll()
})
.then(p => {
this.objects = p.users.concat(p.groups)
this.ready = true
if (this.active()) {
this.init()
$scope.$watch(() => this.model, (newVal, oldVal) => {
if (newVal !== oldVal) {
this.init()
}
})
}
})
})
.directive('xoRoleInput', function () {
return {
restrict: 'E',
scope: {
form: '=',
key: '=',
property: '=',
model: '='
},
controller: 'XoRoleInput as ctrl',
bindToController: true,
template: xoRoleInputView
}
})
.controller('XoRoleInput', function ($scope, xo) {
this.ready = false
this.isRequired = () => isRequired(this.property)
this.active = () => this.ready && getType(this.property) === 'string' && this.key === 'action' && actionGroup.get() === 'acl'
this.init = _initXoObjectInput
this.exportRemove = _exportRemove
this.exportSelect = _exportSelect
xo.role.getAll()
.then(roles => {
this.objects = roles
this.ready = true
if (this.active()) {
this.init()
$scope.$watch(() => this.model, (newVal, oldVal) => {
if (newVal !== oldVal) {
this.init()
}
})
}
})
})
.directive('xoObjectInput', function () {
return {
restrict: 'E',
scope: {
form: '=',
key: '=',
property: '=',
model: '='
},
controller: 'XoObjectInput as ctrl',
bindToController: true,
template: xoObjectInputView
}
})
.controller('XoObjectInput', function ($scope, xoApi, filterFilter, selectHighLevelFilter) {
const HIGH_LEVEL_OBJECTS = {
pool: true,
host: true,
VM: true,
SR: true,
network: true
}
this.types = Object.keys(HIGH_LEVEL_OBJECTS)
this.objects = xoApi.all
this.isRequired = () => isRequired(this.property)
this.active = () => getType(this.property) === 'string' && this.key === 'object' && actionGroup.get() === 'acl'
this.toggleType = (toggle, type) => {
const selectedObjects = this.intraModel && this.intraModel.slice() || []
if (toggle) {
const objects = filterFilter(selectHighLevelFilter(this.objects), {type})
forEach(objects, object => { selectedObjects.indexOf(object) === -1 && selectedObjects.push(object) })
this.intraModel = selectedObjects
} else {
const keptObjects = []
for (let index in selectedObjects) {
const object = selectedObjects[index]
if (object.type !== type) {
keptObjects.push(object)
}
}
this.intraModel = keptObjects
}
this.model.length = 0
forEach(this.intraModel, item => this.model.push(reduceXoObject(item)))
}
this.init = _initXoObjectInput
this.exportRemove = _exportRemove
this.exportSelect = _exportSelect
if (this.active()) {
this.init()
$scope.$watch(() => this.model, (newVal, oldVal) => {
if (newVal !== oldVal) {
this.init()
}
})
}
})
// A module exports its name.
.name

View File

@@ -0,0 +1,6 @@
.form-group(ng-if = 'ctrl.active()')
label.col-md-2.control-label(ng-if = 'ctrl.key')
| {{ ctrl.key }}
span.text-warning(ng-if = 'ctrl.isRequired()') &nbsp;*
div(ng-class = '{"col-md-10": ctrl.key, "col-md-12": !ctrl.key}')
input.form-control(form = '{{ ctrl.form }}', type = 'number', step = '1', ng-model = 'ctrl.model', ng-required = 'ctrl.isRequired()')

View File

@@ -0,0 +1,6 @@
.form-group(ng-if = 'ctrl.active()')
label.col-md-2.control-label(ng-if = 'ctrl.key')
| {{ ctrl.key }}
span.text-warning(ng-if = 'ctrl.isRequired()') &nbsp;*
div(ng-class = '{"col-md-10": ctrl.key, "col-md-12": !ctrl.key}')
input.form-control(form = '{{ ctrl.form }}', type = 'number', step = 'any', ng-model = 'ctrl.model', ng-required = 'ctrl.isRequired()')

View File

@@ -0,0 +1,30 @@
.form-group(ng-if = 'ctrl.active()')
label.col-md-2.control-label(ng-if = 'ctrl.key')
| {{ ctrl.key }}
span.text-warning(ng-if = 'ctrl.isRequired()') &nbsp;*
div(ng-class = '{"col-md-10": ctrl.key, "col-md-12": !ctrl.key}')
.checkbox(ng-if = '!ctrl.isRequired()')
label
input(type = 'checkbox', ng-model = 'ctrl.model.__use')
| &nbsp;Fill informations (optional)
hr
.help-block(ng-if = 'ctrl.isRequired()')
span.text-warning Fill required informations
hr
fieldset.form-horizontal(ng-disabled = '!ctrl.isRequired() && !ctrl.model.__use', ng-hide = '!ctrl.isRequired() && !ctrl.model.__use')
.form-group(ng-if = '(ctrl.property.properties | count) < 1')
p.help-block No parameters
div(ng-repeat = '(key, property) in ctrl.property.properties')
array-input(form = 'ctrl.form', key = 'key', property = 'property', model = 'ctrl.model[key]')
boolean-input(form = 'ctrl.form', key = 'key', property = 'property', model = 'ctrl.model[key]')
host-input(form = 'ctrl.form', key = 'key', property = 'property', model = 'ctrl.model[key]')
integer-input(form = 'ctrl.form', key = 'key', property = 'property', model = 'ctrl.model[key]')
number-input(form = 'ctrl.form', key = 'key', property = 'property', model = 'ctrl.model[key]')
object-input(form = 'ctrl.form', key = 'key', property = 'property', model = 'ctrl.model[key]')
pool-input(form = 'ctrl.form', key = 'key', property = 'property', model = 'ctrl.model[key]')
sr-input(form = 'ctrl.form', key = 'key', property = 'property', model = 'ctrl.model[key]')
string-input(form = 'ctrl.form', model = 'ctrl.model[key]', key = 'key', property = 'property')
vm-input(form = 'ctrl.form', key = 'key', property = 'property', model = 'ctrl.model[key]')
xo-entity-input(form = 'ctrl.form', key = 'key', property = 'property', model = 'ctrl.model[key]')
xo-object-input(form = 'ctrl.form', key = 'key', property = 'property', model = 'ctrl.model[key]')
xo-role-input(form = 'ctrl.form', key = 'key', property = 'property', model = 'ctrl.model[key]')

View File

@@ -0,0 +1,15 @@
.form-group(ng-if = 'ctrl.active()')
label.col-md-2.control-label(ng-if = 'ctrl.key')
| Pools
span.text-warning(ng-if = 'ctrl.isRequired()') &nbsp;*
div(ng-class = '{"col-md-10": ctrl.key, "col-md-12": !ctrl.key}')
ui-select(form = 'ctrl.form', ng-model = 'ctrl.intraModel', multiple, close-on-select = 'false', ng-required = 'ctrl.isRequired()', on-remove = 'ctrl.exportRemove($item)', on-select = 'ctrl.exportSelect($item)')
ui-select-match(placeholder = 'Choose pools')
i(class = 'xo-icon-{{$item.type | lowercase}}')
| {{$item.name_label}}
span(ng-if = '$item.$container') &nbsp;({{ ($item.$container | resolve).name_label }})
ui-select-choices(repeat = 'object in ctrl.objects | selectHighLevel | filter:{type: "pool"} | filter:$select.search | orderBy:["$container", "name_label"] track by object.id')
div
i(class = 'xo-icon-{{object.type | lowercase}}')
| &nbsp;{{ object.name_label }}
span(ng-if = 'object.$container') &nbsp;({{ (object.$container | resolve).name_label || ((object.$container | resolve).master | resolve).name_label }})

View File

@@ -0,0 +1,15 @@
.form-group(ng-if = 'ctrl.active()')
label.col-md-2.control-label(ng-if = 'ctrl.key')
| SRs
span.text-warning(ng-if = 'ctrl.isRequired()') &nbsp;*
div(ng-class = '{"col-md-10": ctrl.key, "col-md-12": !ctrl.key}')
ui-select(form = 'ctrl.form', ng-model = 'ctrl.intraModel', multiple, close-on-select = 'false', ng-required = 'ctrl.isRequired()', on-remove = 'ctrl.exportRemove($item)', on-select = 'ctrl.exportSelect($item)')
ui-select-match(placeholder = 'Choose Storage Repositories')
i(class = 'xo-icon-{{$item.type | lowercase}}')
| {{$item.name_label}}
span(ng-if = '$item.$container') &nbsp;({{ ($item.$container | resolve).name_label }})
ui-select-choices(repeat = 'object in ctrl.objects | selectHighLevel | filter:{type: "sr"} | filter:$select.search | orderBy:["$container", "name_label"] track by object.id')
div
i(class = 'xo-icon-{{object.type | lowercase}}')
| &nbsp;{{ object.name_label }}
span(ng-if = 'object.$container') &nbsp;({{ (object.$container | resolve).name_label || ((object.$container | resolve).master | resolve).name_label }})

View File

@@ -0,0 +1,6 @@
.form-group(ng-if = 'ctrl.active()')
label.col-md-2.control-label(ng-if = 'ctrl.key')
| {{ ctrl.key }}
span.text-warning(ng-if = 'ctrl.isRequired()') &nbsp;*
div(ng-class = '{"col-md-10": ctrl.key, "col-md-12": !ctrl.key}')
input.form-control(form = '{{ ctrl.form }}', type = 'text', ng-model = 'ctrl.model', ng-required = 'ctrl.isRequired()')

View File

@@ -0,0 +1,68 @@
.grid
.panel.panel-default
p.page-title
i.fa.fa-cogs
| Jobs
form#jobform(ng-submit = 'ctrl.save(ctrl.editedJobId, ctrl.jobName, ctrl.selectedAction, ctrl.formData)')
.grid-sm
.panel.panel-default
.panel-heading.panel-title
i.fa.fa-wrench
| {{ ctrl.editedJobId ? "Edit" : "Create" }}
.panel-body
.alert.alert-warning(ng-if = 'ctrl.editedJobId') Editing Job ID: {{ ctrl.editedJobId }}
fieldset.form-horizontal(ng-disabled = '!ctrl.ready')
.form-group
label.col-sm-2.control-label Job Name
.col-sm-10
input.form-control(form = 'jobform', type = 'text', ng-model = 'ctrl.jobName', required, placeholder = 'An explicit name for your job')
.form-group
label.col-sm-2.control-label {{ ctrl.selectedAction ? (ctrl.selectedAction.group + ".") : "Action" }}
.col-sm-10
select.form-control(form = 'jobform', ng-model = 'ctrl.selectedAction', ng-options = 'action.command group by action.group for action in ctrl.actions', ng-change = 'ctrl.resetData()', required)
option(value = '') -- Choose an action --
p.help-block(ng-if = 'ctrl.selectedAction.info.description') {{ ctrl.selectedAction.info.description }}
.form-group(ng-if = 'ctrl.selectedAction.info.permission')
label.col-sm-2.control-label Permission
.col-sm-10: p.form-control-static {{ ctrl.selectedAction.info.permission }}
fieldset.form-horizontal(ng-if = 'ctrl.selectedAction', ng-disabled = '!ctrl.ready')
legend Parameters
object-input(form = '"jobform"', property = 'ctrl.selectedAction.info', model = 'ctrl.formData')
.grid-sm
.panel.panel-default
.panel-body
fieldset.center(ng-disabled = '!ctrl.ready')
button.btn.btn-lg.btn-primary(form = 'jobform', type = 'submit')
i.fa.fa-clock-o
| &nbsp;
i.fa.fa-arrow-right
| &nbsp;
i.fa.fa-database
| &nbsp;Save&nbsp;
| &nbsp;
button.btn.btn-lg.btn-default(type = 'button', ng-click = 'ctrl.resetForm()')
| &nbsp;Reset&nbsp;
.grid-sm
.panel.panel-default
.panel-heading.panel-title
i.fa.fa-list-ul
| Jobs
.panel-body
.text-center(ng-if = '!(ctrl.jobs | count)') No jobs found
table.table(ng-if = 'ctrl.jobs | count')
tr
th Name
th Action
th
tr(ng-repeat = 'job in ctrl.jobs | map | orderBy:"name" track by job.id')
td
| {{ job.name }}&ensp;
span.text-muted.hidden-xs ({{ job.id }})
td {{ job.method }}
td
span.pull-left
button.btn.btn-warning(type = 'button', ng-click = 'ctrl.run(job.id)', ng-disabled = 'ctrl.running[job.id]'): i.fa.fa-play
span.pull-right
button.btn.btn-primary(type = 'button', ng-click = 'ctrl.edit(job.id)'): i.fa.fa-pencil
| &nbsp;
button.btn.btn-danger(type = 'button', ng-click = 'ctrl.delete(job.id)'): i.fa.fa-trash

View File

@@ -0,0 +1,17 @@
.form-group(ng-if = 'ctrl.active()')
label.col-md-2.control-label(ng-if = 'ctrl.key')
| VMs
span.text-warning(ng-if = 'ctrl.isRequired()') &nbsp;*
div(ng-class = '{"col-md-10": ctrl.key, "col-md-12": !ctrl.key}')
ui-select(form = 'ctrl.form', ng-model = 'ctrl.intraModel', multiple, close-on-select = 'false', ng-required = 'ctrl.isRequired()', on-remove = 'ctrl.exportRemove($item)', on-select = 'ctrl.exportSelect($item)')
ui-select-match(placeholder = 'Choose VMs')
i.xo-icon-working(ng-if = 'isVMWorking($item)')
i(ng-class = '"xo-icon-" + ($item.power_state | lowercase)', ng-if = '!isVMWorking($item)')
| &nbsp;{{ $item.name_label }}
span(ng-if = '$item.$container') &nbsp;({{ ($item.$container | resolve).name_label }})
ui-select-choices(repeat = 'vm in ctrl.objects | selectHighLevel | filter:{type: "vm"} | filter:$select.search | orderBy:["$container", "name_label"] track by vm.id')
div
i.xo-icon-working(ng-if = 'isVMWorking(vm)', tooltip = '{{ vm.power_state }} and {{ (vm.current_operations | map)[0] }}')
i(ng-class = '"xo-icon-" + (vm.power_state | lowercase)', ng-if = '!isVMWorking(vm)', tooltip = '{{ vm.power_state }}')
| &nbsp;{{ vm.name_label }}
span(ng-if = 'vm.$container') &nbsp;({{ (vm.$container | resolve).name_label || ((vm.$container | resolve).master | resolve).name_label }})

View File

@@ -0,0 +1,21 @@
.form-group(ng-if = 'ctrl.active()')
label.col-md-2.control-label(ng-if = 'ctrl.key')
| Users / Groups
span.text-warning(ng-if = 'ctrl.isRequired()') &nbsp;*
div(ng-class = '{"col-md-10": ctrl.key, "col-md-12": !ctrl.key}')
ui-select(ng-model = 'ctrl.intraModel', multiple, close-on-select = 'false', ng-required = 'ctrl.isRequired()', on-remove = 'ctrl.exportRemove($item)', on-select = 'ctrl.exportSelect($item)')
ui-select-match(placeholder = 'Choose users or groups')
span(ng-if = '$item.email')
i.xo-icon-user.fa-fw
| {{$item.email}}
span(ng-if = '$item.name')
i.xo-icon-group.fa-fw
| {{$item.name}}
ui-select-choices(repeat = 'entity in ctrl.objects | filter:{ permission: "!admin" } | filter:$select.search')
div
span(ng-if = 'entity.email')
i.xo-icon-user.fa-fw
| {{entity.email}}
span(ng-if = 'entity.name')
i.xo-icon-group.fa-fw
| {{entity.name}}

View File

@@ -0,0 +1,28 @@
.form-group(ng-if = 'ctrl.active()')
label.col-md-2.control-label(ng-if = 'ctrl.key')
| Objects
span.text-warning(ng-if = 'ctrl.isRequired()') &nbsp;*
div(ng-class = '{"col-md-10": ctrl.key, "col-md-12": !ctrl.key}')
ui-select(ng-model = 'ctrl.intraModel', multiple, close-on-select = 'false', ng-required = 'ctrl.isRequired()', on-remove = 'ctrl.exportRemove($item)', on-select = 'ctrl.exportSelect($item)')
ui-select-match(placeholder = 'Choose an object')
i(class = 'xo-icon-{{$item.type | lowercase}}')
| {{$item.name_label}}
span(ng-if="($item.type === 'SR' || $item.type === 'VM') && $item.$container")
| ({{($item.$container | resolve).name_label}})
span(ng-if="$item.type === 'network'")
| ({{($item.$poolId | resolve).name_label}})
ui-select-choices(repeat = 'object in ctrl.objects | selectHighLevel | filter:$select.search | orderBy:["type", "name_label"]')
div
i(class = 'xo-icon-{{object.type | lowercase}}')
| {{object.name_label}}
span(ng-if="(object.type === 'SR' || object.type === 'VM') && object.$container")
| ({{(object.$container | resolve).name_label}})
span(ng-if="object.type === 'network'")
| ({{(object.$poolId | resolve).name_label}})
.text-center
span(ng-repeat = 'type in ctrl.types')
label(tooltip = 'select/deselect all {{type}}s', style = 'cursor: pointer')
input.hidden(type = 'checkbox', ng-model = 'selectedTypes[type]', ng-change = 'ctrl.toggleType(selectedTypes[type], type)')
span.fa-stack
i(class = 'xo-icon-{{type | lowercase}}').fa-stack-1x
i.fa.fa-square-o.fa-stack-2x.text-info(ng-if = 'selectedTypes[type]')

View File

@@ -0,0 +1,13 @@
.form-group(ng-if = 'ctrl.active()')
label.col-md-2.control-label(ng-if = 'ctrl.key')
| Roles
span.text-warning(ng-if = 'ctrl.isRequired()') &nbsp;*
div(ng-class = '{"col-md-10": ctrl.key, "col-md-12": !ctrl.key}')
ui-select(ng-model = 'ctrl.intraModel', multiple, close-on-select = 'false', ng-required = 'ctrl.isRequired()', on-remove = 'ctrl.exportRemove($item)', on-select = 'ctrl.exportSelect($item)')
ui-select-match(placeholder = 'Choose a role')
i(class = 'xo-icon-{{$item.type | lowercase}}')
| {{$item.name}}
ui-select-choices(repeat = 'role in ctrl.objects | filter:$select.search | orderBy:"name"')
div
i(class = 'xo-icon-{{role.type | lowercase}}')
| {{role.name}}

View File

@@ -0,0 +1,171 @@
import angular from 'angular'
import filter from 'lodash.filter'
import forEach from 'lodash.foreach'
import prettyCron from 'prettycron'
import uiBootstrap from 'angular-ui-bootstrap'
import uiRouter from 'angular-ui-router'
import view from './view'
// ====================================================================
const JOB_KEY = 'genericTask'
export default angular.module('taskscheduler.overview', [
uiRouter,
uiBootstrap
])
.config(function ($stateProvider) {
$stateProvider.state('taskscheduler.overview', {
url: '/overview',
controller: 'OverviewCtrl as ctrl',
template: view
})
})
.controller('OverviewCtrl', function ($scope, $state, $stateParams, $interval, xo, xoApi, notify, selectHighLevelFilter, filterFilter) {
this.running = {}
this.currentLogPage = 1
this.logPageSize = 10
const refreshSchedules = () => {
xo.schedule.getAll()
.then(schedules => this.schedules = filter(schedules, schedule => this.jobs[schedule.job] && this.jobs[schedule.job].key === JOB_KEY))
xo.scheduler.getScheduleTable()
.then(table => this.scheduleTable = table)
}
const getLogs = () => {
xo.logs.get('jobs').then(logs => {
const viewLogs = {}
forEach(logs, (log, logKey) => {
const data = log.data
const [time] = logKey.split(':')
if (data.event === 'job.start' && data.key === JOB_KEY) {
viewLogs[logKey] = {
logKey,
jobId: data.jobId,
key: data.key,
userId: data.userId,
start: time,
calls: {},
time
}
} else {
const runJobId = data.runJobId
const entry = viewLogs[runJobId]
if (!entry) {
return
}
if (data.event === 'job.end') {
if (data.error) {
entry.error = data.error
}
entry.end = time
entry.duration = time - entry.start
entry.status = 'Terminated'
} else if (data.event === 'jobCall.start') {
entry.calls[logKey] = {
callKey: logKey,
params: resolveParams(data.params),
method: data.method,
time
}
} else if (data.event === 'jobCall.end') {
const call = entry.calls[data.runCallId]
if (data.error) {
call.error = data.error
entry.hasErrors = true
} else {
call.returnedValue = resolveReturn(data.returnedValue)
}
}
}
})
forEach(viewLogs, log => {
if (log.end === undefined) {
log.status = 'In progress'
}
})
this.logs = viewLogs
})
}
const resolveParams = params => {
for (let key in params) {
const xoObject = xoApi.get(params[key])
if (xoObject) {
const newKey = xoObject.type || key
params[newKey] = xoObject.name_label || xoObject.name || params[key]
newKey !== key && delete params[key]
}
}
return params
}
const resolveReturn = returnValue => {
const xoObject = xoApi.get(returnValue)
let xoName = xoObject && (xoObject.name_label || xoObject.name)
xoName && (xoName += xoObject.type && ` (${xoObject.type})` || '')
returnValue = xoName || returnValue
return returnValue
}
this.prettyCron = prettyCron.toString.bind(prettyCron)
const refreshJobs = () => {
return xo.job.getAll()
.then(jobs => {
const j = {}
forEach(jobs, job => j[job.id] = job)
this.jobs = j
})
}
const refresh = () => {
refreshJobs().then(refreshSchedules)
getLogs()
}
refresh()
const interval = $interval(() => {
refresh()
}, 5e3)
$scope.$on('$destroy', () => {
$interval.cancel(interval)
})
this.enable = id => {
this.working[id] = true
return xo.scheduler.enable(id)
.finally(() => { this.working[id] = false })
.then(refreshSchedules)
}
this.disable = id => {
this.working[id] = true
return xo.scheduler.disable(id)
.finally(() => { this.working[id] = false })
.then(refreshSchedules)
}
this.run = schedule => {
this.running[schedule.id] = true
notify.info({
title: 'Run Job',
message: 'One shot running started. See overview for logs.'
})
const id = schedule.job
return xo.job.runSequence([id]).finally(() => delete this.running[schedule.id])
}
this.displayScheduleJobName = schedule => this.jobs[schedule.job] && this.jobs[schedule.job].name
this.collectionLength = col => Object.keys(col).length
this.working = {}
})
// A module exports its name.
.name

View File

@@ -0,0 +1,85 @@
.panel.panel-default
p.page-title
i.fa.fa-eye
| Job Scheduling Overview
.panel.panel-default
.panel-heading.panel-title
i.fa.fa-clock-o
| Schedules
.panel-body
//- The 2 tables below are here for a "full-width" effect of the content vs the menu (cf sheduler/view.jade)
table.table(ng-if = '!ctrl.schedules')
tr
td.text-center: i.xo-icon-loading
table.table(ng-if = 'ctrl.schedules && !ctrl.collectionLength(ctrl.schedules)')
tr
td.text-center No scheduled jobs
table.table.table-hover(ng-if = 'ctrl.schedules && ctrl.collectionLength(ctrl.schedules)')
tr
th Name
th Job
th.hidden-xs Scheduling
th State
tr(ng-repeat = 'schedule in ctrl.schedules | orderBy:"id":true track by schedule.id')
td: a(ui-sref = 'taskscheduler.schedule({id: schedule.id})') {{ schedule.name || schedule.id }}
td
a(ui-sref = 'taskscheduler.job({id: schedule.job})') {{ ctrl.displayScheduleJobName(schedule) || schedule.job }}
td.hidden-xs {{ ctrl.prettyCron(schedule.cron) }}
td
span.label.label-success.hidden-xs(ng-if = 'ctrl.scheduleTable[schedule.id] === true') enabled
span.label.label-default.hidden-xs(ng-if = 'ctrl.scheduleTable[schedule.id] === false') disabled
span.label.label-warning.hidden-xs(ng-if = 'ctrl.scheduleTable[schedule.id] === undefined') unknown
fieldset.pull-right(ng-disabled = 'ctrl.working[schedule.id]')
button.btn(ng-if = 'ctrl.scheduleTable[schedule.id] === false', type = 'button', ng-click = 'ctrl.enable(schedule.id)'): i.fa.fa-toggle-off
button.btn.btn-success(ng-if = 'ctrl.scheduleTable[schedule.id] === true', type = 'button', ng-click = 'ctrl.disable(schedule.id)'): i.fa.fa-toggle-on
| &nbsp;
button.btn.btn-warning(type = 'button', ng-click = 'ctrl.run(schedule)', ng-disabled = 'ctrl.running[schedule.id]'): i.fa.fa-play
.grid-sm
.panel.panel-default
.panel-heading.panel-title
i.fa.fa-file-text
| Logs
.panel-body
table.table.table-hover(ng-if = 'ctrl.logs')
thead
tr
th Job ID
th Start
th End
th Duration
th Status
tbody(ng-repeat = 'log in ctrl.logs | map | filter:ctrl.logSearch | orderBy:"-time" | slice:(ctrl.logPageSize * (ctrl.currentLogPage - 1)):(ctrl.logPageSize * ctrl.currentLogPage) track by log.logKey')
tr
td
button.btn.btn-sm(type = 'button', tooltip = 'See calls', ng-click = 'seeCalls = !seeCalls', ng-class = '{"btn-default": !log.hasErrors, "btn-danger": log.hasErrors}'): i.fa(ng-class = '{"fa-caret-down": !seeCalls, "fa-caret-up": seeCalls}')
| &nbsp;{{ log.jobId }}
td {{ log.start | date:'medium' }}
td {{ log.end | date:'medium' }}
td {{ log.duration | duration}}
td
span(ng-if = 'log.status === "Terminated"')
span.label(ng-class = '{"label-success": (!log.error && !log.hasErrors), "label-danger": (log.error || log.hasErrors)}') {{ log.status }}
span.label(ng-if = 'log.status !== "Terminated"', ng-class = '{"label-warning": log.status === "In progress", "label-default": !log.status}') {{ log.status || "unknown" }}
p.text-danger(ng-if = 'log.error') &nbsp;{{ log.error }}
tr.bg-info(collapse = '!seeCalls')
td(colspan = '5')
ul.list-group
li.list-group-item(ng-repeat = 'call in log.calls | map | orderBy:"-time" track by call.callKey')
strong.text-info {{ call.method }}:&#32;
span(ng-repeat = '(key, param) in call.params')
| {{ key }}:
strong &nbsp;{{ param }}&#32;
span(ng-if = 'call.returnedValue')
| &#32;
i.text-primary.fa.fa-arrow-right
| &#32;{{ call.returnedValue }}
span.text-danger(ng-if = 'call.error')
| &#32;
i.fa.fa-times
| &#32;{{ call.error }}
.form-inline
.input-group
.input-group-addon: i.fa.fa-search
input.form-control(type = 'text', ng-model = 'ctrl.logSearch', placeholder = 'Search logs...')
.center(ng-if = '(ctrl.logs | map | filter:ctrl.logSearch | count) > ctrl.logPageSize || currentLogPage > 1')
pagination.pagination-sm(boundary-links = 'true', total-items = 'ctrl.logs | map | filter:ctrl.logSearch | count', ng-model = 'ctrl.currentLogPage', items-per-page = 'ctrl.logPageSize', max-size = '10', previous-text = '<', next-text = '>', first-text = '<<', last-text = '>>')

View File

@@ -0,0 +1,115 @@
import angular from 'angular'
import Bluebird from 'bluebird'
import find from 'lodash.find'
import forEach from 'lodash.foreach'
import prettyCron from 'prettycron'
import uiBootstrap from 'angular-ui-bootstrap'
import uiRouter from 'angular-ui-router'
Bluebird.longStackTraces()
import view from './view'
// ====================================================================
const JOB_KEY = 'genericTask'
export default angular.module('xoWebApp.taskscheduler.schedule', [
uiRouter,
uiBootstrap
])
.config(function ($stateProvider) {
$stateProvider.state('taskscheduler.schedule', {
url: '/schedule/:id',
controller: 'ScheduleCtrl as ctrl',
template: view
})
})
.controller('ScheduleCtrl', function (xo, xoApi, notify, $stateParams) {
this.scheduleApi = {}
this.formData = {}
this.ready = false
this.running = {}
let comesForEditing = $stateParams.id
this.reset = () => {
this.formData.editedScheduleId = undefined
this.formData.scheduleName = undefined
this.formData.selectedJob = undefined
this.formData.enabled = false
this.scheduleApi && this.scheduleApi.resetData && this.scheduleApi.resetData()
}
this.reset()
const refreshJobs = () => xo.job.getAll().then(jobs => {
const j = {}
forEach(jobs, job => {
if (job.key === JOB_KEY) {
j[job.id] = job
}
})
this.jobs = j
})
const refreshSchedules = () => xo.schedule.getAll().then(schedules => {
const s = {}
forEach(schedules, schedule => {
if (this.jobs && this.jobs[schedule.job] && (this.jobs[schedule.job].key === JOB_KEY)) {
s[schedule.id] = schedule
}
})
this.schedules = s
})
const refresh = () => refreshJobs().then(refreshSchedules)
const getReady = () => refresh().then(() => this.ready = true)
getReady().then(() => {
if (comesForEditing) {
this.edit(comesForEditing)
comesForEditing = undefined
}
})
const saveNew = (name, job, cron, enabled) => xo.schedule.create(job.id, cron, enabled, name)
const save = (id, name, job, cron) => xo.schedule.set(id, job.id, cron, undefined, name)
this.save = (id, name, job, cron, enabled) => {
const saved = (id !== undefined) ? save(id, name, job, cron) : saveNew(name, job, cron, enabled)
return saved
.then(() => this.reset())
.finally(refresh)
}
this.edit = id => {
this.reset()
const schedule = this.schedules[id]
if (schedule) {
this.formData.editedScheduleId = schedule.id
this.formData.scheduleName = schedule.name
this.formData.selectedJob = find(this.jobs, job => job.id = schedule.job)
this.scheduleApi.setCron(schedule.cron)
}
}
this.delete = (id) => xo.schedule.delete(id).then(refresh).then(() => {
if (id === this.formData.editedScheduleId) {
this.reset()
}
})
this.run = schedule => {
this.running[schedule.id] = true
notify.info({
title: 'Run Job',
message: 'One shot running started. See overview for logs.'
})
const id = schedule.job
return xo.job.runSequence([id]).finally(() => delete this.running[schedule.id])
}
this.prettyCron = prettyCron.toString.bind(prettyCron)
})
// A module exports its name.
.name

View File

@@ -0,0 +1,77 @@
.grid
.panel.panel-default
p.page-title
i.fa.fa-clock-o
| Job scheduler
form#scheduleform(ng-submit = 'ctrl.save(ctrl.formData.editedScheduleId, ctrl.formData.scheduleName, ctrl.formData.selectedJob, ctrl.formData.cronPattern, ctrl.formData.enabled)')
.grid-sm
.panel.panel-default
.panel-heading.panel-title
i.fa.fa-cogs
| Job to schedule
.panel-body
.alert.alert-warning(ng-if = 'ctrl.formData.editedScheduleId') Editing Schedule ID: {{ ctrl.formData.editedScheduleId }}
fieldset.form-horizontal(ng-disabled = '!ctrl.ready')
.form-group
label.col-sm-2.control-label Schedule Name
.col-sm-10
input.form-control(form = 'scheduleform', type = 'text', ng-model = 'ctrl.formData.scheduleName', required, placeholder = 'An explicit name for your schedule')
.form-group
label.col-sm-2.control-label Job
.col-sm-10
select.form-control(form = 'scheduleform', ng-model = 'ctrl.formData.selectedJob', ng-options = '(job.name + " (" + job.id + ")") for job in (ctrl.jobs | map | orderBy:"name")', required)
option(value = '') -- Choose a job --
p.help-block(ng-if = 'ctrl.formData.selectedJob') {{ ctrl.selectedJob }}
.form-group(ng-if = '!ctrl.formData.editedScheduleId')
label.control-label.col-md-2(for = 'enabled')
input#enabled(form = 'scheduleform', ng-model = 'ctrl.formData.enabled', type = 'checkbox')
.help-block.col-md-10 Enable immediatly after creation
.panel.panel-default
.panel-heading.panel-title
i.fa.fa-clock-o
| Schedule
.panel-body.form-horizontal
.text-center(ng-if = '!ctrl.formData'): i.xo-icon-loading
xo-scheduler(data = 'ctrl.formData', api = 'ctrl.scheduleApi')
.grid-sm
.panel.panel-default
.panel-body
fieldset.center(ng-disabled = '!ctrl.ready')
button.btn.btn-lg.btn-primary(form = 'scheduleform', type = 'submit')
i.fa.fa-clock-o
| &nbsp;
i.fa.fa-arrow-right
| &nbsp;
i.fa.fa-database
| &nbsp;Save&nbsp;
| &nbsp;
button.btn.btn-lg.btn-default(type = 'button', ng-click = 'ctrl.reset()')
| &nbsp;Reset&nbsp;
.grid-sm
.panel.panel-default
.panel-heading.panel-title
i.fa.fa-list-ul
| Schedules
.panel-body
.text-center(ng-if = '!(ctrl.schedules | count)') No schedules found
table.table(ng-if = 'ctrl.schedules | count')
tr
th Name
th Job
th.hidden-xs Schedule
th
tr(ng-repeat = 'schedule in ctrl.schedules | map | orderBy:"name" track by schedule.id')
td
| {{ schedule.name }}&ensp;
span.text-muted.hidden-xs ({{schedule.id}})&ensp;
br.visible-xs-block
span.label.label-success(ng-if = 'schedule.enabled') enabled
td {{ ctrl.jobs[schedule.job].name }} ({{ ctrl.jobs[schedule.job].method }})
td.hidden-xs {{ ctrl.prettyCron(schedule.cron) }}
td
span.pull-right
button.btn.btn-primary(type = 'button', ng-click = 'ctrl.edit(schedule.id)'): i.fa.fa-pencil
| &nbsp;
button.btn.btn-warning(type = 'button', ng-click = 'ctrl.run(schedule)', ng-disabled = 'ctrl.running[schedule.id]'): i.fa.fa-play
| &nbsp;
button.btn.btn-danger(type = 'button', ng-click = 'ctrl.delete(schedule.id)'): i.fa.fa-trash

View File

@@ -0,0 +1,17 @@
.menu-grid
.side-menu
ul.nav
li
a(ui-sref = '.overview', ui-sref-active = 'active')
i.fa.fa-fw.fa-eye.fa-menu
span.menu-entry Overview
li
a(ui-sref = '.job')
i.fa.fa-fw.fa-cogs.fa-menu
span.menu-entry Jobs
li
a(ui-sref = '.schedule')
i.fa.fa-fw.fa-clock-o.fa-menu
span.menu-entry Scheduler
.side-content(ui-view = '')

View File

@@ -310,16 +310,16 @@ div(style="margin-top: 57px; visibility: hidden; height: 0") .
i.xo-icon-working(ng-if="isVMWorking(VM)", tooltip="{{VM.power_state}} and {{(VM.current_operations | map)[0]}}")
i(class="xo-icon-{{VM.power_state | lowercase}}",ng-if="!isVMWorking(VM)", tooltip="{{VM.power_state}}")
//- VM name.
td.vm-name.col-xs-8.col-sm-2.col-md-2
td.vm-name.col-xs-8.col-sm-3.col-md-3
p.vm {{VM.name_label}}
//- Quick actions.
td.vm-quick-buttons.col-md-2.hidden-xs
td.vm-quick-buttons.col-md-1.col-sm-1.hidden-xs
.quick-buttons
a(tooltip="Shutdown VM", xo-click="confirmAction('stopVM', VM.id)")
a(ng-if="VM.power_state == ('Running' || 'Paused')", tooltip="Shutdown VM", xo-click="confirmAction('stopVM', VM.id)")
i.fa.fa-stop
a(tooltip="Start VM", xo-click="startVM(VM.id)")
a(ng-if="VM.power_state == ('Halted')", tooltip="Start VM", xo-click="startVM(VM.id)")
i.fa.fa-play
a(tooltip="Reboot VM", xo-click="confirmAction('rebootVM', VM.id)")
a(ng-if="VM.power_state == ('Running' || 'Paused')", tooltip="Reboot VM", xo-click="confirmAction('rebootVM', VM.id)")
i.fa.fa-refresh
a(tooltip="VM Console", xo-sref="consoles_view({id: VM.id})")
i.xo-icon-console
@@ -364,20 +364,20 @@ div(style="margin-top: 57px; visibility: hidden; height: 0") .
i.xo-icon-working(ng-if="isVMWorking(VM)", tooltip="{{VM.power_state}} and {{(VM.current_operations | map)[0]}}")
i(class="xo-icon-{{VM.power_state | lowercase}}",ng-if="!isVMWorking(VM)", tooltip="{{VM.power_state}}")
//- VM name.
td.vm-name.col-xs-8.col-sm-2.col-md-2
td.vm-name.col-xs-8.col-sm-3.col-md-3
p.vm {{VM.name_label}}
//- Quick actions.
td.vm-quick-buttons.col-md-2.hidden-xs
td.vm-quick-buttons.col-md-1.hidden-xs
.quick-buttons
a(tooltip="Shutdown VM", xo-click="stopVM(VM.id)")
a(ng-if="VM.power_state == ('Running' || 'Paused')", tooltip="Shutdown VM", xo-click="stopVM(VM.id)")
i.fa.fa-stop
a(ng-if="VM.power_state == 'Suspended'", tooltip="Resume VM", xo-click="resumeVM(VM.id)")
i.fa.fa-play
a(ng-if="VM.power_state != 'Suspended'", tooltip="Start VM", xo-click="startVM(VM.id)")
i.fa.fa-play
a(tooltip="Reboot VM", xo-click="rebootVM(VM.id)")
a(ng-if="VM.power_state == ('Running' || 'Paused')", tooltip="Reboot VM", xo-click="rebootVM(VM.id)")
i.fa.fa-refresh
a(tooltip="VM Console")
a(tooltip="VM Console", xo-sref="consoles_view({id: VM.id})")
i.xo-icon-console
//- Description.
td.vm-description.col-md-4.hidden-xs

View File

@@ -1,4 +1,5 @@
angular = require 'angular'
assign = require 'lodash.assign'
filter = require 'lodash.filter'
forEach = require 'lodash.foreach'
isEmpty = require 'lodash.isempty'
@@ -20,7 +21,7 @@ module.exports = angular.module 'xoWebApp.vm', [
.controller 'VmCtrl', (
$scope, $state, $stateParams, $location, $q
xoApi, xo
sizeToBytesFilter, bytesToSizeFilter, xoHideUnauthorizedFilter
bytesToSizeFilter, xoHideUnauthorizedFilter
modal
$window
$timeout
@@ -36,6 +37,7 @@ module.exports = angular.module 'xoWebApp.vm', [
do (
networksByPool = xoApi.getIndex('networksByPool')
srsByContainer = xoApi.getIndex('srsByContainer')
hostsByPool = xoApi.getIndex('hostsByPool')
poolSrs = null
hostSrs = null
) ->
@@ -48,6 +50,10 @@ module.exports = angular.module 'xoWebApp.vm', [
srs = []
poolSrs and forEach(poolSrs, (sr) => srs.push(sr))
hostSrs and forEach(hostSrs, (sr) => srs.push(sr))
if (($scope.VM?.power_state is 'Halted') || ($scope.VM?.power_state is 'Suspended')) && pool.id
forEach hostsByPool[pool.id], (host) ->
forEach srsByContainer[host.id], (sr) -> srs.push(sr)
srs = xoHideUnauthorizedFilter(srs)
$scope.writable_SRs = filter(srs, (sr) => sr.content_type isnt 'iso')
$scope.SRs = srs
@@ -161,9 +167,11 @@ module.exports = angular.module 'xoWebApp.vm', [
continue unless oVbd
oVdi = get oVbd.VDI
continue unless oVdi
VDIs.push oVdi if oVdi and not oVbd.is_cd_drive
if not oVbd.is_cd_drive
oVdi = assign({}, oVdi, {size: bytesToSizeFilter(oVdi.size), position: oVbd.position})
VDIs.push oVdi
$scope.VDIs = sortBy(VDIs, (value) -> (get resolveVBD(value))?.position);
$scope.VDIs = sortBy(VDIs, 'position');
descriptor = (obj) ->
if !obj
@@ -234,7 +242,7 @@ module.exports = angular.module 'xoWebApp.vm', [
$scope.savingBootOrder = true
paramString = ''
forEach(bootParams, (boot) -> boot.v && paramString += boot.e)
return xoApi.call 'vm.bootOrder', {vm: id, order: paramString}
return xo.vm.setBootOrder {vm: id, order: paramString}
.finally () ->
$scope.savingBootOrder = false
$scope.bootReordering = false
@@ -302,6 +310,13 @@ module.exports = angular.module 'xoWebApp.vm', [
message: 'Start VM'
}
$scope.recoveryStartVM = (id) ->
xo.vm.recoveryStart id
notify.info {
title: 'VM starting...'
message: 'Start VM in recovery mode'
}
$scope.stopVM = (id) ->
modal.confirm
title: 'VM shutdown'
@@ -405,9 +420,9 @@ module.exports = angular.module 'xoWebApp.vm', [
$data = {
id: VM.id
}
if memory isnt $scope.memorySize and (memory = sizeToBytesFilter memory)
if memory isnt $scope.memorySize
$data.memory = memory
$scope.memorySize = bytesToSizeFilter memory
$scope.memorySize = memory
if CPUs isnt VM.CPUs.number
$data.CPUs = +CPUs
if name_label isnt VM.name_label
@@ -450,6 +465,7 @@ module.exports = angular.module 'xoWebApp.vm', [
$scope.saveDisks = (data) ->
# Group data by disk.
disks = {}
sizeChanges = false
forEach data, (value, key) ->
i = key.indexOf '/'
(disks[key.slice 0, i] ?= {})[key.slice i + 1] = value
@@ -457,62 +473,73 @@ module.exports = angular.module 'xoWebApp.vm', [
promises = []
# Handle SR change.
forEach disks, (attributes, id) ->
disk = get id
if attributes.$SR isnt disk.$SR
promises.push (migrateDisk id, attributes.$SR)
if attributes.size isnt bytesToSizeFilter(disk.size) # /!\ attributes are provided by a modified copy of disk
sizeChanges = true
return false
return
preCheck = if sizeChanges then modal.confirm({title: 'Disk resizing', message: 'Growing the size of a disk is not reversible'}) else $q.resolve()
return preCheck
.then ->
# Handle SR change.
forEach disks, (attributes, id) ->
disk = get id
if attributes.$SR isnt disk.$SR
promises.push(migrateDisk(id, attributes.$SR))
if attributes.size isnt bytesToSizeFilter(disk.size) # /!\ attributes are provided by a modified copy of disk
promises.push(xo.disk.resize(id, attributes.size))
delete attributes.size
# Keep only changed attributes.
forEach attributes, (value, name) ->
delete attributes[name] if value is disk[name]
return
unless isEmpty attributes
# Inject id.
attributes.id = id
# Ask the server to update the object.
promises.push(xoApi.call('vdi.set', attributes))
forEach disks, (attributes, id) ->
# Keep only changed attributes.
disk = get id
forEach attributes, (value, name) ->
delete attributes[name] if value is disk[name]
return
unless isEmpty attributes
# Inject id.
attributes.id = id
# Handle Position changes
vbds = xoApi.get($scope.VM.$VBDs)
notFreePositions = Object.create(null)
forEach vbds, (vbd) ->
if vbd.is_cd_drive
notFreePositions[vbd.position] = null
# Ask the server to update the object.
promises.push xoApi.call 'vdi.set', attributes
return
position = 0
forEach $scope.VDIs, (vdi) ->
oVbd = get(resolveVBD(vdi))
unless oVbd
return
# Handle Position changes
vbds = xoApi.get($scope.VM.$VBDs)
notFreePositions = Object.create(null)
forEach vbds, (vbd) ->
if vbd.is_cd_drive
notFreePositions[vbd.position] = null
while position of notFreePositions
++position
position = 0
forEach $scope.VDIs, (vdi) ->
oVbd = get(resolveVBD(vdi))
unless oVbd
return
if +oVbd.position isnt position
promises.push(
xoApi.call('vbd.set', {
id: oVbd.id,
position: String(position)
})
)
while position of notFreePositions
++position
if +oVbd.position isnt position
promises.push(
xoApi.call('vbd.set', {
id: oVbd.id,
position: String(position)
})
)
++position
return $q.all promises
.catch (err) ->
console.log(err);
notify.error {
title: 'saveDisks'
message: err
}
return $q.all promises
.catch (err) ->
console.log(err);
notify.error {
title: 'saveDisks'
message: err
}
$scope.deleteDisk = (id) ->
modal.confirm({

View File

@@ -81,10 +81,10 @@
| {{memorySize}}
dt UUID
dd {{VM.UUID}}
dt(ng-if="VM.PV_args") PV Args
dd(ng-if="VM.PV_args")
dt(ng-if= "VM.virtualizationMode !== 'hvm'") PV Args
dd(ng-if= "VM.virtualizationMode !== 'hvm'")
span(editable-text="VM.PV_args", e-name="PV_args", e-form="vmSettings")
| {{VM.PV_args}}
| {{VM.PV_args}}
dt(ng-if="refreshStatControl.running && stats") Xen tools
dd(ng-if="refreshStatControl.running && stats")
span.text-success(ng-if="VM.xenTools === 'up to date'") Installed
@@ -241,7 +241,7 @@
br
p.center(ng-if="refreshStatControl.running")
i.xo-icon-loading
| &nbsp; Fetching stats...
| &nbsp; Fetching stats...
.grid
.grid-cell(ng-if="VM.os_version.distro")
p.stat-name OS:
@@ -255,7 +255,7 @@
span(ng-if="VM.PV_drivers && !VM.PV_drivers_up_to_date") Outdated
//- Action panel
.grid-sm
.grid-sm(ng-if = 'canOperate()')
.panel.panel-default
.panel-heading.panel-title
i.fa.fa-flash
@@ -272,6 +272,9 @@
.grid-cell.btn-group(ng-if="VM.power_state == ('Halted')")
button.btn(tooltip="Start VM", tooltip-placement="top", type="button", style="width: 90%", xo-click="startVM(VM.id)", ng-if = 'canOperate()')
i.fa.fa-play.fa-2x.fa-fw
.grid-cell.btn-group(ng-if="VM.power_state == ('Halted')")
button.btn(tooltip="Start VM in recovery mode", tooltip-placement="top", type="button", style="width: 90%", xo-click="recoveryStartVM(VM.id)", ng-if = 'canOperate()')
i.fa.fa-forward.fa-2x.fa-fw
.grid-cell.btn-group(ng-if="VM.power_state == ('Running' || 'Paused')")
button.btn(tooltip="Reboot VM", tooltip-placement="top", type="button", style="width: 90%", xo-click="rebootVM(VM.id)", ng-if = 'canOperate()')
i.fa.fa-refresh.fa-2x.fa-fw
@@ -344,24 +347,8 @@
button.btn(tooltip="Create a snapshot", tooltip-placement="top", style="width: 90%", type="button", xo-click="snapshotVM(VM.id,VM.name_label)", ng-if = 'canAdmin()')
i.xo-icon-snapshot.fa-2x.fa-fw
.grid-cell.btn-group.dropdown(dropdown, ng-if="canAdmin()")
button.btn.dropdown-toggle(
dropdown-toggle
tooltip="Export the VM"
tooltip-placement="top"
style="width: 90%"
type="button"
)
button.btn(tooltip="Export the VM", tooltip-placement="top", style="width: 90%", type="button", xo-click="exportVM(VM.id)", ng-if="canAdmin()")
i.fa.fa-download.fa-2x.fa-fw
span.caret
ul.dropdown-menu.left(role="menu")
li(ng-if = 'canAdmin()')
a(xo-click="exportVM(VM.id)")
i.fa.fa-download.fa-fw
| Full export
li(ng-if = 'canAdmin()')
a(xo-click="exportOnlyMetadataVM(VM.id)")
i.fa.fa-database.fa-fw
| Only Metadata
.grid-cell.btn-group.dropdown(dropdown, ng-if="canAdmin()")
button.btn.dropdown-toggle(
dropdown-toggle
@@ -476,19 +463,21 @@
td
xo-tag(object = 'VDI')
td
//- FIXME: should be editable, but the server needs first
//- to accept a human readable string.
| {{VDI.size | bytesToSize}}
span(
editable-text="VDI.size"
e-name = '{{VDI.id}}/size'
)
| {{VDI.size}}
td.oneliner
span(
ng-if = 'canView((VDI.$SR | resolve).id)'
editable-select="(VDI.$SR | resolve).id"
e-ng-options="SR.id as (SR.name_label + ' (' + (SR.size - SR.usage | bytesToSize) + ' free)') for SR in writable_SRs"
e-ng-options="SR.id as (SR.name_label + ' (' + (SR.size - SR.usage | bytesToSize) + ' free) ' + (SR.$container | resolve).name_label) for SR in writable_SRs"
e-name = '{{VDI.id}}/$SR'
)
//- Are SR editable? will trigger moving VDI to the new SR
a(xo-sref="SRs_view({id: (VDI.$SR | resolve).id})")
| {{(VDI.$SR | resolve).name_label}}
| {{(VDI.$SR | resolve).name_label}} ({{((VDI.$SR | resolve).$container | resolve).name_label}})
td(ng-if="isConnected(VDI)")
span.label.label-success Connected
span.pull-right.btn-group.quick-buttons(ng-if="canAdmin()")
@@ -565,7 +554,7 @@
i.fa.fa-minus(ng-if = 'creatingVdi')
| New Disk
| &nbsp;
button.btn(type="button", ng-class = '{"btn-success": bootReordering, "btn-primary": !bootReordering}', ng-disabled="disksForm.$waiting", ng-click="bootReordering = !bootReordering;adding = false;creatingVdi = false", ng-hide = '!canAdmin()')
button.btn(type="button", ng-class = '{"btn-success": bootReordering, "btn-primary": !bootReordering}', ng-disabled="disksForm.$waiting", ng-click="bootReordering = !bootReordering;adding = false;creatingVdi = false", ng-hide = '!canAdmin()', ng-show = "VM.virtualizationMode === 'hvm'")
i.fa.fa-plus(ng-if = '!bootReordering')
i.fa.fa-minus(ng-if = 'bootReordering')
| Boot order
@@ -606,11 +595,11 @@
| &nbsp;
.form-group
//- label(for = 'newDiskSize') Size&nbsp;
input#newDiskSize.form-control(type = 'text', ng-model = 'newDiskSize', required, placeholder = 'Size e.g 128MB, 8GB, 2TB...')
input#newDiskSize.form-control(type = 'text', ng-model = 'newDiskSize', required, placeholder = 'Size e.g 128 MiB, 8 GiB, 2 TiB...')
| &nbsp;
.form-group
//- label(for = 'newDiskSR') SR&nbsp;
select.form-control(ng-model = 'newDiskSR', required, ng-options="SR.id as (SR.name_label + ' (' + (SR.size - SR.usage | bytesToSize) + ' free)') for SR in writable_SRs")
select.form-control(ng-model = 'newDiskSR', required, ng-options="SR.id as (SR.name_label + ' (' + (SR.size - SR.usage | bytesToSize) + ' free) ' + (SR.$container | resolve).name_label) for SR in writable_SRs")
option(value = '', disabled) Choose your SR
| &nbsp;
br
@@ -738,13 +727,15 @@
.panel-body
p.center(ng-if="!VM.snapshots.length") No snapshots
table.table.table-hover(ng-if="VM.snapshots.length")
th Date
th Name
th.col-md-4 Date
th.col-md-8 Name
tr(ng-repeat="snapshot in VM.snapshots | resolve | orderBy:'-snapshot_time' | slice:(5*(currentSnapPage-1)):(5*currentSnapPage) track by snapshot.id")
td.oneliner {{snapshot.snapshot_time*1e3 | date:"medium"}}
td.oneliner
span(editable-text="snapshot.name_label", e-name="name_label", e-form="vmSnap", onbeforesave="saveSnapshot(snapshot.id, $data)")
| {{snapshot.name_label}}
span(ng-if="snapshot.tags | includes:'quiesce'")
i.fa.fa-info-circle(tooltip = "Quiesced snapshot")
| {{snapshot.name_label}}
span.pull-right.btn-group.quick-buttons
a(tooltip="Export this snapshot", type="button", xo-click="exportVM(snapshot.id)", ng-if = 'canAdmin()')
i.fa.fa-upload.fa-lg

View File

@@ -77,7 +77,10 @@ export default angular.module('no-vnc', [])
rfb = new RFB({
encrypt: isSecure,
target: canvas,
wsProtocols: ['chat']
wsProtocols: ['chat'],
onClipboard (rfb, text) {
setClipboard(text)
}
})
rfb._onUpdateState = (rfb, state) => {
@@ -101,6 +104,11 @@ export default angular.module('no-vnc', [])
}
this.remoteControl = {
pasteToClipboard (text) {
if (rfb) {
rfb.clipboardPasteFrom(text)
}
},
sendCtrlAltDel () {
if (rfb) {
rfb.sendCtrlAltDel()
@@ -108,8 +116,28 @@ export default angular.module('no-vnc', [])
}
}
const setClipboard = (text) => {
this.onClipboardChange({ clipboardContent: text })
}
let canvas = $element.find('canvas')[0]
$scope.unfocus = () => {
if (rfb) {
rfb.get_keyboard().ungrab()
rfb.get_mouse().ungrab()
}
}
$scope.focus = () => {
if (rfb) {
if (document.activeElement) {
document.activeElement.blur()
}
rfb.get_keyboard().grab()
rfb.get_mouse().grab()
}
}
$attrs.$observe('url', (url) => {
reset(url)
})
@@ -122,7 +150,8 @@ export default angular.module('no-vnc', [])
controller: 'NoVncCtrl as noVnc',
restrict: 'E',
scope: {
remoteControl: '='
remoteControl: '=',
onClipboardChange: '&'
},
template: view
}

View File

@@ -1,5 +1,7 @@
canvas.center-block(
height = "{{noVnc.height}}"
width = "{{noVnc.width}}"
ng-mouseenter = 'focus()'
ng-mouseleave = 'unfocus()'
)
| Sorry, your browser does not support the canvas element.

View File

@@ -4,7 +4,7 @@
select.form-control(
ng-model = 'isoDevice.isos.mounted'
ng-change = 'isoDevice.insert(isoDevice.vm, isoDevice.isos.mounted)'
ng-options = 'iso.iso.id as iso.label group by iso.sr for iso in isoDevice.isos.opts'
ng-options = 'iso.iso.id as iso.label group by iso.sr for iso in isoDevice.isos.opts | orderBy:"label"'
unfocus-on-change
)
option(value = '', disabled) -- CD Drive (empty) --

314
app/node_modules/scheduler/index.js generated vendored Normal file
View File

@@ -0,0 +1,314 @@
import angular from 'angular'
import assign from 'lodash.assign'
import forEach from 'lodash.foreach'
import indexOf from 'lodash.indexof'
import later from 'later'
import moment from 'moment'
import prettyCron from 'prettycron'
import remove from 'lodash.remove'
later.date.localTime()
import view from './view'
export default angular.module('xoWebApp.scheduler', [])
.directive('xoScheduler', function () {
return {
restrict: 'E',
template: view,
controller: 'XoScheduler as ctrl',
bindToController: true,
scope: {
data: '=',
api: '='
}
}
})
.controller('XoScheduler', function () {
this.init = () => {
let i, j
const minutes = []
for (i = 0; i < 6; i++) {
minutes[i] = []
for (j = 0; j < 10; j++) {
minutes[i].push(10 * i + j)
}
}
this.minutes = minutes
const hours = []
for (i = 0; i < 3; i++) {
hours[i] = []
for (j = 0; j < 8; j++) {
hours[i].push(8 * i + j)
}
}
this.hours = hours
const days = []
for (i = 0; i < 4; i++) {
days[i] = []
for (j = 1; j < 8; j++) {
days[i].push(7 * i + j)
}
}
days.push([29, 30, 31])
this.days = days
this.months = [
[
{v: 1, l: 'Jan'},
{v: 2, l: 'Feb'},
{v: 3, l: 'Mar'},
{v: 4, l: 'Apr'},
{v: 5, l: 'May'},
{v: 6, l: 'Jun'}
],
[
{v: 7, l: 'Jul'},
{v: 8, l: 'Aug'},
{v: 9, l: 'Sep'},
{v: 10, l: 'Oct'},
{v: 11, l: 'Nov'},
{v: 12, l: 'Dec'}
]
]
this.dayWeeks = [
{v: 0, l: 'Sun'},
{v: 1, l: 'Mon'},
{v: 2, l: 'Tue'},
{v: 3, l: 'Wed'},
{v: 4, l: 'Thu'},
{v: 5, l: 'Fri'},
{v: 6, l: 'Sat'}
]
this.resetData()
}
this.selectMinute = function (minute) {
if (this.isSelectedMinute(minute)) {
remove(this.data.minSelect, v => String(v) === String(minute))
} else {
this.data.minSelect.push(minute)
}
}
this.isSelectedMinute = function (minute) {
return indexOf(this.data.minSelect, minute) > -1 || indexOf(this.data.minSelect, String(minute)) > -1
}
this.selectHour = function (hour) {
if (this.isSelectedHour(hour)) {
remove(this.data.hourSelect, v => String(v) === String(hour))
} else {
this.data.hourSelect.push(hour)
}
}
this.isSelectedHour = function (hour) {
return indexOf(this.data.hourSelect, hour) > -1 || indexOf(this.data.hourSelect, String(hour)) > -1
}
this.selectDay = function (day) {
if (this.isSelectedDay(day)) {
remove(this.data.daySelect, v => String(v) === String(day))
} else {
this.data.daySelect.push(day)
}
}
this.isSelectedDay = function (day) {
return indexOf(this.data.daySelect, day) > -1 || indexOf(this.data.daySelect, String(day)) > -1
}
this.selectMonth = function (month) {
if (this.isSelectedMonth(month)) {
remove(this.data.monthSelect, v => String(v) === String(month))
} else {
this.data.monthSelect.push(month)
}
}
this.isSelectedMonth = function (month) {
return indexOf(this.data.monthSelect, month) > -1 || indexOf(this.data.monthSelect, String(month)) > -1
}
this.selectDayWeek = function (dayWeek) {
if (this.isSelectedDayWeek(dayWeek)) {
remove(this.data.dayWeekSelect, v => String(v) === String(dayWeek))
} else {
this.data.dayWeekSelect.push(dayWeek)
}
}
this.isSelectedDayWeek = function (dayWeek) {
return indexOf(this.data.dayWeekSelect, dayWeek) > -1 || indexOf(this.data.dayWeekSelect, String(dayWeek)) > -1
}
this.noMinutePlan = function (set = false) {
if (!set) {
// The last part (after &&) of this expression is reliable because we maintain the minSelect array with lodash.remove
return this.data.min === 'select' && this.data.minSelect.length === 1 && String(this.data.minSelect[0]) === '0'
} else {
this.data.minSelect = [0]
this.data.min = 'select'
return true
}
}
this.noHourPlan = function (set = false) {
if (!set) {
// The last part (after &&) of this expression is reliable because we maintain the hourSelect array with lodash.remove
return this.data.hour === 'select' && this.data.hourSelect.length === 1 && String(this.data.hourSelect[0]) === '0'
} else {
this.data.hourSelect = [0]
this.data.hour = 'select'
return true
}
}
this.resetData = () => {
this.data.minRange = 5
this.data.hourRange = 2
this.data.minSelect = [0]
this.data.hourSelect = []
this.data.daySelect = []
this.data.monthSelect = []
this.data.dayWeekSelect = []
this.data.min = 'select'
this.data.hour = 'all'
this.data.day = 'all'
this.data.month = 'all'
this.data.dayWeek = 'all'
this.data.cronPattern = '* * * * *'
this.data.summary = []
this.data.previewLimit = 0
this.update()
}
this.update = () => {
const d = this.data
const i = (d.min === 'all' && '*') ||
(d.min === 'range' && ('*/' + d.minRange)) ||
(d.min === 'select' && d.minSelect.join(',')) ||
'*'
const h = (d.hour === 'all' && '*') ||
(d.hour === 'range' && ('*/' + d.hourRange)) ||
(d.hour === 'select' && d.hourSelect.join(',')) ||
'*'
const dm = (d.day === 'all' && '*') ||
(d.day === 'select' && d.daySelect.join(',')) ||
'*'
const m = (d.month === 'all' && '*') ||
(d.month === 'select' && d.monthSelect.join(',')) ||
'*'
const dw = (d.dayWeek === 'all' && '*') ||
(d.dayWeek === 'select' && d.dayWeekSelect.join(',')) ||
'*'
this.data.cronPattern = i + ' ' + h + ' ' + dm + ' ' + m + ' ' + dw
const tabState = {
min: {
all: d.min === 'all',
range: d.min === 'range',
select: d.min === 'select'
},
hour: {
all: d.hour === 'all',
range: d.hour === 'range',
select: d.hour === 'select'
},
day: {
all: d.day === 'all',
range: d.day === 'range',
select: d.day === 'select'
},
month: {
all: d.month === 'all',
select: d.month === 'select'
},
dayWeek: {
all: d.dayWeek === 'all',
select: d.dayWeek === 'select'
}
}
this.tabs = tabState
this.summarize()
}
this.summarize = () => {
const schedule = later.parse.cron(this.data.cronPattern)
const occurences = later.schedule(schedule).next(25)
this.data.summary = []
forEach(occurences, occurence => {
this.data.summary.push(moment(occurence).format('LLLL'))
})
}
const cronToData = (data, cron) => {
const d = Object.create(null)
const cronItems = cron.split(' ')
if (cronItems[0] === '*') {
d.min = 'all'
} else if (cronItems[0].indexOf('/') !== -1) {
d.min = 'range'
const [, range] = cronItems[0].split('/')
d.minRange = range
} else {
d.min = 'select'
d.minSelect = cronItems[0].split(',')
}
if (cronItems[1] === '*') {
d.hour = 'all'
} else if (cronItems[1].indexOf('/') !== -1) {
d.hour = 'range'
const [, range] = cronItems[1].split('/')
d.hourRange = range
} else {
d.hour = 'select'
d.hourSelect = cronItems[1].split(',')
}
if (cronItems[2] === '*') {
d.day = 'all'
} else {
d.day = 'select'
d.daySelect = cronItems[2].split(',')
}
if (cronItems[3] === '*') {
d.month = 'all'
} else {
d.month = 'select'
d.monthSelect = cronItems[3].split(',')
}
if (cronItems[4] === '*') {
d.dayWeek = 'all'
} else {
d.dayWeek = 'select'
d.dayWeekSelect = cronItems[4].split(',')
}
assign(data, d)
}
this.prettyCron = prettyCron.toString.bind(prettyCron)
this.api.setCron = cron => {
cronToData(this.data, cron)
this.update()
}
this.api.resetData = this.resetData.bind(this)
this.init()
})
.name

9
app/node_modules/scheduler/package.json generated vendored Normal file
View File

@@ -0,0 +1,9 @@
{
"private": true,
"browserify": {
"transform": [
"babelify",
"browserify-plain-jade"
]
}
}

104
app/node_modules/scheduler/view.jade generated vendored Normal file
View File

@@ -0,0 +1,104 @@
accordion(ng-if = 'ctrl.data', close-others= 'false', ng-click = 'ctrl.update()')
accordion-group
accordion-heading Month
tabset
tab(select = 'ctrl.data.month = "all"', active = 'ctrl.tabs.month.all')
tab-heading every month
tab(select = 'ctrl.data.month = "select"', active = 'ctrl.tabs.month.select')
tab-heading each selected month
br
table.table.table-bordered
tr(ng-repeat = 'line in ctrl.months')
td(ng-click = 'ctrl.selectMonth(month.v)', ng-class = '{"bg-success": ctrl.isSelectedMonth(month.v)}',ng-repeat = 'month in line') {{ month.l }}
accordion-group
accordion-heading Day of the month
tabset
tab(select = 'ctrl.data.day = "all"', active = 'ctrl.tabs.day.all')
tab-heading every day
tab(select = 'ctrl.data.day = "select"', active = 'ctrl.tabs.day.select')
tab-heading each selected day
br
p.text-warning
i.fa.fa-warning
| &nbsp;This selection can restrict or be restricted by "Day of week" selections below. Use the summary preview to ensure your choice.
br
table.table.table-bordered
tr(ng-repeat = 'line in ctrl.days')
td(ng-click = 'ctrl.selectDay(day)', ng-class = '{"bg-success": ctrl.isSelectedDay(day)}',ng-repeat = 'day in line') {{ day }}
accordion-group
accordion-heading Day of week
tabset
tab(select = 'ctrl.data.dayWeek = "all"', active = 'ctrl.tabs.dayWeek.all')
tab-heading every day of week
tab(select = 'ctrl.data.dayWeek = "select"', active = 'ctrl.tabs.dayWeek.select')
tab-heading each selected day of week
br
p.text-warning
i.fa.fa-warning
| &nbsp;This selection can restrict or be restricted by "Day of the month" selections up ahead. Use the summary preview to ensure your choice.
br
table.table.table-bordered
tr
td(ng-click = 'ctrl.selectDayWeek(dayWeek.v)', ng-class = '{"bg-success": ctrl.isSelectedDayWeek(dayWeek.v)}',ng-repeat = 'dayWeek in ctrl.dayWeeks') {{ dayWeek.l }}
accordion-group
accordion-heading Hour
button.btn.btn-primary(ng-if = '!ctrl.noHourPlan()', type = 'button', ng-click = 'ctrl.noHourPlan(true)') Plan nothing on a hourly grain
button.btn.btn-primary.disabled(ng-if = 'ctrl.noHourPlan()', type = 'button')
i.fa.fa-info-circle
| &nbsp;Nothing planned on a hourly grain
br
br
tabset
tab(select = 'ctrl.data.hour = "all"', active = 'ctrl.tabs.hour.all')
tab-heading every hour
tab(select = 'ctrl.data.hour = "range"', active = 'ctrl.tabs.hour.range')
tab-heading every N hour
br
.form-group
label.col-sm-2.control-label {{ ctrl.data.hourRange }}
.col-sm-10
input.form-control(type = 'range', min = '2', max = '23', step = '1', ng-model = 'ctrl.data.hourRange', ng-change = 'ctrl.update()')
tab(select = 'ctrl.data.hour = "select"', active = 'ctrl.tabs.hour.select')
tab-heading each selected hour
br
table.table.table-bordered
tr(ng-repeat = 'line in ctrl.hours')
td(ng-click = 'ctrl.selectHour(hour)', ng-class = '{"bg-success": ctrl.isSelectedHour(hour)}',ng-repeat = 'hour in line') {{ hour }}
accordion-group
accordion-heading Minute
button.btn.btn-primary(ng-if = '!ctrl.noMinutePlan()', type = 'button', ng-click = 'ctrl.noMinutePlan(true)') Plan nothing on a minute grain
button.btn.btn-primary.disabled(ng-if = 'ctrl.noMinutePlan()', type = 'button')
i.fa.fa-info-circle
| &nbsp;Nothing planned on a minute grain
br
br
tabset
tab(select = 'ctrl.data.min = "all"', active = 'ctrl.tabs.min.all')
tab-heading every minute
tab(select = 'ctrl.data.min = "range"', active = 'ctrl.tabs.min.range')
tab-heading every N minutes
br
.form-group
label.col-sm-2.control-label {{ ctrl.data.minRange }}
.col-sm-10
input.form-control(type = 'range', min = '2', max = '59', step = '1', ng-model = 'ctrl.data.minRange', ng-change = 'ctrl.update()')
tab(select = 'ctrl.data.min = "select"', active = 'ctrl.tabs.min.select')
tab-heading each selected minute
br
table.table.table-bordered
tr(ng-repeat = 'line in ctrl.minutes')
td(ng-click = 'ctrl.selectMinute(min)', ng-class = '{"bg-success": ctrl.isSelectedMinute(min)}',ng-repeat = 'min in line') {{ min }}
input.form-control.hidden(type ='text', readonly, ng-model = 'ctrl.data.cronPattern')
.text-center(ng-if = '!ctrl.data'): i.xo-icon-loading
div(ng-if = 'ctrl.data')
p
strong Scheduled to run:&nbsp;
| {{ ctrl.prettyCron(ctrl.data.cronPattern) }}
.form-inline.container-fluid
.form-group
label Preview:&nbsp;
input.form-control(type = 'range', min = '0', max = '{{ ctrl.data.summary.length - 3 }}', step = '1', ng-model = 'ctrl.data.previewLimit')
br
ul
li(ng-repeat = 'occurence in ctrl.data.summary | limitTo: +ctrl.data.previewLimit+3') {{ occurence }}
li ...

4
app/node_modules/tag/index.js generated vendored
View File

@@ -33,6 +33,10 @@ export default angular.module('xoWebApp.tag', [])
this.object = get(this.object)
}
$scope.canAdmin = (id) => {
return id && xoApi.canInteract(id, 'administrate') || false
}
this.add = (tag) => {
tag = trim(tag)
if (tag === '') {

2
app/node_modules/tag/view.jade generated vendored
View File

@@ -6,7 +6,7 @@
i.fa.fa-times(ng-click = 'ctrl.remove(tag)')
| &ensp;{{tag}}
| &ensp;
i.fa.add-button(ng-class = '{"fa-plus": !edit, "fa-minus": edit, edit: edit}', ng-click = 'edit = !edit; newTag = ""')
i.fa.add-button(ng-class = '{"fa-plus": !edit, "fa-minus": edit, edit: edit}', ng-click = 'edit = !edit; newTag = ""', ng-hide = '!canAdmin(ctrl.object.id)')
| &ensp;
form.form-inline(ng-attr-id = 'xo-tag-{{ctrl.object.id}}',ng-submit = 'ctrl.add(newTag); newTag = ""; edit = false')
.input-group(ng-hide = '!edit')

51
app/node_modules/xo-filters/index.js generated vendored
View File

@@ -1,6 +1,8 @@
import angular from 'angular'
import forEach from 'lodash.foreach'
import includes from 'lodash.includes'
import isEmpty from 'lodash.isempty'
import isNumber from 'lodash.isnumber'
import map from 'lodash.map'
import slice from 'lodash.slice'
import xoApi from 'xo-api'
@@ -17,7 +19,11 @@ export default angular.module('xoWebApp.filters', [
.filter('bytesToSize', () => {
const powers = ['', 'K', 'M', 'G', 'T', 'P']
return function bytesToSize (bytes, unit = 'B', base = 1024) {
return function bytesToSize (bytes, unit = 'iB', base = 1024) {
if (!isNumber(bytes)) {
return bytes
}
let i = 0
while (bytes >= base) {
bytes /= base
@@ -31,47 +37,7 @@ export default angular.module('xoWebApp.filters', [
// Maximum 1 decimals.
bytes = ((bytes * 10) | 0) / 10
return `${bytes}${powers[i]}${unit}`
}
})
.filter('sizeToBytes', () => {
/* eslint no-multi-spaces: 0 */
const RE = new RegExp('^' +
'(\\d+(?:\\.\\d+)?)' + // digits ('.' digits)?
'\\s*' + // Optional spaces between the digits and the unit.
'([kmgtp])?' + // Optional unit modifier K/M/G/T/P.
'b?' + // Optional unit (“b”), not meaningful.
'$', 'i')
const factors = {
k: 1024,
m: 1048576,
g: 1073741824,
t: 1099511627776,
p: 1125899906842624
}
return function sizeToBytes (size) {
const matches = RE.exec(size)
// If the input is invalid, just returns null.
if (!matches) {
return null
}
let value = +matches[1]
const modifier = matches[2]
if (modifier) {
const factor = factors[modifier.toLowerCase()]
if (factor) {
value *= factor
}
}
return Math.round(value)
return `${bytes} ${powers[i]}${unit}`
}
})
@@ -105,6 +71,7 @@ export default angular.module('xoWebApp.filters', [
.filter('isEmpty', () => isEmpty)
.filter('isNotEmpty', () => (collection) => !isEmpty(collection))
.filter('includes', () => includes)
.filter('duration', () => (n, unit = 'ms') => (n > 0 && moment.duration(n, unit).humanize() || ''))

47
app/node_modules/xo/index.js generated vendored
View File

@@ -104,6 +104,9 @@ export default angular.module('xo', [
disk: {
create: action('Create disk', 'disk.create', {
argsMapper: (name, size, sr) => ({name, size, sr})
}),
resize: action('Resize disk', 'disk.resize', {
argsMapper: (id, size) => ({id, size})
})
},
@@ -113,7 +116,10 @@ export default angular.module('xo', [
patch: action('Upload patch', 'pool.patch', {
argsMapper: (pool) => ({pool})
}),
mergeInto: action('Merge pools', 'pool.mergeInto')
mergeInto: action('Merge pools', 'pool.mergeInto'),
setDefaultSr: action('Set the default SR', 'pool.setDefaultSr', {
argsMapper: (pool, sr) => ({pool, sr})
})
},
host: {
@@ -129,12 +135,15 @@ export default angular.module('xo', [
listMissingPatches: action('Check available patches', 'host.listMissingPatches', {
argsMapper: (host) => ({host})
}),
installPatch: action('Install a patch from a patch id', 'host.installPatch', {
installPatch: action('Install a patch from a patch id', 'host.installPatch', {
argsMapper: (host, patch) => ({host, patch})
}),
installAllPatches: action('Install all the missing patches on a host', 'host.installAllPatches', {
argsMapper: (host) => ({host})
}),
emergencyShutdownHost: action('Suspend all VMs running on host and shutdown host', 'host.emergencyShutdownHost', {
argsMapper: (host) => ({host})
}),
refreshStats: action('Get Stats', 'host.stats', {
notification: false,
argsMapper: (host, granularity) => ({host, granularity})
@@ -228,6 +237,12 @@ export default angular.module('xo', [
}),
restart: action('Restart a Docker Container', 'docker.restart', {
argsMapper: (vm, container) => ({vm, container})
}),
register: action('Register the VM for the Docker plugin', 'docker.register', {
argsMapper: (vm) => ({ vm })
}),
deregister: action('Deregister the VM for the Docker plugin', 'docker.deregister', {
argsMapper: (vm) => ({ vm })
})
},
@@ -262,16 +277,24 @@ export default angular.module('xo', [
import: action('Import VM', 'vm.import', {
argsMapper: (host) => ({ host })
}),
importBackup: action('Imports a VM from a remote point', 'vm.importBackup', {
argsMapper: (remote, file, sr) => ({ remote, file, sr })
}),
importDeltaBackup: action('Imports a delta backup from a remote point', 'vm.importDeltaBackup', {
argsMapper: (remote, filePath, sr) => ({ remote, filePath, sr })
}),
migrate: action('Migrate VM', 'vm.migrate', {
argsMapper: (id, host_id) => ({ id, host_id })
}),
restart: action('Restart VM', 'vm.restart', {
argsMapper: (id, force = false) => ({ id, force })
}),
recoveryStart: action('Start VM in recovery mode', 'vm.recoveryStart'),
start: action('Start VM', 'vm.start'),
stop: action('Stop VM', 'vm.stop', {
argsMapper: (id, force = false) => ({ id, force })
}),
setBootOrder: action('Set the boot order', 'vm.setBootOrder'),
revert: action('Revert snapshot', 'vm.revert'),
suspend: action('Suspend VM', 'vm.suspend'),
resume: action('Resume VM', 'vm.resume', {
@@ -281,6 +304,12 @@ export default angular.module('xo', [
notification: false,
argsMapper: (id, granularity) => ({id, granularity})
}),
getCloudInitConfig: action('Get Cloud Init Template', 'vm.getCloudInitConfig', {
argsMapper: (template) => ({ template })
}),
createCloudInitConfigDrive: action('Create Cloud Config Drive', 'vm.createCloudInitConfigDrive', {
argsMapper: (vm, sr, config, coreos) => ({ vm, sr, config, coreos })
}),
// TODO: create/set/pause
connectPci: action('Connect PCI device', 'vm.attachPci', {
argsMapper: (vm, pciId) => ({vm, pciId})
@@ -319,20 +348,24 @@ export default angular.module('xo', [
}),
delete: action('Delete a job', 'job.delete', {
argsMapper: (id) => ({id})
}),
runSequence: action('Run a sequence of jobs', 'job.runSequence', {
argsMapper: (idSequence) => ({idSequence})
})
},
schedule: {
getAll: action('Get all schedules', 'schedule.getAll'),
create: action('Create a schedule', 'schedule.create', {
argsMapper: (jobId, cron, enabled) => ({jobId, cron, enabled})
argsMapper: (jobId, cron, enabled, name) => ({jobId, cron, enabled, name})
}),
set: action('Modify a schedule', 'schedule.set', {
argsMapper: (id, jobId = undefined, cron = undefined, enabled = undefined) => {
argsMapper: (id, jobId = undefined, cron = undefined, enabled = undefined, name = undefined) => {
const args = {id}
jobId !== undefined && (args.jobId = jobId)
cron !== undefined && (args.cron = cron)
enabled !== undefined && (args.enabled = enabled)
name !== undefined && (args.name = name)
return args
}
}),
@@ -370,9 +403,6 @@ export default angular.module('xo', [
}),
list: action('List files found at the remote point', 'remote.list', {
argsMapper: id => ({id})
}),
importVm: action('Imports a VM form a remote point', 'remote.importVm', {
argsMapper: (id, file, host) => ({id, file, host})
})
},
@@ -401,6 +431,9 @@ export default angular.module('xo', [
}),
unload: action('Unloads a plugin', 'plugin.unload', {
argsMapper: (id) => ({id})
}),
purgeConfiguration: action('Purge a plugin configuration', 'plugin.purgeConfiguration', {
argsMapper: (id) => ({id})
})
}
}

View File

@@ -445,6 +445,18 @@ html, body, .view-main {
background-color: #2e3133;
}
// transparent background for dropdown buttons
.filter {
background-color: rgba(0, 0, 0, 0);
border: 0;
color: #9d9d9d;
box-shadow: none !important;
&:hover {
background-color: rgba(0, 0, 0, 0) !important;
color: white !important;
}
}
//////////////////////////////////////////////////////////////////////
// Main view
//////////////////////////////////////////////////////////////////////

View File

@@ -1,6 +1,6 @@
{
"name": "xo-web",
"version": "4.9.0",
"version": "4.11.0",
"license": "AGPL-3.0",
"description": "Web interface client for Xen-Orchestra",
"keywords": [
@@ -27,6 +27,7 @@
"browserify": "^12.0.1",
"browserify-plain-jade": "^0.2.2",
"bundle-collapser": "^1.1.4",
"clipboard": "^1.5.5",
"coffeeify": "^1.0.0",
"d3": "^3.5.5",
"event-stream": "^3.3.0",
@@ -60,8 +61,10 @@
"lodash.indexof": "^3.0.2",
"lodash.intersection": "^3.1.0",
"lodash.isempty": "^3.0.3",
"lodash.isnumber": "^3.0.1",
"lodash.keys": "^3.1.2",
"lodash.map": "^3.1.2",
"lodash.mapvalues": "^3.0.1",
"lodash.omit": "^3.1.0",
"lodash.pluck": "^3.1.0",
"lodash.pull": "^3.0.1",