Compare commits

...

241 Commits

Author SHA1 Message Date
Julien Fontanet
155debc864 4.13.0 2016-02-04 19:51:07 +01:00
Julien Fontanet
a5975ac38b Merge branch 'next-release' into stable 2016-02-04 19:50:39 +01:00
Olivier Lambert
204f1cfd6b Merge pull request #674 from vatesfr/pierre-vm-view-ui-enhancements
Select RAM units with dropdown menus
2016-02-04 09:52:50 +01:00
Pierre
2d22e043a0 Dropdown menus to select byte units (Fix #666)
New-VM view: RAM & VDIs sizes
VM view: RAM & VDIs sizes
SR view: VDIs sizes
2016-02-03 16:18:05 +01:00
Olivier Lambert
c26cacaf4e Merge pull request #687 from vatesfr/xo-acl-resolver
Use xo-acl-resolver.
2016-02-03 14:26:40 +01:00
Olivier Lambert
f0048544e2 Merge pull request #681 from vatesfr/olivierlambert-health
WIP: initial work on new health view
2016-02-03 14:16:45 +01:00
Olivier Lambert
cf227dbfa2 Merge pull request #695 from vatesfr/marsaudf-fix-sr-form
fix SR form
2016-02-03 14:00:38 +01:00
Olivier Lambert
a5f8bdbe61 fix issue #693 2016-02-03 13:26:07 +01:00
Olivier Lambert
e442553c6f Fix issue #691 2016-02-03 13:21:17 +01:00
Olivier Lambert
7134acfcd6 Fix issue #690 2016-02-03 13:18:21 +01:00
Olivier Lambert
82439f444e Fix issue #688 2016-02-03 13:03:58 +01:00
Fabrice Marsaud
1a17908488 fix SR form 2016-02-03 12:12:15 +01:00
Julien Fontanet
9af30e99f8 Use xo-acl-resolver. 2016-02-03 11:53:54 +01:00
Fabrice Marsaud
6f942c3417 orphans in sr view 2016-02-03 11:07:07 +01:00
Fabrice Marsaud
57083c90cd Fixes 2016-02-03 10:48:21 +01:00
Fabrice Marsaud
e28bcdd978 Correct rule for orphan snapshots 2016-02-03 10:28:49 +01:00
Olivier Lambert
0b4a5ab2eb working filter 2016-02-02 23:31:00 +01:00
Fabrice Marsaud
034704a330 No warnings for iso SRs 2016-02-02 17:59:59 +01:00
Fabrice Marsaud
5c60eaf6ab SR warnings 2016-02-02 17:45:49 +01:00
Fabrice Marsaud
f5709eac2c orphan snapshots panels 2016-02-02 16:52:57 +01:00
Fabrice Marsaud
5a5e714aca Fixed cpuWeight default choice 2016-02-02 09:43:18 +01:00
Olivier Lambert
747d48e4d9 adding missing files 2016-02-01 18:30:02 +01:00
Olivier Lambert
07a0200f30 WIP: initial work on new health view 2016-02-01 18:28:09 +01:00
Fabrice Marsaud
1c5313f2d9 Fixed cpuWeight type 2016-02-01 17:51:10 +01:00
Olivier Lambert
05e08719fb Merge pull request #678 from vatesfr/olivierlambert-srfixes
Use only physical usage for SRs
2016-02-01 17:31:10 +01:00
Olivier Lambert
ca0e616f88 Only use physical utilization for SR 2016-02-01 17:26:08 +01:00
Fabrice Marsaud
a8d20caba4 CPU Weight can back to default 2016-02-01 16:29:53 +01:00
Olivier Lambert
0d4bbb0a48 Merge pull request #677 from vatesfr/marsaudf-cpu-weigth#633
Edit VM CPU Weight
2016-02-01 16:10:36 +01:00
Fabrice Marsaud
b9cc219530 Handle VM CPU Weight 2016-02-01 15:54:52 +01:00
Olivier Lambert
e204ab5871 Merge pull request #650 from vatesfr/pierre-vm-migration-details
Custom VM migration in VM view
2016-01-28 17:02:35 +01:00
Fabrice Marsaud
16d0c05b4b Fiw attempt on console canvas 2016-01-28 15:33:48 +01:00
Fabrice Marsaud
6f8329d191 VDI multi delete 2016-01-28 15:09:36 +01:00
Olivier Lambert
d751463b26 Merge pull request #660 from vatesfr/abhamonr-avoid-metadata-imp-exp-delta-backups
New delta backup format used. (without 'xva' files) (fix #651)
2016-01-28 11:35:11 +01:00
Olivier Lambert
d3b66eff59 Merge pull request #671 from vatesfr/marsaudf-clear-logs#661
Delete job logs
2016-01-28 11:01:15 +01:00
Olivier Lambert
4257d0332a Merge pull request #672 from vatesfr/marsaudf-console-links#668
Differentiate VM and VM-controller console
2016-01-28 10:02:40 +01:00
Fabrice Marsaud
b80442c061 Fix to remove smb remotes for delta-backups 2016-01-28 09:22:22 +01:00
Fabrice Marsaud
3a0f6820ad Differentiate VM and VM-controller console 2016-01-28 09:02:58 +01:00
Fabrice Marsaud
1bc92f5363 Delete job logs 2016-01-28 08:50:50 +01:00
Olivier Lambert
818ddcf01e Merge pull request #663 from vatesfr/marsaudf-angular-crash#662
Secure VM object concurrent suppression
2016-01-28 08:18:43 +01:00
Olivier Lambert
618ba361c7 Merge pull request #568 from vatesfr/marsaudf-smb-mounts#338
SMB remotes
2016-01-28 08:18:24 +01:00
wescoeur
599160a325 New delta backup format used. (without 'xva' files) (fix #651) 2016-01-27 13:37:54 +01:00
Pierre
35fba6f4ed Custom VM migration from VM view (See #567)
- Migration on the same pool :
	- if the VM's VDIs are on the pool's SRs : standard migration
	- if the VM's VDIs are on local SRs : choose migration network and target SRs
- Migration on another pool : choose migration network, target SRs and target VIFs
2016-01-27 11:36:38 +01:00
Fabrice Marsaud
a14aad75fd external remote url module 2016-01-27 10:12:16 +01:00
Fabrice Marsaud
3513e85b0b remote url composing fix 2016-01-27 10:12:16 +01:00
Fabrice Marsaud
66c0390fc7 No smb remotes for delta backups 2016-01-27 10:12:16 +01:00
Fabrice Marsaud
a6549ccb08 SMB remotes 2016-01-27 10:12:16 +01:00
Julien Fontanet
15d2878014 Merge pull request #669 from Danp2/Danp2-patch-1
Fix spelling of "immediately"
2016-01-27 09:10:44 +01:00
Danp2
d271be8723 Update view.jade 2016-01-26 19:32:09 -06:00
Danp2
6f9d2d99dd Update view.jade 2016-01-26 19:31:43 -06:00
Danp2
5d62664ee3 Update view.jade 2016-01-26 19:30:45 -06:00
Danp2
7124d9f2f8 Update view.jade 2016-01-26 19:26:53 -06:00
Fabrice Marsaud
0459744771 Fixes disk save handling 2016-01-25 17:40:47 +01:00
Fabrice Marsaud
417544b781 Secure VM object concurrent suppression 2016-01-25 17:40:35 +01:00
Olivier Lambert
f9028cb366 Change the word Terminated by Finished for backups 2016-01-22 18:22:50 +01:00
Olivier Lambert
9a264719a9 Avoid broken Angular views. Fix #662 2016-01-21 15:11:42 +01:00
Olivier Lambert
96c213dcc4 Typo about the year in the changelog 2016-01-18 12:56:29 +01:00
Julien Fontanet
dec1a8e204 4.12.0 2016-01-18 10:45:50 +01:00
Olivier Lambert
a17fd697e2 update the CHANGELOG 2016-01-17 16:58:31 +01:00
Fabrice Marsaud
a6ab66e799 Fix #654 2016-01-17 16:12:11 +01:00
Fabrice Marsaud
17095ec3c6 Fix #652 2016-01-17 15:57:58 +01:00
Olivier Lambert
82687147b8 changelog for release 2016-01-17 10:24:28 +01:00
Olivier Lambert
ba76422c1f Merge pull request #648 from vatesfr/abhamonr-continuous-replication-view
Continuous replication view.
2016-01-16 20:04:05 +01:00
wescoeur
083b3c4ece Continuous replication view. (fix #582) 2016-01-15 13:15:43 +01:00
Olivier Lambert
5ecfdf38a8 Merge pull request #600 from vatesfr/abhamonr-button-bootable-disk
Use checkbox to disable/enable bootable disk property. (fix #583)
2016-01-14 16:26:54 +01:00
wescoeur
dd1acf3c2a Use checkbox to disable/enable bootable disk property. (fix #583) 2016-01-14 16:22:45 +01:00
Olivier Lambert
76e9c2d196 Fix issue #643 2016-01-13 18:54:19 +01:00
Julien Fontanet
15f046959d Fix lodash.trim to 3.0.1 (see lodash/lodash#1769). 2016-01-13 16:23:20 +01:00
Olivier Lambert
bf3ba04624 Merge pull request #620 from vatesfr/abhamonr-disable-vm-start-during-delta-import
Disable vm start during delta import and explicit notification. (fix #613)
2016-01-13 11:53:54 +01:00
Olivier Lambert
d997894d9a Merge pull request #614 from vatesfr/pierre-create-multiple-vms
Create multiple VMs at once
2016-01-13 11:33:18 +01:00
wescoeur
c1059db6e5 Disable vm start during delta import and explicit notification. (fix #613) 2016-01-13 11:04:24 +01:00
Pierre
8ad29a2836 Creation of multiple VMs at once
- Panel to enable the creation of multiple VMs at once
- Main name is no longer required when creating multiple VMs
- Number of VMs is checked before creating VMs names input fields
- Redirection to tree view instead of VM view when creating multiple VMs
- Number of new VMs in summary
2016-01-13 10:52:48 +01:00
Olivier Lambert
93a454b835 fix id propagation problem 2016-01-13 10:33:06 +01:00
Olivier Lambert
da899386ec Merge pull request #640 from vatesfr/marsaudf-plugin-view-fix#637
Plugin reload after changes
2016-01-07 11:53:16 +01:00
Fabrice Marsaud
05d22903ea Plugin reload after changes 2016-01-07 11:45:15 +01:00
Olivier Lambert
33945520f1 Fix issue #639 2016-01-06 10:22:52 +01:00
Olivier Lambert
40284809cf choose to boot VM after creation. Fix #635 2016-01-04 16:41:16 +01:00
Olivier Lambert
efc18aaaec ensure CloudConfig drive is created before going on the freshly created VM view 2016-01-04 16:37:04 +01:00
Olivier Lambert
348441b046 improve hostname regex for CloudInit 2016-01-04 12:35:47 +01:00
Olivier Lambert
66601b2e7c remove space in hostname for cloudconfig. Fix #634 2016-01-04 12:33:52 +01:00
Olivier Lambert
724c5e4b73 VM creation auto name, description & select existing install repo 2015-12-31 10:50:15 +01:00
Olivier Lambert
7eff29bc65 remove useless logs 2015-12-31 10:25:49 +01:00
Olivier Lambert
ca002003c2 fix VIFs issues in VM creation 2015-12-31 10:25:23 +01:00
Olivier Lambert
f0675f1f3c Merge pull request #618 from vatesfr/pierre-delete-running-vm
Delete a running or suspended VM (See #616)
2015-12-31 08:56:19 +01:00
Olivier Lambert
976186c525 Merge pull request #631 from vatesfr/olivierlambert-existing-vifs
manage existing VDIs, fix #630
2015-12-30 22:16:00 +01:00
Olivier Lambert
89d5777e52 allow existing VIFs edition during VM creation. Fix #630 2015-12-30 22:12:15 +01:00
Olivier Lambert
8dbb69809c Merge pull request #629 from vatesfr/olivierlambert-custom-templates
Custom templates, fix #627 and #628
2015-12-30 18:18:09 +01:00
Olivier Lambert
7348bd5d15 support templates with existing install_repository, as requested for issue #625 2015-12-30 16:08:39 +01:00
Olivier Lambert
9a46a466f7 properlly manage PV args (related to #625) 2015-12-30 15:27:41 +01:00
Fabrice Marsaud
fafc5c8553 Deltabackup display fix 2015-12-30 13:46:12 +01:00
Julien Fontanet
4ffdfaa506 Merge pull request #619 from vatesfr/pierre-fix-suspend-vm-tree-view
Fixed `suspendVM` in tree view.
2015-12-22 15:56:50 +01:00
Pierre
e3989840ee Fixed suspendVM in tree view. 2015-12-22 15:51:59 +01:00
Pierre
b3e6f531a1 Delete not halted VMs (See #616) 2015-12-22 15:46:19 +01:00
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
92 changed files with 5724 additions and 1547 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,138 @@
# ChangeLog
## **4.12.0** (2016-01-18)
Continuous Replication, Continuous Delta backup...
### Enhancements
- Continuous VM replication [\#582](https://github.com/vatesfr/xo-web/issues/582)
- Continuous Delta Backup [\#576](https://github.com/vatesfr/xo-web/issues/576)
- Scheduler should not run job again if previous instance is not finished [\#642](https://github.com/vatesfr/xo-web/issues/642)
- Boot VM automatically after creation [\#635](https://github.com/vatesfr/xo-web/issues/635)
- Manage existing VIFs in templates [\#630](https://github.com/vatesfr/xo-web/issues/630)
- Support templates with existing install repository [\#627](https://github.com/vatesfr/xo-web/issues/627)
- Remove running VMs [\#616](https://github.com/vatesfr/xo-web/issues/616)
- Prevent a VM to start before delta import is finished [\#613](https://github.com/vatesfr/xo-web/issues/613)
- Spawn multiple VMs at once [\#606](https://github.com/vatesfr/xo-web/issues/606)
- Fixed `suspendVM` in tree view. [\#619](https://github.com/vatesfr/xo-web/pull/619) ([pdonias](https://github.com/pdonias))
### Bug fixes
- User defined MAC address is not fetch in VM install [\#643](https://github.com/vatesfr/xo-web/issues/643)
- CoreOsCloudConfig is not shown with CoreOS [\#639](https://github.com/vatesfr/xo-web/issues/639)
- Plugin activation/deactivation in web UI seems broken [\#637](https://github.com/vatesfr/xo-web/issues/637)
- Issue when creating CloudConfig drive [\#636](https://github.com/vatesfr/xo-web/issues/636)
- CloudConfig hostname shouldn't have space [\#634](https://github.com/vatesfr/xo-web/issues/634)
- Cloned VIFs are not properly deleted on VM creation [\#632](https://github.com/vatesfr/xo-web/issues/632)
- Default PV args missing during VM creation [\#628](https://github.com/vatesfr/xo-web/issues/628)
- VM creation problems from custom templates [\#625](https://github.com/vatesfr/xo-web/issues/625)
- Emergency shutdown race condition [\#622](https://github.com/vatesfr/xo-web/issues/622)
- `vm.delete\(\)` should not delete VDIs attached to other VMs [\#621](https://github.com/vatesfr/xo-web/issues/621)
- VM creation error from template with a disk [\#581](https://github.com/vatesfr/xo-web/issues/581)
- Only delete VDI exports when VM backup is successful [\#644](https://github.com/vatesfr/xo-web/issues/644)
- Change the name of an imported VM during the import process [\#641](https://github.com/vatesfr/xo-web/issues/641)
- Creating a new VIF in view is partially broken [\#652](https://github.com/vatesfr/xo-web/issues/652)
- Grey out the "create button" during VM creation [\#654](https://github.com/vatesfr/xo-web/issues/654)
## **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

@@ -22,12 +22,14 @@ import deleteVmsState from './modules/delete-vms'
import genericModalState from './modules/generic-modal'
import hostState from './modules/host'
import listState from './modules/list'
import migrateVmState from './modules/migrate-vm'
import navbarState from './modules/navbar'
import newSrState from './modules/new-sr'
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'
@@ -57,12 +59,14 @@ export default angular.module('xoWebApp', [
genericModalState,
hostState,
listState,
migrateVmState,
navbarState,
newSrState,
newVmState,
poolState,
settingsState,
srState,
taskScheduler,
treeState,
updater,
vmState,
@@ -72,7 +76,7 @@ export default angular.module('xoWebApp', [
// Prevent Angular.js from mangling exception stack (interfere with
// source maps).
.factory('$exceptionHandler', () => function (exception) {
throw exception
console.log(exception && exception.stack || exception)
})
.config(function (

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,222 @@
import angular from 'angular'
import find from 'lodash.find'
import forEach from 'lodash.foreach'
import later from 'later'
import prettyCron from 'prettycron'
import uiBootstrap from 'angular-ui-bootstrap'
import uiRouter from 'angular-ui-router'
later.date.localTime()
import view from './view'
// ====================================================================
export default angular.module('backup.continuousReplication', [
uiRouter,
uiBootstrap
])
.config(function ($stateProvider) {
$stateProvider.state('backup.continuousReplication', {
url: '/continuous-replication/:id',
controller: 'ContinuousReplicationCtrl as ctrl',
template: view
})
})
.controller('ContinuousReplicationCtrl', function ($scope, $stateParams, $interval, xo, xoApi, notify, selectHighLevelFilter, filterFilter, bytesToSizeFilter) {
const JOBKEY = 'continuousReplication'
this.ready = false
this.running = {}
this.comesForEditing = $stateParams.id
this.scheduleApi = {}
this.formData = {}
const refreshSchedules = () => 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 = () => xo.job.getAll()
.then(jobs => {
const j = {}
forEach(jobs, job => {
j[job.id] = job
})
this.jobs = j
})
const refresh = () => refreshJobs().then(refreshSchedules)
const getReady = () => refresh().then(() => this.ready = true)
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 selectedSr = xoApi.get(job.paramsVector.items[0].values[0].sr)
const cronPattern = schedule.cron
this.resetData()
// const formData = this.formData
this.formData.selectedVms = selectedVms
this.formData.tag = tag
this.formData.selectedSr = selectedSr
this.formData.scheduleId = schedule.id
this.scheduleApi.setCron(cronPattern)
}
this.save = (id, vms, tag, sr, cron, enabled) => {
if (!vms.length) {
notify.warning({
title: 'No Vms selected',
message: 'Choose VMs to copy'
})
return
}
const _save = (id === undefined) ? saveNew(vms, tag, sr, cron, enabled) : save(id, vms, tag, sr, cron)
return _save
.then(() => {
notify.info({
title: 'Continuous Replication',
message: 'Job schedule successfuly saved'
})
this.resetData()
})
.finally(refresh)
}
const save = (id, vms, tag, sr, cron) => {
const schedule = this.schedules[id]
const job = this.jobs[schedule.job]
const values = []
forEach(vms, vm => {
values.push({vm: vm.id, tag, sr: sr.id})
})
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, tag, sr, cron, enabled) => {
const values = []
forEach(vms, vm => {
values.push({vm: vm.id, tag, sr: sr.id})
})
const job = {
type: 'call',
key: JOBKEY,
method: 'vm.deltaCopy',
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.inTargetPool = vm => vm.$poolId === (this.formData.selectedSr && this.formData.selectedSr.$poolId)
this.resetData = () => {
this.formData.allRunning = false
this.formData.allHalted = false
this.formData.selectedVms = []
this.formData.scheduleId = undefined
this.formData.tag = undefined
this.formData.selectedSr = undefined
this.formData.enabled = false
this.scheduleApi && this.scheduleApi.resetData && this.scheduleApi.resetData()
}
this.collectionLength = col => Object.keys(col).length
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,135 @@
.panel.panel-default
p.page-title
i.fa.fa-map-signs(style="color: #e25440;")
| Continuous Replication
form#ciform(ng-submit = 'ctrl.save(ctrl.formData.scheduleId, ctrl.formData.selectedVms, ctrl.formData.tag, ctrl.formData.selectedSr, ctrl.formData.cronPattern, ctrl.formData.enabled)')
.grid-sm
.panel.panel-default
.panel-heading.panel-title
i.fa.xo-icon-vm(style='color: #e25440;')
| VMs to copy
.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 Continuous Replication
.alert.alert-warning(ng-if = 'ctrl.formData.scheduleId') Modifying Continuous Replication ID {{ ctrl.formData.scheduleId }}
.form-group
label.control-label.col-md-2(for = 'tag') Tag
.col-md-10
input#tag.form-control(form = 'ciform', ng-model = 'ctrl.formData.tag', placeholder = 'VM copy 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#vmlist(form = 'ciform', ng-model = 'ctrl.formData.selectedVms', multiple, close-on-select = 'false', required)
ui-select-match(placeholder = 'Choose VMs to copy')
span(ng-class = '{"bg-danger": ctrl.inTargetPool($item)}')
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 = 'ciform', 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 = 'ciform', 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(ng-if = '(ctrl.formData.selectedVms | filter:ctrl.inTargetPool).length')
.col-md-offset-2.col-md-10
.alert.alert-warning
i.fa.fa-exclamation-triangle
|  At the moment, the selected VMs displayed in red are in the copy target pool.
.form-group
label.control-label.col-md-2(for = 'sr') To SR
.col-md-10
ui-select#sr(form = 'ciform', ng-model = 'ctrl.formData.selectedSr', required)
ui-select-match(placeholder = 'Choose destination SR')
i(class="xo-icon-sr")
| {{$select.selected.name_label}}
span(ng-if="$select.selected.$container")
| ({{($select.selected.$container | resolve).name_label}})
ui-select-choices(repeat = 'sr in ctrl.objects | selectHighLevel | filter:{type: "sr", content_type: "!iso"} | filter:$select.search | orderBy:["$container", "name_label"] track by sr.id')
div
i(class="xo-icon-sr")
| {{sr.name_label}} ({{sr.size - sr.physical_usage | bytesToSize }})
span(ng-if="sr.$container")
| ({{(sr.$container | resolve).name_label || ((sr.$container | resolve).master | resolve).name_label}})
.form-group(ng-if = '!ctrl.formData.scheduleId')
label.control-label.col-md-2(for = 'enabled')
input#enabled(form = 'ciform', ng-model = 'ctrl.formData.enabled', type = 'checkbox')
.help-block.col-md-8 Enable immediately 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 = 'ciform', 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.collectionLength(ctrl.schedules)') No scheduled jobs
table.table.table-hover(ng-if = 'ctrl.schedules && ctrl.collectionLength(ctrl.schedules)')
tr
th ID
th Tag
th.hidden-xs.hidden-sm VMs to Copy
th.hidden-xs To SR
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].vm | 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')
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 {{ (ctrl.jobs[schedule.job].paramsVector.items[0].values[0].sr | resolve).name_label }}
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

@@ -0,0 +1,259 @@
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 { parse } from 'xo-remote-parser'
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 => {
remote = parse(remote)
if (remote.type !== 'smb') {
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 immediately 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].vm | 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].remote].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

@@ -75,7 +75,7 @@ form#drform(ng-submit = 'ctrl.save(ctrl.formData.scheduleId, ctrl.formData.selec
.form-group(ng-if = '!ctrl.formData.scheduleId')
label.control-label.col-md-2(for = 'enabled')
input#enabled(form = 'drform', ng-model = 'ctrl.formData.enabled', type = 'checkbox')
.help-block.col-md-8 Enable immediatly after creation
.help-block.col-md-8 Enable immediately after creation
.panel.panel-default
.panel-heading.panel-title
i.fa.fa-clock-o
@@ -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,13 @@
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 continuousReplication from './continuous-replication'
import deltaBackup from './delta-backup'
import disasterRecovery from './disaster-recovery'
import management from './management'
import mount from './remote'
@@ -18,17 +15,19 @@ import restore from './restore'
import rollingSnapshot from './rolling-snapshot'
import view from './view'
import scheduler from './scheduler'
export default angular.module('backup', [
uiRouter,
backup,
continuousReplication,
deltaBackup,
disasterRecovery,
management,
mount,
restore,
rollingSnapshot
rollingSnapshot,
scheduler
])
.config(function ($stateProvider) {
$stateProvider.state('backup', {
@@ -49,302 +48,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'
@@ -19,17 +20,35 @@ export default angular.module('backup.management', [
template: view
})
})
.controller('ManagementCtrl', function ($scope, $state, $stateParams, $interval, xo, xoApi, notify, selectHighLevelFilter, filterFilter) {
.controller('ManagementCtrl', function (
$interval,
$scope,
$state,
$stateParams,
filterFilter,
modal,
notify,
selectHighLevelFilter,
xo,
xoApi
) {
this.running = {}
const mapJobKeyToState = {
'rollingSnapshot': 'rollingsnapshot',
'rollingBackup': 'backup',
'disasterRecovery': 'disasterrecovery'
continuousReplication: 'continuousReplication',
deltaBackup: 'deltaBackup',
disasterRecovery: 'disasterrecovery',
rollingBackup: 'backup',
rollingSnapshot: 'rollingsnapshot',
__none: 'index'
}
const mapJobKeyToJobDisplay = {
'rollingSnapshot': 'Rolling Snapshot',
'rollingBackup': 'Backup',
'disasterRecovery': 'Disaster Recovery'
continuousReplication: 'Continuous Replication',
deltaBackup: 'Delta Backup',
disasterRecovery: 'Disaster Recovery',
rollingBackup: 'Backup',
rollingSnapshot: 'Rolling Snapshot',
__none: '[unknown]'
}
this.currentLogPage = 1
@@ -37,7 +56,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 +64,12 @@ export default angular.module('backup.management', [
const getLogs = () => {
xo.logs.get('jobs').then(logs => {
const viewLogs = {}
const logsToClear = []
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) {
logsToClear.push(logKey)
viewLogs[logKey] = {
logKey,
jobId: data.jobId,
@@ -62,31 +81,33 @@ export default angular.module('backup.management', [
}
} else {
const runJobId = data.runJobId
const entry = viewLogs[runJobId]
if (!entry) {
return
}
logsToClear.push(logKey)
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'
entry.status = 'Finished'
} 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)
}
}
}
@@ -99,22 +120,30 @@ export default angular.module('backup.management', [
})
this.logs = viewLogs
this.logsToClear = logsToClear
})
}
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 = () => {
@@ -139,6 +168,14 @@ export default angular.module('backup.management', [
$interval.cancel(interval)
})
this.clearLogs = () => {
modal.confirm({
title: 'Clear logs',
message: 'Are you sure you want to delete all logs ?'
})
.then(() => xo.logs.delete('jobs', this.logsToClear))
}
this.enable = id => {
this.working[id] = true
return xo.scheduler.enable(id)
@@ -151,9 +188,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,30 +16,30 @@
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
i.fa.fa-file-text
| Logs
span.quick-edit(ng-if = 'ctrl.logs | isNotEmpty', tooltip = 'Remove all logs', xo-click = 'ctrl.clearLogs()')
i.fa.fa-trash-o.fa-fw
.panel-body
table.table.table-hover(ng-if = 'ctrl.logs')
thead
@@ -53,21 +53,22 @@
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' }}
td {{ log.duration | duration}}
td
span(ng-if = 'log.status === "Terminated"')
span(ng-if = 'log.status === "Finished"')
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" }}
span.label(ng-if = 'log.status !== "Finished"', 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 = '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,10 +1,9 @@
import angular from 'angular'
import filter from 'lodash.filter'
import map from 'lodash.map'
import trim from 'lodash.trim'
import size from 'lodash.size'
import uiBootstrap from 'angular-ui-bootstrap'
import uiRouter from 'angular-ui-router'
import size from 'lodash.size'
import {format, parse} from 'xo-remote-parser'
import view from './view'
@@ -26,7 +25,7 @@ export default angular.module('backup.remote', [
const refresh = () => {
return xo.remote.getAll()
.then(remotes => this.backUpRemotes = remotes)
.then(remotes => this.backUpRemotes = map(remotes, parse))
}
this.getReady = () => {
@@ -40,15 +39,7 @@ export default angular.module('backup.remote', [
$interval.cancel(interval)
})
this.sanitizePath = (...paths) => filter(map(paths, s => s && filter(map(s.split('/'), trim)).join('/'))).join('/')
this.prepareUrl = (type, host, path) => {
let url = type + ':/'
if (type === 'nfs') {
url += '/' + host + ':'
}
url += '/' + this.sanitizePath(path)
return url
}
this.prepareUrl = (type, host, path, username, password, domain) => format({type, host, path, username, password, domain})
const reset = () => {
this.path = this.host = this.name = undefined
@@ -56,7 +47,7 @@ export default angular.module('backup.remote', [
}
this.add = (name, url) => xo.remote.create(name, url).then(reset).then(refresh)
this.remove = id => xo.remote.delete(id).then(refresh)
this.enable = id => { console.log('GO !!!'); xo.remote.set(id, undefined, undefined, true).then(refresh) }
this.enable = id => xo.remote.set(id, undefined, undefined, true).then(refresh)
this.disable = id => xo.remote.set(id, undefined, undefined, false).then(refresh)
this.size = size

View File

@@ -14,6 +14,7 @@
th.text-info Local
th Name
th Path
th
th State
th Error
th
@@ -21,6 +22,7 @@
td
td {{ remote.name }}
td {{ remote.path }}
td
td
span(ng-if = 'remote.enabled')
span.text-success
@@ -37,6 +39,7 @@
th.text-info NFS
th Name
th Device
th
th State
th Error
th
@@ -44,6 +47,7 @@
td
td {{ remote.name }}
td {{ remote.host }}:{{ remote.share }}
td
td
span(ng-if = 'remote.enabled')
span.text-success
@@ -55,7 +59,36 @@
button.btn.btn-primary.pull-right(type = 'button', ng-click = 'ctrl.enable(remote.id)'): i.fa.fa-link
td: span.text-muted {{ remote.error }}
td: button.btn.btn-danger.pull-right(type = 'button', ng-click = 'ctrl.remove(remote.id)'): i.fa.fa-trash
form(ng-submit = 'ctrl.add(ctrl.name, ctrl.prepareUrl(ctrl.remoteType, ctrl.host, ctrl.path))')
tbody(ng-if = '(ctrl.backUpRemotes | filter:{type:"smb"}).length')
tr
th.text-info SMB
th Name
th Share
th Auth
th State
th Error
th
tr(ng-repeat = 'remote in ctrl.backUpRemotes | filter:{type:"smb"} | orderBy:["name"] track by remote.id')
td
td {{ remote.name }}
td
strong.text-info &bsol;&bsol;
| {{ remote.host }}
strong.text-info &bsol;
| {{ remote.path }}
td {{ remote.username }}@{{remote.domain}}
td
span(ng-if = 'remote.enabled')
span.text-success
| Accessible&nbsp;
i.fa.fa-check
button.btn.btn-warning.pull-right(type = 'button', ng-click = 'ctrl.disable(remote.id)'): i.fa.fa-chain-broken
span(ng-if = '!remote.enabled')
span.text-muted Unaccessible&nbsp;
button.btn.btn-primary.pull-right(type = 'button', ng-click = 'ctrl.enable(remote.id)'): i.fa.fa-link
td: span.text-muted {{ remote.error }}
td: button.btn.btn-danger.pull-right(type = 'button', ng-click = 'ctrl.remove(remote.id)'): i.fa.fa-trash
form(ng-submit = 'ctrl.add(ctrl.name, ctrl.prepareUrl(ctrl.remoteType, ctrl.host, ctrl.path, ctrl.username, ctrl.password, ctrl.domain))')
fieldset
legend New File System Remote
.form-inline
@@ -64,21 +97,47 @@
select.form-control(ng-model = 'ctrl.remoteType')
option(value = 'file') Local
option(value = 'nfs') NFS
option(value = 'smb') SMB
| &nbsp;
.form-group
label.sr-only Name
input.form-control(type = 'text', ng-model = 'ctrl.name', placeholder = 'Name', required)
| &nbsp;
br
.form-inline
.form-group(ng-if = 'ctrl.remoteType === "nfs"')
label.sr-only Host
input.form-control(type = 'text', ng-model = 'ctrl.host', placeholder = 'host', required)
strong &nbsp;:&nbsp;
.input-group
.input-group(ng-if = 'ctrl.remoteType !== "smb"')
span.input-group-addon /
label.sr-only Path&nbsp;
input.form-control(type = 'text', ng-model = 'ctrl.path', placeholder = 'path', required)
label.sr-only Path
input.form-control(type = 'text', ng-model = 'ctrl.path', placeholder = 'path/to/backup')
.form-group(ng-if = 'ctrl.remoteType === "smb"')
.input-group
span.input-group-addon &bsol;&bsol;
label.sr-only Share
input.form-control(type = 'text', ng-model = 'ctrl.host', placeholder = 'share', required)
.input-group
span.input-group-addon &bsol;
label.sr-only Path
input.form-control(type = 'text', ng-model = 'ctrl.path', placeholder != 'path&bsol;to&bsol;backup')
br
.form-inline(ng-if = 'ctrl.remoteType === "smb"')
.form-group
label.sr-only User Name
input.form-control(type = 'text', ng-model = 'ctrl.username', placeholder = 'username', required)
| &nbsp;
.form-group
button.btn.btn-primary.pull-right(type = 'submit', ng-disabled = '!ctrl.ready')
| Save&nbsp;
i.fa.fa-floppy-o
label.sr-only Password
input.form-control(type = 'password', ng-model = 'ctrl.password', placeholder = 'password', required)
| &nbsp;
.form-group
label.sr-only Domain
input.form-control(type = 'text', ng-model = 'ctrl.domain', placeholder = 'domain', required)
br
br
.form-group
button.btn.btn-primary(type = 'submit', ng-disabled = '!ctrl.ready')
| Save&nbsp;
i.fa.fa-floppy-o

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_(.*)_([^\/]+)\/([^_]+)_(.*)$/.exec(file)
if (arr) {
const [ , tag, uuid, date, name ] = arr
const value = {
path: file,
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.physical_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.physical_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

@@ -52,7 +52,7 @@ form#snapform(ng-submit = 'ctrl.save(ctrl.formData.scheduleId, ctrl.formData.sel
.form-group(ng-if = '!ctrl.formData.scheduleId')
label.control-label.col-md-2(for = 'enabled')
input#enabled(form = 'snapform', ng-model = 'ctrl.formData.enabled', type = 'checkbox')
.help-block.col-md-8 Enable immediatly after creation
.help-block.col-md-8 Enable immediately after creation
.panel.panel-default
.panel-heading.panel-title
i.fa.fa-clock-o
@@ -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
@@ -25,5 +29,9 @@
a(ui-sref = '.disasterrecovery')
i.fa.fa-fw.fa-medkit.fa-menu
span.menu-entry Disaster Recovery
li
a(ui-sref = '.continuousReplication')
i.fa.fa-fw.fa-map-signs.fa-menu
span.menu-entry Continuous Replication
.side-content(ui-view = '')

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,30 @@ 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()
$scope.shutdownHost = (id) ->
modal.confirm({
title: 'Shutdown host'
message: 'Are you sure you want to shutdown this host?'
}).then ->
xo.host.stop id
$scope.rebootHost = (id) ->
modal.confirm({
title: 'Reboot host'
message: 'Are you sure you want to reboot this host? It will be disabled then rebooted'
}).then ->
xo.host.restart id
$scope.startHost = (id) ->
xo.host.start id
clipboard = new Clipboard('.copy')
clipboard.on('error', (e) -> console.log('Clipboard', e))
# A module exports its name.
.name

View File

@@ -7,24 +7,37 @@
i.xo-icon-console.fa-stack-1x(class = 'xo-color-{{VM.power_state | lowercase}}')
| &nbsp;
a(
ng-if = 'VM.type === "VM"'
class = 'xo-color-{{VM.power_state | lowercase}}'
ui-sref = 'VMs_view({id: VM.id})'
) {{VM.name_label}}
a(
ng-if = 'VM.type === "VM-controller"'
class = 'xo-color-{{VM.power_state | lowercase}}'
ui-sref = 'hosts_view({id: VM.$container})'
) {{VM.name_label}}
.list-group
//- 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
.btn-group
.col-sm-2
.btn-group(ng-if = 'VM.type === "VM"')
button.btn.btn-default.inversed(
ng-if = "VM.power_state == ('Running' || 'Paused')"
tooltip = "Stop VM"
@@ -46,9 +59,32 @@
xo-click = "rebootVM(VM.id)"
)
i.fa.fa-refresh.fa-fw
.btn-group(ng-if = 'VM.type === "VM-controller"')
button.btn.btn-default.inversed(
ng-if = "VM.power_state == ('Running' || 'Paused')"
tooltip = "Shutdown Host"
type = "button"
xo-click = "shutdownHost(VM.$container)"
)
i.fa.fa-stop.fa-fw
button.btn.btn-default.inversed(
ng-if = "VM.power_state == ('Halted')"
tooltip = "Start Host"
type = "button"
xo-click = "startHost(VM.$container)"
)
i.fa.fa-play.fa-fw
button.btn.btn-default.inversed(
ng-if = "VM.power_state == ('Running' || 'Paused')"
tooltip = "Reboot Host"
type = "button"
xo-click = "rebootHost(VM.$container)"
)
i.fa.fa-refresh.fa-fw
//- Console
.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

@@ -1,29 +1,19 @@
import angular from 'angular'
import Bluebird from 'bluebird'
import uiRouter from 'angular-ui-router'
import filter from 'lodash.filter'
import find from 'lodash.find'
import forEach from 'lodash.foreach'
import sortBy from 'lodash.sortby'
import xoApi from 'xo-api'
import xoHorizon from'xo-horizon'
import xoServices from 'xo-services'
import xoWeekHeatmap from'xo-week-heatmap'
import view from './view'
export default angular.module('dashboard.health', [
uiRouter,
xoApi,
xoHorizon,
xoServices,
xoWeekHeatmap
xoApi
])
.config(function ($stateProvider) {
$stateProvider.state('dashboard.health', {
controller: 'Health as bigController',
controller: 'Health as ctrl',
data: {
requireAdmin: true
},
@@ -32,332 +22,68 @@ export default angular.module('dashboard.health', [
})
})
.filter('type', () => {
return function (objects, type) {
if (!type) {
return objects
}
return filter(objects, object => object.type === type)
}
})
.controller('Health', function () {})
.controller('HealthHeatmap', function (xoApi, xo, xoAggregate, notify, bytesToSizeFilter) {
this.charts = {
heatmap: null
}
this.objects = xoApi.all
.controller('Health', function (xo, xoApi, $scope, modal) {
this.currentVdiPage = 1
this.currentVmPage = 1
this.prepareTypeFilter = function (selection) {
const object = selection[0]
this.typeFilter = object && object.type || undefined
const vms = xoApi.getView('VM-snapshot').all
const vdis = xoApi.getView('VDI-snapshot').all
const srs = xoApi.getView('SR').all
$scope.$watchCollection(() => vdis, () => {
const orphanVdiSnapshots = filter(vdis, vdi => vdi && !vdi.$snapshot_of)
this.orphanVdiSnapshots = orphanVdiSnapshots
})
$scope.$watchCollection(() => vms, () => {
const orphanVmSnapshots = filter(vms, vm => vm && !vm.$snapshot_of)
this.orphanVmSnapshots = orphanVmSnapshots
})
$scope.$watchCollection(() => srs, () => {
const warningSrs = filter(srs, sr => sr.content_type !== 'iso' && (sr.physical_usage / sr.size) >= 0.8 && (sr.physical_usage / sr.size) < 0.9)
const dangerSrs = filter(srs, sr => sr.content_type !== 'iso' && (sr.physical_usage / sr.size) >= 0.9)
this.warningSrs = warningSrs
this.dangerSrs = dangerSrs
})
this.selectedVdiForDelete = {}
this.selectedVmForDelete = {}
this.deleteVdiSnapshot = function (id) {
modal.confirm({
title: 'VDI snapshot deletion',
message: 'Are you sure you want to delete this snapshot?'
}).then(() => xo.vdi.delete(id))
}
this.selectAll = function (type) {
this.selected = filter(this.objects, object =>
(object.type === type && object.power_state === 'Running'))
this.typeFilter = type
this.deleteVmSnapshot = function (id) {
modal.confirm({
title: 'VM snapshot deletion',
message: 'Are you sure you want to delete this snapshot? (including its disks)'
}).then(() => xo.vm.delete(id, true))
}
this.prepareMetrics = function (objects) {
this.chosen = objects && objects.slice()
this.metrics = undefined
this.selectedMetric = undefined
if (this.chosen && this.chosen.length) {
this.loadingMetrics = true
const statPromises = []
forEach(this.chosen, object => {
const apiType = (object.type === 'host' && 'host') || (object.type === 'VM' && 'vm') || undefined
if (!apiType) {
notify.error({
title: 'Unhandled object ' + (objects.name_label || ''),
message: 'There is no stats available for this type of objects'
})
object._ignored = true
} else {
delete object._ignored
statPromises.push(
xo[apiType].refreshStats(object.id, 'hours') // hours granularity (7 * 24 hours)
.then(result => {
if (result.stats === undefined) {
object._ignored = true
throw new Error('No stats')
}
return {object, result}
})
.catch(error => {
error.object = object
object._ignored = true
throw error
})
)
}
})
Bluebird.settle(statPromises)
.then(stats => {
const averageMetrics = {}
let averageObjectLayers = {}
let averageCPULayers = 0
forEach(stats, statePromiseInspection => { // One object...
if (statePromiseInspection.isRejected()) {
notify.warning({
title: 'Error fetching stats',
message: 'Metrics do not include ' + statePromiseInspection.reason().object.name_label
})
} else if (statePromiseInspection.isFulfilled()) {
const {object, result} = statePromiseInspection.value()
// Make date array
result.stats.date = []
let timestamp = result.endTimestamp
for (let i = result.stats.memory.length - 1; i >= 0; i--) {
result.stats.date.unshift(timestamp)
timestamp -= 3600
}
const averageCPU = averageMetrics['All CPUs'] && averageMetrics['All CPUs'].values || []
forEach(result.stats.cpus, (values, metricKey) => { // Every CPU metric of this object
metricKey = 'CPU ' + metricKey
averageObjectLayers[metricKey] !== undefined || (averageObjectLayers[metricKey] = 0)
averageObjectLayers[metricKey]++
averageCPULayers++
const mapValues = averageMetrics[metricKey] && averageMetrics[metricKey].values || [] // already fed or not
forEach(values, (value, key) => {
if (mapValues[key] === undefined) { // first value
mapValues.push({
value: +value,
date: +result.stats.date[key] * 1000
})
} else { // average with previous
mapValues[key].value = ((mapValues[key].value || 0) * (averageObjectLayers[metricKey] - 1) + (+value)) / averageObjectLayers[metricKey]
}
if (averageCPU[key] === undefined) { // first overall value
averageCPU.push({
value: +value,
date: +result.stats.date[key] * 1000
})
} else { // average with previous overall value
averageCPU[key].value = (averageCPU[key].value * (averageCPULayers - 1) + value) / averageCPULayers
}
})
averageMetrics[metricKey] = {
key: metricKey,
values: mapValues
}
})
averageMetrics['All CPUs'] = {
key: 'All CPUs',
values: averageCPU
}
forEach(result.stats.vifs, (vif, vifType) => {
const rw = (vifType === 'rx') ? 'out' : 'in'
forEach(vif, (values, metricKey) => {
metricKey = 'Network ' + metricKey + ' ' + rw
averageObjectLayers[metricKey] !== undefined || (averageObjectLayers[metricKey] = 0)
averageObjectLayers[metricKey]++
const mapValues = averageMetrics[metricKey] && averageMetrics[metricKey].values || [] // already fed or not
forEach(values, (value, key) => {
if (mapValues[key] === undefined) { // first value
mapValues.push({
value: +value,
date: +result.stats.date[key] * 1000
})
} else { // average with previous
mapValues[key].value = ((mapValues[key].value || 0) * (averageObjectLayers[metricKey] - 1) + (+value)) / averageObjectLayers[metricKey]
}
})
averageMetrics[metricKey] = {
key: metricKey,
values: mapValues,
filter: bytesToSizeFilter
}
})
})
forEach(result.stats.pifs, (pif, pifType) => {
const rw = (pifType === 'rx') ? 'out' : 'in'
forEach(pif, (values, metricKey) => {
metricKey = 'NIC ' + metricKey + ' ' + rw
averageObjectLayers[metricKey] !== undefined || (averageObjectLayers[metricKey] = 0)
averageObjectLayers[metricKey]++
const mapValues = averageMetrics[metricKey] && averageMetrics[metricKey].values || [] // already fed or not
forEach(values, (value, key) => {
if (mapValues[key] === undefined) { // first value
mapValues.push({
value: +value,
date: +result.stats.date[key] * 1000
})
} else { // average with previous
mapValues[key].value = ((mapValues[key].value || 0) * (averageObjectLayers[metricKey] - 1) + (+value)) / averageObjectLayers[metricKey]
}
})
averageMetrics[metricKey] = {
key: metricKey,
values: mapValues,
filter: bytesToSizeFilter
}
})
})
forEach(result.stats.xvds, (xvd, xvdType) => {
const rw = (xvdType === 'r') ? 'read' : 'write'
forEach(xvd, (values, metricKey) => {
metricKey = 'Disk ' + metricKey + ' ' + rw
averageObjectLayers[metricKey] !== undefined || (averageObjectLayers[metricKey] = 0)
averageObjectLayers[metricKey]++
const mapValues = averageMetrics[metricKey] && averageMetrics[metricKey].values || [] // already fed or not
forEach(values, (value, key) => {
if (mapValues[key] === undefined) { // first value
mapValues.push({
value: +value,
date: +result.stats.date[key] * 1000
})
} else { // average with previous
mapValues[key].value = ((mapValues[key].value || 0) * (averageObjectLayers[metricKey] - 1) + (+value)) / averageObjectLayers[metricKey]
}
})
averageMetrics[metricKey] = {
key: metricKey,
values: mapValues,
filter: bytesToSizeFilter
}
})
})
if (result.stats.load) {
const metricKey = 'Load average'
averageObjectLayers[metricKey] !== undefined || (averageObjectLayers[metricKey] = 0)
averageObjectLayers[metricKey]++
const mapValues = averageMetrics[metricKey] && averageMetrics[metricKey].values || [] // already fed or not
forEach(result.stats.load, (value, key) => {
if (mapValues[key] === undefined) { // first value
mapValues.push({
value: +value,
date: +result.stats.date[key] * 1000
})
} else { // average with previous
mapValues[key].value = ((mapValues[key].value || 0) * (averageObjectLayers[metricKey] - 1) + (+value)) / averageObjectLayers[metricKey]
}
})
averageMetrics[metricKey] = {
key: metricKey,
values: mapValues
}
}
if (result.stats.memoryUsed) {
const metricKey = 'RAM Used'
averageObjectLayers[metricKey] !== undefined || (averageObjectLayers[metricKey] = 0)
averageObjectLayers[metricKey]++
const mapValues = averageMetrics[metricKey] && averageMetrics[metricKey].values || [] // already fed or not
forEach(result.stats.memoryUsed, (value, key) => {
if (mapValues[key] === undefined) { // first value
mapValues.push({
value: +value * (object.type === 'host' ? 1024 : 1),
date: +result.stats.date[key] * 1000
})
} else { // average with previous
mapValues[key].value = ((mapValues[key].value || 0) * (averageObjectLayers[metricKey] - 1) + (+value)) / averageObjectLayers[metricKey]
}
})
averageMetrics[metricKey] = {
key: metricKey,
values: mapValues,
filter: bytesToSizeFilter
}
}
}
})
this.metrics = sortBy(averageMetrics, (_, key) => key)
this.loadingMetrics = false
})
}
}
})
.controller('HealthHorizons', function ($scope, xoApi, xoAggregate, xo, $timeout) {
let ctrl, stats
ctrl = this
ctrl.synchronizescale = true
ctrl.objects = xoApi.all
ctrl.chosen = []
this.prepareTypeFilter = function (selection) {
const object = selection[0]
ctrl.typeFilter = object && object.type || undefined
this.deleteSelectedVdis = function () {
return modal.confirm({
title: 'VDI snapshot deletion',
message: 'Are you sure you want to delete all selected VDI snapshots? This operation is irreversible.'
}).then(() => {
forEach(this.selectedVdiForDelete, (selected, id) => console.log(id))
forEach(this.selectedVdiForDelete, (selected, id) => selected && xo.vdi.delete(id))
this.selectedVdiForDelete = {}
})
}
this.selectAll = function (type) {
ctrl.selected = filter(ctrl.objects, object =>
(object.type === type && object.power_state === 'Running'))
ctrl.typeFilter = type
}
this.prepareMetrics = function (objects) {
ctrl.chosen = objects
ctrl.selectedMetric = null
ctrl.loadingMetrics = true
xoAggregate
.refreshStats(ctrl.chosen, 'hours')
.then(function (result) {
stats = result
ctrl.metrics = stats.keys
ctrl.stats = {}
// $timeout(refreshStats, 1000)
ctrl.loadingMetrics = false
})
.catch(function (e) {
console.log(' ERROR ', e)
})
}
this.toggleSynchronizeScale = function () {
ctrl.synchronizescale = !ctrl.synchronizescale
if (ctrl.selectedMetric) {
ctrl.prepareStat()
}
}
this.prepareStat = function () {
let min, max
max = 0
min = 0
ctrl.stats = {}
// compute a global extent => the chart will have the same scale
if (ctrl.synchronizescale) {
forEach(stats.details, function (stat, object_id) {
forEach(stat[ctrl.selectedMetric], function (val) {
if (!isNaN(val.value)) {
max = Math.max(val.value || 0, max)
}
})
})
ctrl.extents = [min, max]
} else {
ctrl.extents = null
}
forEach(stats.details, function (stat, object_id) {
const label = find(ctrl.chosen, {id: object_id})
ctrl.stats[label.name_label] = stat[ctrl.selectedMetric]
this.deleteSelectedVms = function () {
return modal.confirm({
title: 'VM snapshot deletion',
message: 'Are you sure you want to delete all selected VM snapshots? This operation is irreversible.'
}).then(() => {
forEach(this.selectedVmForDelete, (selected, id) => selected && xo.vm.delete(id, true))
this.selectedVmForDelete = {}
})
}
})
.name

View File

@@ -4,152 +4,85 @@
i.fa.fa-heartbeat
| Health
.grid-sm
.grid-cell
.panel.panel-default
.panel-heading.panel-title
i.fa.fa-fire
| Weekly Heatmap
.panel-body(ng-controller='HealthHeatmap as heatmap')
| {{heatmap.toto}}
form
.grid-sm
.grid-cell.grid--gutters
.container-fluid
.form-group
ui-select.form-control(ng-model = 'heatmap.selected', ng-change = 'heatmap.prepareTypeFilter(heatmap.selected)', multiple, close-on-select = 'false')
ui-select-match(placeholder = 'Choose an object')
i(class = 'xo-icon-{{ $item.type | lowercase }}')
| {{ $item.name_label }}
ui-select-choices(repeat = 'object in heatmap.objects | underStat | type:heatmap.typeFilter | filter:{ power_state: "Running" } | filter:$select.search | orderBy:["type", "name_label"] track by object.id')
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 }})
//- br
.btn-group(role = 'group')
button.btn.btn-default(ng-click = 'heatmap.selected = []', tooltip = 'Clear selection')
i.fa.fa-times
button.btn.btn-default(ng-click = 'heatmap.selectAll("VM")', tooltip = 'Choose all VMs')
i.xo-icon-vm
button.btn.btn-default(ng-click = 'heatmap.selectAll("host")', tooltip = 'Choose all hosts')
i.xo-icon-host
button.btn.btn-success(ng-click = 'heatmap.prepareMetrics(heatmap.selected)', tooltip = 'Load metrics')
i.fa.fa-check
| &nbsp;Select
.grid-cell.grid--gutters
.container-fluid
span(ng-if = 'heatmap.loadingMetrics')
| Loading metrics ...&nbsp;
i.fa.fa-circle-o-notch.fa-spin
.form-group(ng-if = 'heatmap.metrics')
ui-select(ng-model = 'heatmap.selectedMetric')
ui-select-match(placeholder = 'Choose a metric') {{ $select.selected.key }}
ui-select-choices(repeat = 'metric in heatmap.metrics | filter:$select.search | orderBy:["key"]') {{ metric.key }}
br
p.text-center(ng-if = 'heatmap.chosen.length')
span(ng-repeat = 'object in heatmap.chosen', ng-class = '{"text-danger": object._ignored}')
i(class = 'xo-icon-{{ object.type | lowercase }}')
| &nbsp;
span(ng-if = '!object._ignored') {{ object.name_label }}
del(ng-if = 'object._ignored') {{ object.name_label }}
| &nbsp;&ensp;
weekheatmap(ng-if = 'heatmap.selectedMetric', chart-data='heatmap.selectedMetric')
.panel.panel-default
.panel-heading.panel-title
i.fa.fa-hdd-o
| Orphaned VDI snapshots
.panel-body
.center(ng-if = 'ctrl.orphanVdiSnapshots | isEmpty') No orphaned snapshots found
table.table.table-hover(ng-if = 'ctrl.orphanVdiSnapshots | isNotEmpty')
tr
th Name
th Description
th Tags
th Size
th SR
span.pull-right: button.btn.btn-danger(xo-click = 'ctrl.deleteSelectedVdis()', tooltip = 'Delete selected snapshots'): i.fa.fa-trash
tr(ng-repeat = 'vdi in ctrl.orphanVdiSnapshots | filter:vdiSearch | orderBy:natural("name_label") | slice:(10*(ctrl.currentVdiPage-1)):(10*ctrl.currentVdiPage) track by vdi.id')
td.oneliner {{ vdi.name_label }}
td.oneliner {{ vdi.name_description }}
td: xo-tag(object = 'vdi')
td {{ vdi.size | bytesToSize}}
td.oneliner
a(xo-sref="SRs_view({id: (vdi.$SR | resolve).id})")
| {{(vdi.$SR | resolve).name_label}} ({{((vdi.$SR | resolve).$container | resolve).name_label}})
span.pull-right
.btn-group.quick-buttons
a(xo-click="ctrl.deleteVdiSnapshot(vdi.id)"): i.fa.fa-trash-o.fa-lg(tooltip="Destroy this snapshot")
input(type = 'checkbox', ng-model = 'ctrl.selectedVdiForDelete[vdi.id]', tooltip = 'select for deletion')
.form-inline
.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 = '(ctrl.orphanVdiSnapshots | filter:vdiSearch).length > 10 || ctrl.currentVdiPage > 1')
pagination(boundary-links="true", total-items="(ctrl.orphanVdiSnapshots | filter:vdiSearch).length", ng-model="ctrl.currentVdiPage", items-per-page="10", max-size="5", class="pagination-sm", previous-text="<", next-text=">", first-text="<<", last-text=">>")
.grid-sm
.grid-cell
.panel.panel-default
.panel-heading.panel-title
i.fa.fa-fire
| Weekly Charts
.panel-body(ng-controller="HealthHorizons as horizons")
form
.grid-sm
.grid-cell.grid--gutters
.container-fluid
.form-group
ui-select.form-control(
ng-model = 'horizons.selected',
ng-change = 'horizons.prepareTypeFilter(horizons.selected)',
multiple,
close-on-select = 'false'
)
ui-select-match(placeholder = 'Choose an object')
i(class = 'xo-icon-{{ $item.type | lowercase }}')
| {{ $item.name_label }}
ui-select-choices(repeat = 'object in horizons.objects | underStat | type:horizons.typeFilter | filter:{ power_state: "Running" } | filter:$select.search | orderBy:["type", "name_label"] track by object.id')
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 }})
//- br
.btn-group(role = 'group')
button.btn.btn-default(ng-click = 'horizons.selected = []', tooltip = 'Clear selection')
i.fa.fa-times
button.btn.btn-default(ng-click = 'horizons.selectAll("VM")', tooltip = 'Choose all VMs')
i.xo-icon-vm
button.btn.btn-default(ng-click = 'horizons.selectAll("host")', tooltip = 'Choose all hosts')
i.xo-icon-host
button.btn.btn-success(ng-click = 'horizons.prepareMetrics(horizons.selected)', tooltip = 'Load metrics')
i.fa.fa-check
| &nbsp;Select
.grid-cell.grid--gutters
.container-fluid
span(ng-if = 'horizons.loadingMetrics')
| Loading metrics ...&nbsp;
i.fa.fa-circle-o-notch.fa-spin
.form-group(ng-if = 'horizons.metrics && !horizons.loadingMetrics')
ui-select(ng-model = 'horizons.selectedMetric',ng-change='horizons.prepareStat()')
ui-select-match(placeholder = 'Choose a metric') {{ $select.selected }}
ui-select-choices(repeat = 'metric in horizons.metrics | filter:$select.search | orderBy:["key"]') {{ metric }}
br
button.btn.btn-primary.pull-right(
tooltip="Desynchronize Scale",
ng-click="horizons.toggleSynchronizeScale()"
ng-if='horizons.synchronizescale && horizons.selectedMetric'
)
i.fa.fa-balance-scale
button.btn.btn-default.pull-right(
tooltip="Synchronize Scale",
ng-click="horizons.toggleSynchronizeScale()"
ng-if='!horizons.synchronizescale && horizons.selectedMetric'
)
i.fa.fa-balance-scale
br
p.text-center(ng-if = 'horizons.chosen.length')
span(ng-repeat = 'object in horizons.chosen', ng-class = '{"text-danger": object._ignored}')
i(class = 'xo-icon-{{ object.type | lowercase }}')
| &nbsp;
span(ng-if = '!object._ignored') {{ object.name_label }}
del(ng-if = 'object._ignored') {{ object.name_label }}
| &nbsp;&ensp;
div(
ng-repeat='(label,stat) in horizons.stats'
ng-if='!horizons.loadingMetrics'
style='position:relative'
)
horizon(
ng-if='$first'
chart-data='stat'
show-axis='true'
axis-orientation='top'
selected='horizons.selectedDate'
extent='horizons.extents'
label='{{label}}'
)
horizon(
ng-if='$middle'
chart-data='stat'
selected='horizons.selectedDate'
extent='horizons.extents'
label='{{label}}'
)
horizon(
ng-if='$last && !$first'
chart-data='stat'
show-axis='true'
axis-orientation='bottom'
selected='horizons.selectedDate'
extent='horizons.extents'
label='{{label}}'
)
.panel.panel-default
.panel-heading.panel-title
i.fa.fa-camera
| Orphaned VM snapshots
.panel-body
.center(ng-if = 'ctrl.orphanVmSnapshots | isEmpty') No orphaned snapshots found
table.table.table-hover(ng-if = 'ctrl.orphanVmSnapshots | isNotEmpty')
tr
th Name
th Description
th OS
th Container
span.pull-right: button.btn.btn-danger(xo-click = 'ctrl.deleteSelectedVms()', tooltip = 'Delete selected snapshots'): i.fa.fa-trash
tr(ng-repeat = 'vm in ctrl.orphanVmSnapshots | orderBy:natural("name_label") | slice:(10*(ctrl.currentVmPage-1)):(10*ctrl.currentVmPage) track by vm.id')
td.oneliner
i.xo-icon-working(ng-if="vm.current_operations | isNotEmpty", tooltip="{{vm.power_state}} and {{(vm.current_operations | map)[0]}}")
i(class="xo-icon-{{vm.power_state | lowercase}}",ng-if="vm.current_operations | isEmpty", tooltip="{{vm.power_state}}")
| &nbsp;&nbsp;{{ vm.name_label }}
td.oneliner {{ vm.name_description }}
td.onliner {{ vm.os_version.name }}
td.oneliner {{ (vm.$container | resolve).name_label }}
span.pull-right
.btn-group.quick-buttons
a(xo-click="ctrl.deleteVmSnapshot(vm.id)"): i.fa.fa-trash-o.fa-lg(tooltip="Destroy this snapshot")
input(type = 'checkbox', ng-model = 'ctrl.selectedVmForDelete[vm.id]', tooltip = 'select for deletion')
.form-inline
.input-group
.input-group-addon: i.fa.fa-filter
input.form-control(type = 'text', ng-model = 'vmSearch', placeholder = 'Enter your search here')
.center(ng-if = '(ctrl.orphanVmSnapshots | filter:vmSearch).length > 10 || ctrl.currentVmPage > 1')
pagination(boundary-links="true", total-items="(ctrl.orphanVmSnapshots | filter:vmSearch).length", ng-model="ctrl.currentVmPage", items-per-page="10", max-size="5", class="pagination-sm", previous-text="<", next-text=">", first-text="<<", last-text=">>")
.grid-sm
.panel.panel-default
.panel-heading.panel-title
i.fa.fa-database
| SR Warnings
.panel-body
.center(ng-if = '(ctrl.warningSrs | isEmpty) && (ctrl.dangerSrs | isEmpty)') No warnings found
table.table.table-hover(ng-if = '(ctrl.warningSrs | isNotEmpty) || (ctrl.dangerSrs | isNotEmpty)')
tr
th SR
th Physical usage
tr(ng-repeat = 'sr in ctrl.warningSrs')
td: a(xo-sref="SRs_view({id: sr.id})") {{ sr.name_label }} ({{ (sr.$container | resolve).name_label }})
td: span.label.label-warning {{ [sr.physical_usage, sr.size] | percentage }}
tr(ng-repeat = 'sr in ctrl.dangerSrs')
td: a(xo-sref="SRs_view({id: sr.id})") {{ sr.name_label }} ({{ (sr.$container | resolve).name_label }})
td: span.label.label-danger {{ [sr.physical_usage, sr.size] | percentage }}

View File

@@ -4,6 +4,7 @@ import uiRouter from 'angular-ui-router'
import dataviz from './dataviz'
import filter from 'lodash.filter'
import health from './health'
import stats from './stats'
import overview from './overview'
import view from './view'
@@ -12,6 +13,7 @@ export default angular.module('dashboard', [
uiRouter,
dataviz,
health,
stats,
overview
])
.config(function ($stateProvider) {

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

@@ -98,6 +98,42 @@
td {{SR.size | bytesToSize}}
td
.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}}")
.progress-bar(
role="progressbar",
aria-valuemin="0",
aria-valuenow="{{SR.physical_usage}}",
aria-valuemax="{{SR.size}}",
style="width: {{[SR.physical_usage, SR.size] | percentage}}",
tooltip="Physical usage: {{[SR.physical_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

@@ -0,0 +1,363 @@
import angular from 'angular'
import Bluebird from 'bluebird'
import uiRouter from 'angular-ui-router'
import filter from 'lodash.filter'
import find from 'lodash.find'
import forEach from 'lodash.foreach'
import sortBy from 'lodash.sortby'
import xoApi from 'xo-api'
import xoHorizon from'xo-horizon'
import xoServices from 'xo-services'
import xoWeekHeatmap from'xo-week-heatmap'
import view from './view'
export default angular.module('dashboard.stats', [
uiRouter,
xoApi,
xoHorizon,
xoServices,
xoWeekHeatmap
])
.config(function ($stateProvider) {
$stateProvider.state('dashboard.stats', {
controller: 'stats as bigController',
data: {
requireAdmin: true
},
url: '/stats',
template: view
})
})
.filter('type', () => {
return function (objects, type) {
if (!type) {
return objects
}
return filter(objects, object => object.type === type)
}
})
.controller('stats', function () {})
.controller('statsHeatmap', function (xoApi, xo, xoAggregate, notify, bytesToSizeFilter) {
this.charts = {
heatmap: null
}
this.objects = xoApi.all
this.prepareTypeFilter = function (selection) {
const object = selection[0]
this.typeFilter = object && object.type || undefined
}
this.selectAll = function (type) {
this.selected = filter(this.objects, object =>
(object.type === type && object.power_state === 'Running'))
this.typeFilter = type
}
this.prepareMetrics = function (objects) {
this.chosen = objects && objects.slice()
this.metrics = undefined
this.selectedMetric = undefined
if (this.chosen && this.chosen.length) {
this.loadingMetrics = true
const statPromises = []
forEach(this.chosen, object => {
const apiType = (object.type === 'host' && 'host') || (object.type === 'VM' && 'vm') || undefined
if (!apiType) {
notify.error({
title: 'Unhandled object ' + (objects.name_label || ''),
message: 'There is no stats available for this type of objects'
})
object._ignored = true
} else {
delete object._ignored
statPromises.push(
xo[apiType].refreshStats(object.id, 'hours') // hours granularity (7 * 24 hours)
.then(result => {
if (result.stats === undefined) {
object._ignored = true
throw new Error('No stats')
}
return {object, result}
})
.catch(error => {
error.object = object
object._ignored = true
throw error
})
)
}
})
Bluebird.settle(statPromises)
.then(stats => {
const averageMetrics = {}
let averageObjectLayers = {}
let averageCPULayers = 0
forEach(stats, statePromiseInspection => { // One object...
if (statePromiseInspection.isRejected()) {
notify.warning({
title: 'Error fetching stats',
message: 'Metrics do not include ' + statePromiseInspection.reason().object.name_label
})
} else if (statePromiseInspection.isFulfilled()) {
const {object, result} = statePromiseInspection.value()
// Make date array
result.stats.date = []
let timestamp = result.endTimestamp
for (let i = result.stats.memory.length - 1; i >= 0; i--) {
result.stats.date.unshift(timestamp)
timestamp -= 3600
}
const averageCPU = averageMetrics['All CPUs'] && averageMetrics['All CPUs'].values || []
forEach(result.stats.cpus, (values, metricKey) => { // Every CPU metric of this object
metricKey = 'CPU ' + metricKey
averageObjectLayers[metricKey] !== undefined || (averageObjectLayers[metricKey] = 0)
averageObjectLayers[metricKey]++
averageCPULayers++
const mapValues = averageMetrics[metricKey] && averageMetrics[metricKey].values || [] // already fed or not
forEach(values, (value, key) => {
if (mapValues[key] === undefined) { // first value
mapValues.push({
value: +value,
date: +result.stats.date[key] * 1000
})
} else { // average with previous
mapValues[key].value = ((mapValues[key].value || 0) * (averageObjectLayers[metricKey] - 1) + (+value)) / averageObjectLayers[metricKey]
}
if (averageCPU[key] === undefined) { // first overall value
averageCPU.push({
value: +value,
date: +result.stats.date[key] * 1000
})
} else { // average with previous overall value
averageCPU[key].value = (averageCPU[key].value * (averageCPULayers - 1) + value) / averageCPULayers
}
})
averageMetrics[metricKey] = {
key: metricKey,
values: mapValues
}
})
averageMetrics['All CPUs'] = {
key: 'All CPUs',
values: averageCPU
}
forEach(result.stats.vifs, (vif, vifType) => {
const rw = (vifType === 'rx') ? 'out' : 'in'
forEach(vif, (values, metricKey) => {
metricKey = 'Network ' + metricKey + ' ' + rw
averageObjectLayers[metricKey] !== undefined || (averageObjectLayers[metricKey] = 0)
averageObjectLayers[metricKey]++
const mapValues = averageMetrics[metricKey] && averageMetrics[metricKey].values || [] // already fed or not
forEach(values, (value, key) => {
if (mapValues[key] === undefined) { // first value
mapValues.push({
value: +value,
date: +result.stats.date[key] * 1000
})
} else { // average with previous
mapValues[key].value = ((mapValues[key].value || 0) * (averageObjectLayers[metricKey] - 1) + (+value)) / averageObjectLayers[metricKey]
}
})
averageMetrics[metricKey] = {
key: metricKey,
values: mapValues,
filter: bytesToSizeFilter
}
})
})
forEach(result.stats.pifs, (pif, pifType) => {
const rw = (pifType === 'rx') ? 'out' : 'in'
forEach(pif, (values, metricKey) => {
metricKey = 'NIC ' + metricKey + ' ' + rw
averageObjectLayers[metricKey] !== undefined || (averageObjectLayers[metricKey] = 0)
averageObjectLayers[metricKey]++
const mapValues = averageMetrics[metricKey] && averageMetrics[metricKey].values || [] // already fed or not
forEach(values, (value, key) => {
if (mapValues[key] === undefined) { // first value
mapValues.push({
value: +value,
date: +result.stats.date[key] * 1000
})
} else { // average with previous
mapValues[key].value = ((mapValues[key].value || 0) * (averageObjectLayers[metricKey] - 1) + (+value)) / averageObjectLayers[metricKey]
}
})
averageMetrics[metricKey] = {
key: metricKey,
values: mapValues,
filter: bytesToSizeFilter
}
})
})
forEach(result.stats.xvds, (xvd, xvdType) => {
const rw = (xvdType === 'r') ? 'read' : 'write'
forEach(xvd, (values, metricKey) => {
metricKey = 'Disk ' + metricKey + ' ' + rw
averageObjectLayers[metricKey] !== undefined || (averageObjectLayers[metricKey] = 0)
averageObjectLayers[metricKey]++
const mapValues = averageMetrics[metricKey] && averageMetrics[metricKey].values || [] // already fed or not
forEach(values, (value, key) => {
if (mapValues[key] === undefined) { // first value
mapValues.push({
value: +value,
date: +result.stats.date[key] * 1000
})
} else { // average with previous
mapValues[key].value = ((mapValues[key].value || 0) * (averageObjectLayers[metricKey] - 1) + (+value)) / averageObjectLayers[metricKey]
}
})
averageMetrics[metricKey] = {
key: metricKey,
values: mapValues,
filter: bytesToSizeFilter
}
})
})
if (result.stats.load) {
const metricKey = 'Load average'
averageObjectLayers[metricKey] !== undefined || (averageObjectLayers[metricKey] = 0)
averageObjectLayers[metricKey]++
const mapValues = averageMetrics[metricKey] && averageMetrics[metricKey].values || [] // already fed or not
forEach(result.stats.load, (value, key) => {
if (mapValues[key] === undefined) { // first value
mapValues.push({
value: +value,
date: +result.stats.date[key] * 1000
})
} else { // average with previous
mapValues[key].value = ((mapValues[key].value || 0) * (averageObjectLayers[metricKey] - 1) + (+value)) / averageObjectLayers[metricKey]
}
})
averageMetrics[metricKey] = {
key: metricKey,
values: mapValues
}
}
if (result.stats.memoryUsed) {
const metricKey = 'RAM Used'
averageObjectLayers[metricKey] !== undefined || (averageObjectLayers[metricKey] = 0)
averageObjectLayers[metricKey]++
const mapValues = averageMetrics[metricKey] && averageMetrics[metricKey].values || [] // already fed or not
forEach(result.stats.memoryUsed, (value, key) => {
if (mapValues[key] === undefined) { // first value
mapValues.push({
value: +value * (object.type === 'host' ? 1024 : 1),
date: +result.stats.date[key] * 1000
})
} else { // average with previous
mapValues[key].value = ((mapValues[key].value || 0) * (averageObjectLayers[metricKey] - 1) + (+value)) / averageObjectLayers[metricKey]
}
})
averageMetrics[metricKey] = {
key: metricKey,
values: mapValues,
filter: bytesToSizeFilter
}
}
}
})
this.metrics = sortBy(averageMetrics, (_, key) => key)
this.loadingMetrics = false
})
}
}
})
.controller('statsHorizons', function ($scope, xoApi, xoAggregate, xo, $timeout) {
let ctrl, stats
ctrl = this
ctrl.synchronizescale = true
ctrl.objects = xoApi.all
ctrl.chosen = []
this.prepareTypeFilter = function (selection) {
const object = selection[0]
ctrl.typeFilter = object && object.type || undefined
}
this.selectAll = function (type) {
ctrl.selected = filter(ctrl.objects, object =>
(object.type === type && object.power_state === 'Running'))
ctrl.typeFilter = type
}
this.prepareMetrics = function (objects) {
ctrl.chosen = objects
ctrl.selectedMetric = null
ctrl.loadingMetrics = true
xoAggregate
.refreshStats(ctrl.chosen, 'hours')
.then(function (result) {
stats = result
ctrl.metrics = stats.keys
ctrl.stats = {}
// $timeout(refreshStats, 1000)
ctrl.loadingMetrics = false
})
.catch(function (e) {
console.log(' ERROR ', e)
})
}
this.toggleSynchronizeScale = function () {
ctrl.synchronizescale = !ctrl.synchronizescale
if (ctrl.selectedMetric) {
ctrl.prepareStat()
}
}
this.prepareStat = function () {
let min, max
max = 0
min = 0
ctrl.stats = {}
// compute a global extent => the chart will have the same scale
if (ctrl.synchronizescale) {
forEach(stats.details, function (stat, object_id) {
forEach(stat[ctrl.selectedMetric], function (val) {
if (!isNaN(val.value)) {
max = Math.max(val.value || 0, max)
}
})
})
ctrl.extents = [min, max]
} else {
ctrl.extents = null
}
forEach(stats.details, function (stat, object_id) {
const label = find(ctrl.chosen, {id: object_id})
ctrl.stats[label.name_label] = stat[ctrl.selectedMetric]
})
}
})
.name

View File

@@ -0,0 +1,155 @@
.grid-sm
.panel.panel-default
p.page-title
i.fa.fa-bar-chart
| Stats
.grid-sm
.grid-cell
.panel.panel-default
.panel-heading.panel-title
i.fa.fa-fire
| Weekly Heatmap
.panel-body(ng-controller='statsHeatmap as heatmap')
| {{heatmap.toto}}
form
.grid-sm
.grid-cell.grid--gutters
.container-fluid
.form-group
ui-select.form-control(ng-model = 'heatmap.selected', ng-change = 'heatmap.prepareTypeFilter(heatmap.selected)', multiple, close-on-select = 'false')
ui-select-match(placeholder = 'Choose an object')
i(class = 'xo-icon-{{ $item.type | lowercase }}')
| {{ $item.name_label }}
ui-select-choices(repeat = 'object in heatmap.objects | underStat | type:heatmap.typeFilter | filter:{ power_state: "Running" } | filter:$select.search | orderBy:["type", "name_label"] track by object.id')
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 }})
//- br
.btn-group(role = 'group')
button.btn.btn-default(ng-click = 'heatmap.selected = []', tooltip = 'Clear selection')
i.fa.fa-times
button.btn.btn-default(ng-click = 'heatmap.selectAll("VM")', tooltip = 'Choose all VMs')
i.xo-icon-vm
button.btn.btn-default(ng-click = 'heatmap.selectAll("host")', tooltip = 'Choose all hosts')
i.xo-icon-host
button.btn.btn-success(ng-click = 'heatmap.prepareMetrics(heatmap.selected)', tooltip = 'Load metrics')
i.fa.fa-check
| &nbsp;Select
.grid-cell.grid--gutters
.container-fluid
span(ng-if = 'heatmap.loadingMetrics')
| Loading metrics ...&nbsp;
i.fa.fa-circle-o-notch.fa-spin
.form-group(ng-if = 'heatmap.metrics')
ui-select(ng-model = 'heatmap.selectedMetric')
ui-select-match(placeholder = 'Choose a metric') {{ $select.selected.key }}
ui-select-choices(repeat = 'metric in heatmap.metrics | filter:$select.search | orderBy:["key"]') {{ metric.key }}
br
p.text-center(ng-if = 'heatmap.chosen.length')
span(ng-repeat = 'object in heatmap.chosen', ng-class = '{"text-danger": object._ignored}')
i(class = 'xo-icon-{{ object.type | lowercase }}')
| &nbsp;
span(ng-if = '!object._ignored') {{ object.name_label }}
del(ng-if = 'object._ignored') {{ object.name_label }}
| &nbsp;&ensp;
weekheatmap(ng-if = 'heatmap.selectedMetric', chart-data='heatmap.selectedMetric')
.grid-sm
.grid-cell
.panel.panel-default
.panel-heading.panel-title
i.fa.fa-fire
| Weekly Charts
.panel-body(ng-controller="statsHorizons as horizons")
form
.grid-sm
.grid-cell.grid--gutters
.container-fluid
.form-group
ui-select.form-control(
ng-model = 'horizons.selected',
ng-change = 'horizons.prepareTypeFilter(horizons.selected)',
multiple,
close-on-select = 'false'
)
ui-select-match(placeholder = 'Choose an object')
i(class = 'xo-icon-{{ $item.type | lowercase }}')
| {{ $item.name_label }}
ui-select-choices(repeat = 'object in horizons.objects | underStat | type:horizons.typeFilter | filter:{ power_state: "Running" } | filter:$select.search | orderBy:["type", "name_label"] track by object.id')
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 }})
//- br
.btn-group(role = 'group')
button.btn.btn-default(ng-click = 'horizons.selected = []', tooltip = 'Clear selection')
i.fa.fa-times
button.btn.btn-default(ng-click = 'horizons.selectAll("VM")', tooltip = 'Choose all VMs')
i.xo-icon-vm
button.btn.btn-default(ng-click = 'horizons.selectAll("host")', tooltip = 'Choose all hosts')
i.xo-icon-host
button.btn.btn-success(ng-click = 'horizons.prepareMetrics(horizons.selected)', tooltip = 'Load metrics')
i.fa.fa-check
| &nbsp;Select
.grid-cell.grid--gutters
.container-fluid
span(ng-if = 'horizons.loadingMetrics')
| Loading metrics ...&nbsp;
i.fa.fa-circle-o-notch.fa-spin
.form-group(ng-if = 'horizons.metrics && !horizons.loadingMetrics')
ui-select(ng-model = 'horizons.selectedMetric',ng-change='horizons.prepareStat()')
ui-select-match(placeholder = 'Choose a metric') {{ $select.selected }}
ui-select-choices(repeat = 'metric in horizons.metrics | filter:$select.search | orderBy:["key"]') {{ metric }}
br
button.btn.btn-primary.pull-right(
tooltip="Desynchronize Scale",
ng-click="horizons.toggleSynchronizeScale()"
ng-if='horizons.synchronizescale && horizons.selectedMetric'
)
i.fa.fa-balance-scale
button.btn.btn-default.pull-right(
tooltip="Synchronize Scale",
ng-click="horizons.toggleSynchronizeScale()"
ng-if='!horizons.synchronizescale && horizons.selectedMetric'
)
i.fa.fa-balance-scale
br
p.text-center(ng-if = 'horizons.chosen.length')
span(ng-repeat = 'object in horizons.chosen', ng-class = '{"text-danger": object._ignored}')
i(class = 'xo-icon-{{ object.type | lowercase }}')
| &nbsp;
span(ng-if = '!object._ignored') {{ object.name_label }}
del(ng-if = 'object._ignored') {{ object.name_label }}
| &nbsp;&ensp;
div(
ng-repeat='(label,stat) in horizons.stats'
ng-if='!horizons.loadingMetrics'
style='position:relative'
)
horizon(
ng-if='$first'
chart-data='stat'
show-axis='true'
axis-orientation='top'
selected='horizons.selectedDate'
extent='horizons.extents'
label='{{label}}'
)
horizon(
ng-if='$middle'
chart-data='stat'
selected='horizons.selectedDate'
extent='horizons.extents'
label='{{label}}'
)
horizon(
ng-if='$last && !$first'
chart-data='stat'
show-axis='true'
axis-orientation='bottom'
selected='horizons.selectedDate'
extent='horizons.extents'
label='{{label}}'
)

View File

@@ -9,6 +9,10 @@
a(ui-sref = '.dataviz({chart:""})')
i.fa.fa-fw.fa-pie-chart.fa-menu
span.menu-entry Dataviz
li
a(ui-sref = '.stats')
i.fa.fa-fw.fa-bar-chart.fa-menu
span.menu-entry Stats
li
a(ui-sref = '.health')
i.fa.fa-fw.fa-heartbeat.fa-menu

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"
@@ -286,7 +293,7 @@
th Name
th Format
th Size
th Physical/Allocated usage
th Physical usage
th Type
th Status
//- TODO: display PBD status for each SR of this host (connected or not)
@@ -298,21 +305,20 @@
td {{SR.size | bytesToSize}}
td
.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}}")
.progress-bar(role="progressbar", aria-valuemin="0", aria-valuenow="{{SR.physical_usage}}", aria-valuemax="{{SR.size}}", style="width: {{[SR.physical_usage, SR.size] | percentage}}", tooltip="Physical usage: {{[SR.physical_usage, SR.size] | percentage}}")
td
span.label.label-primary Shared
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
@@ -323,21 +329,20 @@
td {{SR.size | bytesToSize}}
td
.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}}")
.progress-bar(role="progressbar", aria-valuemin="0", aria-valuenow="{{SR.physical_usage}}", aria-valuemax="{{SR.size}}", style="width: {{[SR.physical_usage, SR.size] | percentage}}", tooltip="Physical usage: {{[SR.physical_usage, SR.size] | percentage}}")
td
span.label.label-info Local
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 +360,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 +373,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 +444,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 +456,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 +467,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 +480,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 +499,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 +511,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
@@ -129,7 +178,7 @@
.grid-cell.flat-cell.flat-cell-description
i {{SR.name_description}}
.grid-cell.flat-cell
| Usage: {{[SR.usage, SR.size] | percentage}} ({{SR.usage | bytesToSize}}/{{SR.size | bytesToSize}})
span(ng-if="SR.content_type !== 'disk'") Usage: {{[SR.physical_usage, SR.size] | percentage}} ({{SR.physical_usage | bytesToSize}}/{{SR.size | bytesToSize}})
.grid-cell.flat-cell
| Type: {{SR.SR_type}}
.grid-cell.flat-cell(ng-init="container = (SR.$container | resolve)")

View File

@@ -0,0 +1,75 @@
import angular from 'angular'
import forEach from 'lodash.foreach'
import uiBootstrap from 'angular-ui-bootstrap'
import xoServices from 'xo-services'
import view from './view'
// ===================================================================
export default angular.module('xoWebApp.migrateVm', [
uiBootstrap,
xoServices
])
.controller('MigrateVmCtrl', function (
$scope,
$modalInstance,
xoApi,
VDIs,
srsOnTargetPool,
srsOnTargetHost,
VIFs,
networks,
defaults,
intraPoolMigration
) {
$scope.VDIs = VDIs
$scope.SRs = srsOnTargetPool.concat(srsOnTargetHost)
$scope.VIFs = VIFs
$scope.networks = networks
$scope.intraPoolMigration = intraPoolMigration
$scope.selected = {}
$scope.selected.migrationNetwork = defaults.network
$scope.selected.vdi = {}
forEach($scope.VDIs, (vdi) => {
$scope.selected.vdi[vdi.id] = defaults.sr
})
if (!intraPoolMigration) {
$scope.selected.vif = {}
forEach($scope.VIFs, (vif) => {
$scope.selected.vif[vif.id] = defaults.network
})
}
$scope.migrate = function () {
$modalInstance.close($scope.selected)
}
})
.service('migrateVmModal', function ($modal, xo, xoApi) {
return function (state, id, hostId, VDIs, srsOnTargetPool, srsOnTargetHost, VIFs, networks, defaults, intraPoolMigration) {
return $modal.open({
controller: 'MigrateVmCtrl',
template: view,
resolve: {
VDIs: () => VDIs,
srsOnTargetPool: () => srsOnTargetPool,
srsOnTargetHost: () => srsOnTargetHost,
VIFs: () => VIFs,
networks: () => networks,
defaults: () => defaults,
intraPoolMigration: () => intraPoolMigration
}
}).result.then(function (selected) {
let isAdmin = xoApi.user && (xoApi.user.permission === 'admin')
state.go(isAdmin ? 'tree' : 'list')
return xo.vm.migrate(id, hostId, selected.vdi, intraPoolMigration ? undefined : selected.vif, selected.migrationNetwork)
})
}
})
// A module exports its name.
.name

View File

@@ -0,0 +1,50 @@
form(ng-submit="migrate()")
.modal-header
h3 VM migration
.modal-body
.form-inline
label Choose a network to migrate the VM:&nbsp
select.form-control(
ng-options="network.id as network.name_label for network in networks"
ng-model="selected.migrationNetwork"
)
p &nbsp
p
strong For each VDI, choose an SR:
table.table
tr
th.col-sm-5 Name
th.col-sm-7 SRs
tbody
tr(ng-repeat="vdi in VDIs")
td {{ vdi.name_label }}
td
table.table
tbody
tr
select.form-control(
ng-options="sr.id as sr.name_label for sr in SRs"
ng-model="selected.vdi[vdi.id]"
)
p(ng-if="!intraPoolMigration")
strong For each VIF, choose a network:
table.table(ng-if="!intraPoolMigration")
tr
th.col-sm-5 Device
th.col-sm-7 Networks
tbody
tr(ng-repeat="vif in VIFs")
td VIF \#{{ vif.device }} ({{vif.MAC}})
td
table.table
tbody
tr
select.form-control(
ng-options="network.id as network.name_label for network in networks"
ng-model="selected.vif[vif.id]"
)
.modal-footer
button.btn.btn-primary(type="submit")
| Migrate
button.btn.btn-warning(type="button", ng-click="$dismiss()")
| Cancel

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

@@ -2,6 +2,7 @@ angular = require 'angular'
cloneDeep = require 'lodash.clonedeep'
filter = require 'lodash.filter'
forEach = require 'lodash.foreach'
trim = require 'lodash.trim'
#=====================================================================
@@ -11,7 +12,7 @@ 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
@@ -19,8 +20,53 @@ module.exports = angular.module 'xoWebApp.newVm', [
bytesToSizeFilter, sizeToBytesFilter
notify
) ->
{get} = xoApi
$scope.multipleVmsActive = false
$scope.vmsNames = ['VM1', 'VM2']
$scope.numberOfVms = 2
$scope.newNumberOfVms = 2
$scope.checkNumberOfVms = ->
if $scope.newNumberOfVms && Number.isInteger($scope.newNumberOfVms)
$scope.newNumberOfVms = $scope.numberOfVms = Math.min(100,Math.max(2,$scope.newNumberOfVms))
else
$scope.newNumberOfVms = $scope.numberOfVms = 2
$scope.refreshNames = ->
$scope.defaultName = 'VM'
$scope.defaultName = $scope.name_label if $scope.name_label
forEach($scope.vmsNames, (name, index) ->
$scope.vmsNames[index] = $scope.defaultName + (index+1)
)
$scope.toggleBootAfterCreate = ->
$scope.bootAfterCreate = false if $scope.multipleVmsActive
$scope.configDriveActive = false
existingDisks = {}
$scope.saveChange = (position, propertyName, value) ->
if not existingDisks[position]?
existingDisks[position] = {}
existingDisks[position][propertyName] = value
$scope.updateVdiSize = (position) ->
$scope.saveChange(position, 'size', bytesToSizeFilter(sizeToBytesFilter($scope.existingDiskSizeValues[position] + ' ' + $scope.existingDiskSizeUnits[position])))
$scope.initExistingValues = (template) ->
$scope.name_label = template.name_label
sizes = {}
$scope.existingDiskSizeValues = {}
$scope.existingDiskSizeUnits = {}
forEach xoApi.get(template.$VBDs), (VBD) ->
sizes[VBD.position] = bytesToSizeFilter xoApi.get(VBD.VDI).size
$scope.existingDiskSizeValues[VBD.position] = parseInt(sizes[VBD.position].split(' ')[0], 10)
$scope.existingDiskSizeUnits[VBD.position] = sizes[VBD.position].split(' ')[1]
$scope.VIFs.length = 0
if template.VIFs.length
forEach xoApi.get(template.VIFs), (VIF) ->
network = xoApi.get(VIF.$network)
$scope.addVIF(network)
return
else $scope.addVIF()
{get} = xoApi
removeItems = do ->
splice = Array::splice.call.bind Array::splice
(array, index, n) -> splice array, index, n ? 1
@@ -103,29 +149,35 @@ 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 = ''
$scope.installation_cdrom = ''
$scope.installation_method = ''
$scope.installation_network = ''
$scope.memory = ''
$scope.name_description = ''
$scope.memoryValue = null
$scope.units = ['MiB', 'GiB', 'TiB']
$scope.memoryUnit = $scope.units[0]
$scope.name_description = 'Created by XO'
$scope.name_label = ''
$scope.template = ''
$scope.firstSR = ''
$scope.VDIs = []
$scope.VIFs = []
$scope.isDiskTemplate = false
$scope.cloudConfigSshKey = ''
$scope.bootAfterCreate = true
$scope.updateMemoryUnit = (memoryUnit) ->
$scope.memoryUnit = memoryUnit
$scope.addVIF = do ->
id = 0
->
(network = '') ->
$scope.VIFs.push {
id: id++
network: ''
network
}
$scope.addVIF()
$scope.removeVIF = (index) -> removeItems $scope.VIFs, index
@@ -143,6 +195,8 @@ module.exports = angular.module 'xoWebApp.newVm', [
id: VDI_id++
bootable: false
size: ''
sizeValue: null
sizeUnit: $scope.units[0]
SR: default_SR
type: 'system'
}
@@ -150,7 +204,11 @@ 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 = ''
# Fetch the PV args
$scope.pv_args = template.PV_args
{install_methods} = template.template_info
availableMethods = $scope.availableMethods = Object.create null
for method in install_methods
@@ -160,45 +218,84 @@ module.exports = angular.module 'xoWebApp.newVm', [
else
delete $scope.installation_method
delete $scope.installation_method
delete $scope.installation_network
# if the template already have a configured install repository
installRepository = template.template_info.install_repository
if installRepository
if installRepository is 'cdrom'
$scope.installation_method = 'cdrom'
else
$scope.installation_network = template.template_info.install_repository
$scope.installation_method = 'network'
VDIs = $scope.VDIs = cloneDeep template.template_info.disks
# if the template has no config disk
# nor it's Other install media (specific case)
# then do NOT display disk and network panel
if VDIs.length is 0 and template.name_label isnt 'Other install media'
$scope.isDiskTemplate = true
$scope.VIFs.length = 0
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
VDI.sizeValue = if VDI.size then parseInt(VDI.size.split(' ')[0], 10) else null
VDI.sizeUnit = VDI.size.split(' ')[1]
# 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 = ->
$scope.createVMs = ->
if !$scope.multipleVmsActive
$scope.createVM($scope.name_label)
return
forEach($scope.vmsNames, (name) ->
$scope.createVM(name)
)
# Send the client on the tree view
$state.go 'tree'
xenDefaultWeight = 256
$scope.weightMap = {
'Quarter (1/4)': xenDefaultWeight / 4,
'Half (1/2)': xenDefaultWeight / 2,
'Normal': xenDefaultWeight,
'Double (x2)': xenDefaultWeight * 2
}
$scope.createVM = (name_label) ->
{
CPUs
cpuWeight
pv_args
installation_cdrom
installation_method
installation_network
memory
memoryValue
memoryUnit
name_description
name_label
template
VDIs
VIFs
} = $scope
forEach VDIs, (vdi) ->
vdi.size = bytesToSizeFilter(sizeToBytesFilter(vdi.sizeValue + ' ' + vdi.sizeUnit))
# 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.
@@ -207,10 +304,13 @@ module.exports = angular.module 'xoWebApp.newVm', [
# Removes the dummy identifier used for AngularJS.
delete VIF.id
# Removes the MAC address if empty.
if 'MAC' of VIF
VIF.MAC = VIF.MAC.trim()
delete VIF.MAC unless VIF.MAC
# xo-server expects a network id, not the whole object
VIF.network = VIF.network.id
# Removes the mac address if empty.
if 'mac' of VIF
VIF.mac = trim(VIF.mac)
delete VIF.mac unless VIF.mac
if installation_method is 'cdrom'
@@ -232,7 +332,6 @@ module.exports = angular.module 'xoWebApp.newVm', [
}
else
installation = undefined
data = {
installation
pv_args
@@ -240,8 +339,8 @@ module.exports = angular.module 'xoWebApp.newVm', [
template: template.id
VDIs
VIFs
existingDisks
}
# TODO:
# - disable the form during creation
# - indicate the progress of the operation
@@ -249,35 +348,67 @@ module.exports = angular.module 'xoWebApp.newVm', [
title: 'VM creation'
message: 'VM creation started'
}
$scope.creatingVM = true
id = null
xoApi.call('vm.create', data)
.then (id_) ->
id = id_
xoApi.call('vm.create', data).then (id) ->
# If nothing to sets, just stops.
return id unless CPUs or name_description or memory
return unless CPUs or name_description or memoryValue
data = {
id
}
data.CPUs = +CPUs if CPUs
if cpuWeight
data.cpuWeight = cpuWeight
if name_description
data.name_description = name_description
if pv_args
data.pv_args = pv_args
if memory
memory = sizeToBytesFilter memory
if memoryValue
# FIXME: handles invalid entries.
data.memory = memory
xoApi.call('vm.set', data).then -> id
.then (id) ->
$state.go 'VMs_view', { id }
data.memoryValue = memoryValue + ' ' + memoryUnit
return xoApi.call('vm.set', data)
.then () ->
# If a CloudConfig drive needs to be created
if $scope.coreOsCloudConfig
# Use the CoreOS specific Cloud Config creation
return xo.vm.createCloudInitConfigDrive(id, $scope.firstSR, $scope.coreOsCloudConfig, true).then ->
return 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
hostname = name_label
# Remove leading and trailing spaces.
.replace(/^\s+|\s+$/g, '')
# Replace spaces with '-'.
.replace(/\s+/g, '-')
$scope.cloudContent = '#cloud-config\nhostname: ' + hostname + '\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
return xo.vm.createCloudInitConfigDrive(id, $scope.firstSR, $scope.cloudContent).then ->
# Boot directly on disk
return xo.vm.setBootOrder({vm: id, order: 'c'})
.then () ->
if $scope.bootAfterCreate
xo.vm.start id
if !$scope.multipleVmsActive
# Send the client on the VM view
$state.go 'VMs_view', { id }
.catch (error) ->
notify.error {
title: 'VM creation'
message: 'The creation failed'
}
$scope.creatingVM = false
console.log error

View File

@@ -2,13 +2,13 @@
.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()")
form.form-horizontal(ng-submit="createVMs()")
.grid
.panel.panel-default
.panel-heading.panel-title
@@ -18,11 +18,11 @@ 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 = 'initExistingValues(template)')
.form-group
label.col-sm-3.control-label Name
.col-sm-9
input.form-control(type="text", placeholder="Name of your new VM", required="", ng-model="name_label")
input.form-control(type="text", placeholder="Name of your new VM", ng-required="!multipleVmsActive", ng-model="name_label")
.form-group
label.col-sm-3.control-label Description
.col-sm-9
@@ -36,26 +36,42 @@ form.form-horizontal(ng-submit="createVM()")
label.col-sm-3.control-label vCPUs
.col-sm-9
input.form-control(type="text", placeholder="{{template.CPUs.number}}", ng-model="CPUs")
.form-group
label.col-sm-3.control-label CPU Weight
.col-sm-9
select.form-control(ng-model = "cpuWeight", ng-options='value as key for (key, value) in weightMap track by value')
option(value = '') default
.form-group
label.col-sm-3.control-label RAM
.col-sm-9
input.form-control(type="text", placeholder="{{template.memory.size | bytesToSize}}", ng-model="memory")
.grid(ng-if="isDiskTemplate")
.panel.panel-default
.input-group
input.form-control(type='number' min="0" placeholder="{{ template.memory.size | bytesConvert:memoryUnit:'iB' }}" ng-model="memoryValue")
.input-group-btn
span.pull-right.dropdown(dropdown)
button.btn.btn-default.dropdown-toggle(type='button' dropdown-toggle)
| {{ memoryUnit }}
span.caret
ul.dropdown-menu(role="menu")
li(ng-repeat="memoryUnit in units")
a(ng-click="updateMemoryUnit(memoryUnit)") {{ memoryUnit }}
.grid
//- Cloud Config Panel, only for templates with existing disks
.panel.panel-default(ng-if="isDiskTemplate")
.panel-heading.panel-title
i.fa.fa-info-circle
| Template info
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
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}}
.grid(ng-if="!isDiskTemplate")
//- Install panel
.panel.panel-default
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)
//- Install panel, only if an installation method is needed
.panel.panel-default(ng-if="!isDiskTemplate")
.panel-heading.panel-title
i.fa.fa-download
| Install settings
@@ -70,19 +86,17 @@ form.form-horizontal(ng-submit="createVM()")
name = 'installation_method'
ng-model = '$parent.installation_method'
value = 'cdrom'
required
)
select.form-control.disabled(
ng-disabled="'cdrom' !== installation_method"
ng-model="$parent.installation_cdrom"
required
)
option(value = '') Please select
optgroup(ng-repeat="SR in ISO_SRs | orderBy:natural('name_label') track by SR.id", ng-if="SR.VDIs.length", label="{{SR.name_label}}")
option(ng-repeat="VDI in SR.VDIs | resolve | orderBy:natural('name_label') track by VDI.id", ng-value="VDI.id")
| {{VDI.name_label}}
.form-group(
ng-show = 'availableMethods.http || availableMethods.ftp || availableMethods.nfs'
ng-show = '(availableMethods.http || availableMethods.ftp || availableMethods.nfs)'
)
label.col-sm-3.control-label Network
.col-sm-9
@@ -93,7 +107,6 @@ form.form-horizontal(ng-submit="createVM()")
name = 'installation_method'
ng-model = '$parent.installation_method'
value = 'network'
required
)
input.form-control(type="text", ng-disabled="'network' !== installation_method", placeholder="e.g: http://ftp.debian.org/debian", ng-model="$parent.installation_network")
.form-group(ng-show = 'template.virtualizationMode === "hvm"')
@@ -104,22 +117,12 @@ form.form-horizontal(ng-submit="createVM()")
name = 'installation_method'
ng-model = '$parent.installation_method'
value = 'pxe'
required
)
.form-group(ng-show="template.PV_args")
.form-group(ng-show='template.virtualizationMode === "pv"')
label.col-sm-3.control-label PV Args
.col-sm-9
input.form-control(type="text", placeholder="{{template.PV_args}}", ng-model="$parent.pv_args")
//- <div class="form-group"> FIXME
//- <label class="col-sm-3 control-label">Home server</label>
//- <div class="col-sm-9">
//- <select class="form-control">
//- <option>Default (auto)</option>
//- </select>
//- </div>
//- </div>
//- Interface panel
.panel.panel-default
.panel-heading.panel-title
@@ -134,10 +137,10 @@ form.form-horizontal(ng-submit="createVM()")
//- Buttons
tr(ng-repeat="VIF in VIFs track by VIF.id")
td
input.form-control(type="text", ng-model="VIF.MAC", ng-pattern="/^\s*[0-9a-f]{2}(:[0-9a-f]{2}){5}\s*$/i", placeholder="00:00:00:00:00")
input.form-control(type="text", ng-model="VIF.mac", ng-pattern="/^\s*[0-9a-f]{2}(:[0-9a-f]{2}){5}\s*$/i", placeholder="Auto-generated if empty")
td
select.form-control(
ng-options = 'network.id as network.name_label for network in networks | orderBy:natural("name_label") track by network.id'
ng-options = 'network as network.name_label for network in networks | orderBy:natural("name_label") track by network.id'
ng-model = 'VIF.network'
required
)
@@ -154,8 +157,56 @@ form.form-horizontal(ng-submit="createVM()")
i.fa.fa-plus
| Add interface
//- end of misc and interface panel
//- Cloud config panel
.grid(ng-if = 'coreOsCloudConfig')
.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.coreOsCloudConfig', name='coreOsCloudConfig')
| {{coreOsCloudConfig}}
//- Multiple VMs panel
.grid
.panel.panel-default
.panel-heading.panel-title
i.fa.fa-clone
| Multiple VMs
span.pull-right
label(style = 'cursor: pointer;')
input.hidden(type = 'checkbox', ng-model = 'multipleVmsActive', ng-click='refreshNames()', ng-change="toggleBootAfterCreate()")
i.fa(ng-class = '{"fa-toggle-on": multipleVmsActive, "text-success": multipleVmsActive, "fa-toggle-off": !multipleVmsActive}', style = 'font-size: 1.5em;')
.panel-body(ng-if="multipleVmsActive")
.form-group
label.col-md-offset-4.col-sm-2.control-label
i.fa.fa-refresh(ng-click = "$parent.refreshNames()" tooltip="Set VMs to default names")
| Number of VMs
.col-sm-2
.input-group(style="width:10em")
input.form-control(type="number" ng-model="$parent.newNumberOfVms")
span.input-group-btn
button.btn.btn-default(type="button" ng-click="checkNumberOfVms()")
i.fa.fa-arrow-right
.col-sm-6(ng-repeat="offset in [0, 1]")
.form-group(
ng-repeat = "n in [].constructor($parent.numberOfVms).slice(0, ($parent.numberOfVms+1)/2) track by $index"
ng-if = "2*$index+offset < $parent.numberOfVms"
)
label.col-sm-2.control-label VM \#{{ 2*$index+1+offset }}
.col-sm-10
input.form-control(
type = "text"
required
placeholder = "Name of new VM \#{{ (2*$index+1+offset) }}"
ng-model = "$parent.vmsNames[2*$index+offset]"
ng-init = "$parent.vmsNames[2*$index+offset] = $parent.defaultName + (2*$index+1+offset)"
)
//- Disk panel
.grid(ng-if="!isDiskTemplate")
.grid
.panel.panel-default
.panel-heading.panel-title
i.xo-icon-disk
@@ -170,13 +221,50 @@ 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.physical_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-group
input.form-control(
type='number'
min="0"
placeholder="Size of this virtual disk"
ng-model="existingDiskSizeValues[VBD.position]"
ng-readonly='!configDriveActive'
ng-change = 'updateVdiSize(VBD.position)'
)
.input-group-btn
span.pull-right.dropdown(dropdown)
button.btn.btn-default.dropdown-toggle(type='button' dropdown-toggle ng-disabled='!configDriveActive')
| {{ existingDiskSizeUnits[VBD.position] }}
span.caret
ul.dropdown-menu(role="menu")
li(ng-repeat="unit in $parent.units")
a(ng-click="existingDiskSizeUnits[VBD.position] = unit; updateVdiSize(VBD.position)") {{ unit }}
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'))")
select.form-control(ng-model="VDI.SR", ng-options="SR.id as (SR.name_label + ' (' + (SR.size - SR.physical_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-group
input.form-control(type='number' min="0" placeholder="Size of this virtual disk" ng-model="VDI.sizeValue")
.input-group-btn
span.pull-right.dropdown(dropdown)
button.btn.btn-default.dropdown-toggle(type='button' dropdown-toggle)
| {{ VDI.sizeUnit }}
span.caret
ul.dropdown-menu(role="menu")
li(ng-repeat="unit in units")
a(ng-click="VDI.sizeUnit = unit") {{ unit }}
td
input.form-control(type="text", placeholder="Name of this virtual disk", ng-model="VDI.name_label")
td
@@ -206,7 +294,9 @@ form.form-horizontal(ng-submit="createVM()")
.panel-body
.grid
.grid-cell
p.center.big {{name_label}}
p.center.big
span(ng-if="!multipleVmsActive") {{name_label}}
span(ng-if="multipleVmsActive") {{numberOfVms}} new VMs
| &nbsp;
span.small(ng-if="template.name_label") ({{template.name_label}})
.grid
@@ -218,7 +308,9 @@ form.form-horizontal(ng-submit="createVM()")
.grid-cell
//- p.stat-name RAM
p.center.big(tooltip="RAM")
| {{(memory) || (template.memory.size | bytesToSize)}}&nbsp;
span(ng-if="memory") {{memory}} {{memoryUnit}}
span(ng-if="!memory") {{template.memory.size | bytesToSize}}
| &nbsp;
i.xo-icon-memory
.grid-cell
//- p.stat-name Disks
@@ -230,7 +322,17 @@ 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
label
input(type='checkbox', ng-model = 'bootAfterCreate')
span(ng-if='!multipleVmsActive') Boot VM after creation
span(ng-if='multipleVmsActive') Boot {{numberOfVms}} VMs after creation
p.center
button.btn.btn-lg.btn-primary(type="submit", ng-disabled = 'creatingVM')
i.fa.fa-play(ng-if = '!creatingVM')
i.fa.fa-circle-o-notch.fa-spin(ng-if = 'creatingVM')
| Create VM

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

@@ -99,7 +99,14 @@
td.oneliner {{host.name_description}}
td
.progress-condensed
.progress-bar(role="progressbar", aria-valuemin="0", aria-valuenow="{{host.memory.usage}}", aria-valuemax="{{host.memory.size}}", style="width: {{[host.memory.usage, host.memory.size] | percentage}}")
.progress-bar(
role="progressbar",
aria-valuemin="0",
aria-valuenow="{{host.memory.usage}}",
aria-valuemax="{{host.memory.size}}",
style="width: {{[host.memory.usage, host.memory.size] | percentage}}",
tooltip="RAM: {{host.memory.usage | bytesToSize}}/{{host.memory.size | bytesToSize}} ({{[host.memory.usage, host.memory.size] | percentage}})"
)
//- Shared SR panel
.grid-sm
@@ -114,18 +121,24 @@
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}}
td
.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}}")
.progress-bar(role="progressbar", aria-valuemin="0", aria-valuenow="{{SR.physical_usage}}", aria-valuemax="{{SR.size}}", style="width: {{[SR.physical_usage, SR.size] | percentage}}", tooltip="Physical usage: {{[SR.physical_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

@@ -4,6 +4,7 @@ import forEach from 'lodash.foreach'
import marked from 'marked'
import trim from 'lodash.trim'
import uiRouter from 'angular-ui-router'
import remove from 'lodash.remove'
import xoApi from 'xo-api'
import xoServices from 'xo-services'
@@ -26,12 +27,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 +45,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,18 +86,32 @@ 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 => {
forEach(plugins, plugin => {
plugin._loaded = plugin.loaded
plugin._autoload = plugin.autoload
if (!plugin.configuration) {
plugin.configuration = {}
const preparePluginForView = plugin => {
plugin._loaded = plugin.loaded
plugin._autoload = plugin.autoload
if (!plugin.configuration) {
plugin.configuration = {}
}
loadDefaults(plugin.configurationSchema, plugin.configuration)
}
const refreshPlugin = id => {
xo.plugin.get()
.then(plugins => {
const plugin = find(plugins, plugin => plugin.id === id)
if (plugin) {
preparePluginForView(plugin)
remove(this.plugins, plugin => plugin.id === id)
this.plugins.push(plugin)
}
loadDefaults(plugin.configurationSchema, plugin.configuration)
})
}
const refreshPlugins = () => xo.plugin.get().then(plugins => {
forEach(plugins, preparePluginForView)
this.plugins = plugins
})
refreshPlugins()
@@ -100,21 +120,52 @@ export default angular.module('settings.plugins', [
this.disabled[id] = true
return xo.plugin[method](...args)
.finally(() => {
return refreshPlugins()
.then(() => this.disabled[id] = false)
refreshPlugin(id)
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) {
@@ -152,12 +203,16 @@ export default angular.module('settings.plugins', [
})
.controller('MultiString', function ($scope, xo, xoApi) {
if (this.model === undefined || this.model === null) {
this.model = []
}
if (!Array.isArray(this.model)) {
throw new Error('multiString directive model must be an array')
const checkModel = () => {
if (this.model === undefined || this.model === null) {
this.model = []
}
if (!Array.isArray(this.model)) {
throw new Error('multiString directive model must be an array')
}
}
checkModel()
$scope.$watch(() => this.model, checkModel)
this.add = (string) => {
string = trim(string)
@@ -172,7 +227,7 @@ export default angular.module('settings.plugins', [
}
})
.directive('objectInput', () => {
.directive('confObjectInput', () => {
return {
restrict: 'E',
template: objectInputView,
@@ -181,12 +236,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

@@ -36,17 +36,41 @@ export default angular.module('xoWebApp.sr', [
})
}
})
.controller('SrCtrl', function ($scope, $stateParams, $state, $q, notify, xoApi, xo, modal, $window, bytesToSizeFilter) {
.controller('SrCtrl', function ($scope, $stateParams, $state, $q, notify, xoApi, xo, modal, $window, bytesToSizeFilter, sizeToBytesFilter) {
$window.bytesToSize = bytesToSizeFilter // FIXME dirty workaround to custom a Chart.js tooltip template
$scope.units = ['MiB', 'GiB', 'TiB']
$scope.currentLogPage = 1
$scope.currentVDIPage = 1
let {get} = xoApi
$scope.$watch(() => xoApi.get($stateParams.id), function (SR) {
const VDIs = []
if (SR) {
forEach(SR.VDIs, vdi => {
vdi = xoApi.get(vdi)
if (vdi) {
const size = bytesToSizeFilter(vdi.size)
VDIs.push({...vdi, size, sizeValue: size.split(' ')[0], sizeUnit: size.split(' ')[1]})
}
})
}
$scope.SR = SR
$scope.VDIs = VDIs
})
$scope.selectedForDelete = {}
$scope.deleteSelectedVdis = function () {
return modal.confirm({
title: 'VDI deletion',
message: 'Are you sure you want to delete all selected VDIs? This operation is irreversible.'
}).then(function () {
forEach($scope.selectedForDelete, (selected, id) => selected && xo.vdi.delete(id))
$scope.selectedForDelete = {}
})
}
$scope.saveSR = function ($data) {
let {SR} = $scope
let {name_label, name_description} = $data
@@ -176,6 +200,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 +210,52 @@ 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))
attributes.size = bytesToSizeFilter(sizeToBytesFilter(attributes.sizeValue + ' ' + attributes.sizeUnit))
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
attributes.size = bytesToSizeFilter(sizeToBytesFilter(attributes.sizeValue + ' ' + attributes.sizeUnit))
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
@@ -52,19 +54,12 @@
| Stats
.panel-body
.row
.col-sm-6.col-lg-4
p.stat-name Physical Alloc:
.col-sm-6
p.stat-name Physical usage:
canvas.stat-simple(id="doughnut", class="chart chart-doughnut", data="[(SR.physical_usage), (SR.size - SR.physical_usage)]", labels="['Used', 'Free']", options='{tooltipTemplate: "<%if (label){%><%=label%>: <%}%><%= bytesToSize(value) %>"}')
.col-sm-6.col-lg-4
p.stat-name Virtual Alloc:
canvas.stat-simple(id="doughnut", class="chart chart-doughnut", data="[(SR.usage), (SR.size - SR.usage)]", labels="['Used', 'Free']", options='{tooltipTemplate: "<%if (label){%><%=label%>: <%}%><%= bytesToSize(value) %>"}')
.col-sm-4.visible-lg
.col-sm-6
p.stat-name VDIs:
p.center.big-stat {{SR.VDIs.length}}
.row.hidden-lg
.col-sm-12
br
p.stat-name {{SR.VDIs.length}} VDIs
//- Action panel
.grid
.panel.panel-default
@@ -96,39 +91,63 @@
| VDI Map
.panel-body
.progress
.progress-bar.progress-bar-vm(ng-if="((VDI.size/SR.size)*100) > 0.5", ng-repeat="VDI in SR.VDIs | resolve | orderBy:natural('name_label') track by VDI.id", role="progressbar", aria-valuemin="0", aria-valuenow="{{VDI.size}}", aria-valuemax="{{SR.size}}", style="width: {{[VDI.size, SR.size] | percentage}}", tooltip="{{VDI.name_label}} ({{[VDI.size, SR.size] | percentage}})")
.progress-bar.progress-bar-vm(
ng-if="((VDI.usage/SR.size)*100) > 0.5",
ng-repeat="VDI in SR.VDIs | resolve | orderBy:natural('name_label') track by VDI.id",
role="progressbar",
aria-valuemin="0",
aria-valuenow="{{VDI.usage}}",
aria-valuemax="{{SR.size}}",
style="width: {{[VDI.usage, SR.size] | percentage}}",
tooltip="{{VDI.name_label}} ({{VDI.usage | bytesToSize}}) {{[VDI.usage, SR.size] | percentage}}"
)
//- display the name only if it fits in its progress bar
span(ng-if="VDI.name_label.length < ((VDI.size/SR.size)*100)") {{VDI.name_label}}
span(ng-if="VDI.name_label.length < ((VDI.usage/SR.size)*100)") {{VDI.name_label}}
ul.list-inline.text-center
li Total: {{SR.size | bytesToSize}}
li Currently used: {{SR.usage | bytesToSize}}
li Available: {{SR.size-SR.usage | bytesToSize}}
li Currently used: {{SR.physical_usage | bytesToSize}}
li Available: {{SR.size-SR.physical_usage | bytesToSize}}
//- TODO: VDIs.
.grid
form(name = "disksForm" editable-form = '' onbeforesave = 'saveDisks($data)').panel.panel-default
.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
table.table.table-hover
tr
th Name
th Description
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)")
th.col-sm-2 Name
th.col-sm-2 Description
th.col-sm-1 Tags
th.col-sm-1 Size
th.col-sm-1(ng-show="disksForm.$visible")
th.col-sm-2
| Virtual Machine:&nbsp;
span.pull-right: button.btn.btn-danger(type = 'button', xo-click = 'deleteSelectedVdis()', tooltip = 'Delete selected disks'): i.fa.fa-trash
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"
e-name = '{{VDI.id}}/name_label'
)
| {{VDI.name_label}} &nbsp;
span.label.label-info(ng-if="VDI.$snapshot_of") snapshot
span(ng-if="VDI.type === 'VDI-snapshot'")
span.label.label-info(ng-if="VDI.$snapshot_of") snapshot
span.label.label-warning(ng-if="!VDI.$snapshot_of") orphaned snapshot
td.oneliner
span(
editable-text="VDI.name_description"
@@ -138,22 +157,32 @@
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.sizeValue"
e-name = '{{VDI.id}}/sizeValue'
)
| {{VDI.sizeValue}} {{VDI.sizeUnit}}
td(ng-show="disksForm.$visible")
span(
editable-select="VDI.sizeUnit"
e-ng-options="unit for unit in units"
e-name="{{VDI.id}}/sizeUnit"
)
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])")
i.fa.fa-unlink.fa-lg(tooltip="Disconnect this disk")
a(ng-if="!(VDI.$VBDs[0] | resolve).attached", xo-click="deleteVDI(VDI.id)")
i.fa.fa-trash-o.fa-lg(tooltip="Destroy this disk")
span.pull-right
.btn-group.quick-buttons
a(ng-if="(VDI.$VBDs[0] | resolve).attached", xo-click="disconnectVBD(VDI.$VBDs[0])")
i.fa.fa-unlink.fa-lg(tooltip="Disconnect this disk")
a(ng-if="!(VDI.$VBDs[0] | resolve).attached", xo-click="deleteVDI(VDI.id)")
i.fa.fa-trash-o.fa-lg(tooltip="Destroy this disk")
input(ng-if = '!(VDI.$VBDs[0] | resolve).attached', type = 'checkbox', ng-model = 'selectedForDelete[VDI.id]', tooltip = 'select for deletion')
//- TODO: Ability to create new VDIs.
.form-inline
.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 +218,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',
'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,193 @@
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 (
$interval,
$scope,
$state,
$stateParams,
filterFilter,
modal,
notify,
selectHighLevelFilter,
xo,
xoApi
) {
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 = {}
const logsToClear = []
forEach(logs, (log, logKey) => {
const data = log.data
const [time] = logKey.split(':')
if (data.event === 'job.start' && data.key === JOB_KEY) {
logsToClear.push(logKey)
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
}
logsToClear.push(logKey)
if (data.event === 'job.end') {
if (data.error) {
entry.error = data.error
}
entry.end = time
entry.duration = time - entry.start
entry.status = 'Finished'
} 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
this.logsToClear = logsToClear
})
}
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.clearLogs = () => {
modal.confirm({
title: 'Clear logs',
message: 'Are you sure you want to delete all logs ?'
})
.then(() => xo.logs.delete('jobs', this.logsToClear))
}
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,87 @@
.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
span.quick-edit(ng-if = 'ctrl.logs | isNotEmpty', tooltip = 'Remove all logs', xo-click = 'ctrl.clearLogs()')
i.fa.fa-trash-o.fa-fw
.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 === "Finished"')
span.label(ng-class = '{"label-success": (!log.error && !log.hasErrors), "label-danger": (log.error || log.hasErrors)}') {{ log.status }}
span.label(ng-if = 'log.status !== "Finished"', 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

@@ -164,7 +164,7 @@ module.exports = angular.module 'xoWebApp.tree', [
$scope.force_stopVM = (id) -> xo.vm.stop id, true
$scope.rebootVM = xo.vm.restart
$scope.force_rebootVM = (id) -> xo.vm.restart id, true
$scope.suspendVM = (id) -> xo.vm.suspend id, true
$scope.suspendVM = (id) -> xo.vm.suspend id
$scope.resumeVM = (id) -> xo.vm.resume id, true
$scope.migrateVM = (id, hostId) -> xo.vm.migrate id, hostId

View File

@@ -205,8 +205,8 @@ div(style="margin-top: 57px; visibility: hidden; height: 0") .
i.xo-icon-sr
| {{SR.name_label}}
td.col-md-6.right.no-border
.progress.progress-small(tooltip="Disk: {{[SR.usage, SR.size] | percentage}} allocated")
.progress-bar(role="progressbar", aria-valuenow="{{100*SR.usage/SR.size}}", aria-valuemin="0", aria-valuemax="100", style="width: {{[SR.usage, SR.size] | percentage}}")
.progress.progress-small(tooltip="Disk: {{SR.physical_usage | bytesToSize}}/{{SR.size | bytesToSize}} ({{[SR.physical_usage, SR.size] | percentage}})")
.progress-bar(role="progressbar", aria-valuenow="{{100*SR.physical_usage/SR.size}}", aria-valuemin="0", aria-valuemax="100", style="width: {{[SR.physical_usage, SR.size] | percentage}}")
//- Contains all the hosts of this pool.
.grid-cell.grid--gutters.hosts-vms-cells
//- Contains a host and all its children (VMs).
@@ -276,7 +276,7 @@ div(style="margin-top: 57px; visibility: hidden; height: 0") .
//- Memory
li(ng-if="host.power_state === 'Running' && host.enabled")
i.xo-icon-memory.i-progress
.progress.progress-small(tooltip="RAM: {{[host.memory.usage, host.memory.size] | percentage}} allocated")
.progress.progress-small(tooltip="RAM: {{host.memory.usage | bytesToSize}}/{{host.memory.size | bytesToSize}} ({{[host.memory.usage, host.memory.size] | percentage}})")
.progress-bar(role="progressbar", aria-valuenow="{{100*host.memory.usage/host.memory.size}}", aria-valuemin="0", aria-valuemax="100", style="width: {{[host.memory.usage, host.memory.size] | percentage}}")
//- Host address
li.text-muted.substats
@@ -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
@@ -333,7 +333,11 @@ div(style="margin-top: 57px; visibility: hidden; height: 0") .
//- Memory
td.vm-memory-stat.col-md-2.hidden-xs
.cpu
| {{VM.memory.size | bytesToSize}}
span.text-muted
span(tooltip = "{{VM.CPUs.number}} vCPUs ({{VM.CPUs.max}} max)")
| {{VM.CPUs.number}}x&nbsp;
i.xo-icon-cpu
| {{VM.memory.size | bytesToSize}}
i.xo-icon-docker.fa-fw(ng-if="VM.docker", tooltip="Docker enabled")
i.fa.fa-fw(ng-if="VM.xenTools === 'up to date' && !VM.docker")
i.xo-icon-warning.fa-fw(ng-if="VM.xenTools === 'out of date'", tooltip="Xen tools outdated")
@@ -364,20 +368,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
@@ -389,7 +393,11 @@ div(style="margin-top: 57px; visibility: hidden; height: 0") .
//- Memory
td.vm-memory-stat.col-md-2.hidden-xs
.cpu
| {{VM.memory.size | bytesToSize}}
span.text-muted
span(tooltip = "{{VM.CPUs.number}} vCPUs ({{VM.CPUs.max}} max)")
| {{VM.CPUs.number}}x&nbsp;
i.xo-icon-cpu
| {{VM.memory.size | bytesToSize}}
i.xo-icon-docker.fa-fw(ng-if="VM.docker", tooltip="Docker enabled")
i.fa.fa-fw(ng-if="VM.xenTools === 'up to date' && !VM.docker")
i.xo-icon-warning.fa-fw(ng-if="VM.xenTools === 'out of date'", tooltip="Xen tools outdated")

View File

@@ -1,6 +1,9 @@
angular = require 'angular'
assign = require 'lodash.assign'
filter = require 'lodash.filter'
find = require 'lodash.find'
forEach = require 'lodash.foreach'
includes = require 'lodash.includes'
isEmpty = require 'lodash.isempty'
sortBy = require 'lodash.sortby'
@@ -20,8 +23,9 @@ module.exports = angular.module 'xoWebApp.vm', [
.controller 'VmCtrl', (
$scope, $state, $stateParams, $location, $q
xoApi, xo
sizeToBytesFilter, bytesToSizeFilter, xoHideUnauthorizedFilter
bytesToSizeFilter, sizeToBytesFilter, xoHideUnauthorizedFilter, bytesConvertFilter
modal
migrateVmModal
$window
$timeout
dateFilter
@@ -30,12 +34,25 @@ module.exports = angular.module 'xoWebApp.vm', [
$window.bytesToSize = bytesToSizeFilter # FIXME dirty workaround to custom a Chart.js tooltip template
{get} = xoApi
checkMainObject = ->
if !$scope.VM
$state.go('index')
return false
else
return true
pool = null
host = null
vm = null
$scope.srsByContainer = xoApi.getIndex('srsByContainer')
$scope.networksByPool = xoApi.getIndex('networksByPool')
$scope.pools = xoApi.getView('pools')
$scope.PIFs = xoApi.getView('PIFs')
$scope.VIFs = xoApi.getView('VIFs')
do (
networksByPool = xoApi.getIndex('networksByPool')
srsByContainer = xoApi.getIndex('srsByContainer')
hostsByPool = xoApi.getIndex('hostsByPool')
poolSrs = null
hostSrs = null
) ->
@@ -48,6 +65,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
@@ -98,7 +119,8 @@ module.exports = angular.module 'xoWebApp.vm', [
() => this.stop(),
this.baseTimeOut
)
return $scope.refreshStats($scope.VM.id)
promise = if $scope.VM?.id then $scope.refreshStats($scope.VM.id) else $q.reject()
return promise
.then () => this._reset()
.catch (err) =>
if !this.running || this.attempt >= 2 || $scope.VM.power_state isnt 'Running' || $scope.isVMWorking($scope.VM)
@@ -131,8 +153,14 @@ module.exports = angular.module 'xoWebApp.vm', [
$scope.VM = vm = VM
return unless VM?
$scope.cpuWeight = VM.cpuWeight || 0
# For the edition of this VM.
$scope.memorySize = bytesToSizeFilter VM.memory.size
$scope.bytes = VM.memory.size
memory = bytesToSizeFilter($scope.bytes).split(' ')
$scope.memoryValue = memory[0]
$scope.memoryUnit = memory[1]
$scope.bootParams = parseBootParams($scope.VM.boot.order)
$scope.prepareVDIs()
@@ -161,9 +189,19 @@ 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
$scope.VDIs = sortBy(VDIs, (value) -> (get resolveVBD(value))?.position);
if not oVbd.is_cd_drive
size = bytesToSizeFilter(oVdi.size)
oVdi = assign({}, oVdi, {
size,
sizeValue: size.split(' ')[0],
sizeUnit: size.split(' ')[1],
position: oVbd.position
})
oVdi.xoBootable = $scope.isBootable oVdi
VDIs.push oVdi
$scope.VDIs = sortBy(VDIs, 'position');
descriptor = (obj) ->
if !obj
@@ -234,7 +272,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 +340,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'
@@ -365,11 +410,57 @@ module.exports = angular.module 'xoWebApp.vm', [
}
$scope.migrateVM = (id, hostId) ->
modal.confirm
title: 'VM migrate'
message: 'Are you sure you want to migrate this VM?'
.then ->
xo.vm.migrate id, hostId
targetHost = $scope.hosts.all[hostId]
targetPoolId = $scope.hosts.all[hostId].$poolId
targetPool = $scope.pools.all[targetPoolId]
{VDIs} = $scope
vmSrsOnTargetPool = true
forEach(VDIs, (vdi) ->
vmSrsOnTargetPool = vmSrsOnTargetPool && $scope.srsByContainer[targetPoolId].hasOwnProperty(vdi.$SR)
)
if vmSrsOnTargetPool
modal.confirm
title: 'VM migrate'
message: 'Are you sure you want to migrate this VM?'
.then ->
xo.vm.migrate id, hostId
return
defaults = {}
VIFs = []
networks = []
srsOnTargetPool = []
srsOnTargetHost = []
# Possible SRs for each VDI
forEach($scope.srsByContainer[targetPoolId], (sr) ->
srsOnTargetPool.push(sr) if sr.content_type != 'iso'
)
forEach($scope.srsByContainer[targetHost.id], (sr) ->
srsOnTargetHost.push(sr) if sr.content_type != 'iso'
)
defaults.sr = targetPool.default_SR
# Possible networks for each VIF
forEach($scope.VM.VIFs, (vifId) ->
VIFs.push($scope.VIFs.all[vifId])
)
poolNetworks = $scope.networksByPool[targetPoolId]
forEach(targetHost.PIFs, (pifId) ->
networkId = $scope.PIFs.all[pifId].$network
networks.push(poolNetworks[networkId])
)
defaultPIF = find($scope.PIFs.all, (pif) -> pif.management && includes(targetHost.PIFs, pif.id))
defaults.network = defaultPIF.$network
{pool} = $scope
intraPoolMigration = (pool.id == targetPoolId)
migrateVmModal($state, id, hostId, $scope.VDIs, srsOnTargetPool, srsOnTargetHost, VIFs, networks, defaults, intraPoolMigration)
$scope.destroyVM = (id) ->
modal.confirm
@@ -398,18 +489,32 @@ module.exports = angular.module 'xoWebApp.vm', [
xoApi.call 'vm.set', result
$scope.xenDefaultWeight = xenDefaultWeight = 256
$scope.weightMap = {0: 'Default'}
$scope.weightMap[xenDefaultWeight / 4] = 'Quarter (1/4)'
$scope.weightMap[xenDefaultWeight / 2] = 'Half (1/2)'
$scope.weightMap[xenDefaultWeight] = 'Normal'
$scope.weightMap[xenDefaultWeight * 2] = 'Double (x2)'
$scope.units = ['MiB', 'GiB', 'TiB']
$scope.saveVM = ($data) ->
{VM} = $scope
{CPUs, memory, name_label, name_description, high_availability, auto_poweron, PV_args} = $data
{CPUs, cpuWeight, memoryValue, memoryUnit, name_label, name_description, high_availability, auto_poweron, PV_args} = $data
cpuWeight = cpuWeight || 0 # 0 will let XenServer use it's default value
newBytes = sizeToBytesFilter(memoryValue + ' ' + memoryUnit)
$data = {
id: VM.id
}
if memory isnt $scope.memorySize and (memory = sizeToBytesFilter memory)
$data.memory = memory
$scope.memorySize = bytesToSizeFilter memory
if $scope.bytes isnt newBytes
$data.memory = bytesToSizeFilter(newBytes)
if CPUs isnt VM.CPUs.number
$data.CPUs = +CPUs
if cpuWeight isnt (VM.cpuWeight || 0)
$data.cpuWeight = +cpuWeight
if name_label isnt VM.name_label
$data.name_label = name_label
if name_description isnt VM.name_description
@@ -436,83 +541,115 @@ module.exports = angular.module 'xoWebApp.vm', [
return
migrateDisk = (id, sr_id) ->
return modal.confirm({
notify.info {
title: 'Disk migration'
message: 'Are you sure you want to migrate (move) this disk to another SR?'
}).then ->
notify.info {
title: 'Disk migration'
message: 'Disk migration started'
}
xo.vdi.migrate id, sr_id
return
message: 'Disk migration started'
}
xo.vdi.migrate id, sr_id
return
$scope.saveDisks = (data) ->
$scope.saveDisks = (data, vdis) ->
# Group data by disk.
disks = {}
sizeChanges = false
srChanges = false
forEach data, (value, key) ->
i = key.indexOf '/'
(disks[key.slice 0, i] ?= {})[key.slice i + 1] = value
return
# Setting correctly formatted disk size properties
forEach disks, (disk) ->
disk.size = bytesToSizeFilter(sizeToBytesFilter(disk.sizeValue + ' ' + disk.sizeUnit))
disk.sizeValue = disk.size.split(' ')[0]
disk.sizeUnit = disk.size.split(' ')[1]
promises = []
# Handle SR change.
# Set bootable status
forEach vdis, (vdi) ->
bootable = vdi.xoBootable
if $scope.isBootable(vdi) != bootable
id = (get resolveVBD(vdi)).id
promises.push (xo.vbd.setBootable id, bootable)
return
# Disk resize
forEach disks, (attributes, id) ->
disk = get id
if attributes.$SR isnt disk.$SR
promises.push (migrateDisk id, attributes.$SR)
srChanges = true
if attributes.size isnt bytesToSizeFilter(disk.size) # /!\ attributes are provided by a modified copy of disk
sizeChanges = true
return false
return
message = ''
if sizeChanges
message += 'Growing the size of a disk is not reversible. '
if srChanges
message += 'You are about to migrate (move) some disk(s) to another SR. '
message += 'Are you sure you want to perform those changes?'
preCheck = if sizeChanges or srChanges then modal.confirm({title: 'Disk modifications', message: message}) 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({
@@ -530,7 +667,7 @@ module.exports = angular.module 'xoWebApp.vm', [
if not vdi?
return
for vbd in vdi.$VBDs
rVbd = vbd if (get vbd).VM is $scope.VM.id
rVbd = vbd if (get vbd)?.VM is $scope.VM?.id
return rVbd || null
$scope.disconnectVBD = (vdi) ->
@@ -712,6 +849,7 @@ module.exports = angular.module 'xoWebApp.vm', [
xo.docker.unpause VM, container
$scope.addVdi = (vdi, readonly, bootable) ->
return unless checkMainObject()
$scope.addWaiting = true # disables form fields
position = $scope.maxPos + 1
@@ -731,6 +869,7 @@ module.exports = angular.module 'xoWebApp.vm', [
$scope.addWaiting = false
$scope.isConnected = isConnected = (vdi) -> (get resolveVBD(vdi))?.attached
$scope.isBootable = isBootable = (vdi) -> (get resolveVBD(vdi))?.bootable
$scope.isFreeForWriting = isFreeForWriting = (vdi) ->
free = true
@@ -740,6 +879,7 @@ module.exports = angular.module 'xoWebApp.vm', [
return free
$scope.createVdi = (name, size, sr, bootable, readonly) ->
return unless checkMainObject
$scope.createVdiWaiting = true # disables form fields
position = $scope.maxPos + 1
@@ -770,9 +910,10 @@ module.exports = angular.module 'xoWebApp.vm', [
$scope.createVdiWaiting = false
$scope.updateMTU = (network) ->
$scope.newInterfaceMTU = network.MTU
$scope.newInterfaceMTU = network && network.MTU
$scope.createInterface = (network, mtu, automac, mac) ->
return unless checkMainObject()
$scope.createVifWaiting = true # disables form fields
@@ -806,19 +947,19 @@ module.exports = angular.module 'xoWebApp.vm', [
$scope.canAdmin = (id = undefined) ->
if id == undefined
id = $scope.VM && $scope.VM.id
id = $scope.VM?.id
return id && xoApi.canInteract(id, 'administrate') || false
$scope.canOperate = (id = undefined) ->
if id == undefined
id = $scope.VM && $scope.VM.id
id = $scope.VM?.id
return id && xoApi.canInteract(id, 'operate') || false
$scope.canView = (id = undefined) ->
if id == undefined
id = $scope.VM && $scope.VM.id
id = $scope.VM?.id
return id && xoApi.canInteract(id, 'view') || false

View File

@@ -71,20 +71,35 @@
e-form="vmSettings"
)
| {{VM.CPUs.number}}
dt CPU Weight
dd
span(
editable-select="cpuWeight"
e-ng-options="key as value for (key, value) in weightMap"
e-name="cpuWeight"
e-form="vmSettings"
)
| {{ weightMap[VM.cpuWeight || 0] }}
dt RAM
dd
span(
editable-text="memorySize"
e-name="memory"
editable-text="memoryValue"
e-name="memoryValue"
e-form="vmSettings"
)
| {{memoryValue}} {{memoryUnit}}
span(
editable-select="memoryUnit"
e-ng-options="unit for unit in units"
e-name="memoryUnit"
e-form="vmSettings"
)
| {{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 +256,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,129 +270,114 @@
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
| Actions
.panel-body.text-center
.grid-sm.grid--gutters
.grid.grid-cell
.grid-cell.btn-group(ng-if="VM.power_state == ('Running' || 'Paused')")
button.btn(tooltip="Stop VM", tooltip-placement="top", type="button", style="width: 90%", xo-click="stopVM(VM.id)", ng-if = 'canOperate()')
i.fa.fa-stop.fa-2x.fa-fw
.grid-cell.btn-group(ng-if="VM.power_state == ('Running')")
button.btn(tooltip="Suspend VM", tooltip-placement="top", type="button", style="width: 90%", xo-click="suspendVM(VM.id)", ng-if = 'canOperate()')
i.fa.fa-pause.fa-2x.fa-fw
.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 == ('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
.grid-cell.btn-group.dropdown(
ng-if="VM.power_state == ('Running' || 'Paused')"
dropdown
.grid-cell.btn-group(ng-if="VM.power_state == ('Running' || 'Paused')")
button.btn(tooltip="Stop VM", tooltip-placement="top", type="button", style="width: 90%", xo-click="stopVM(VM.id)", ng-if = 'canOperate()')
i.fa.fa-stop.fa-2x.fa-fw
.grid-cell.btn-group(ng-if="VM.power_state == ('Running')")
button.btn(tooltip="Suspend VM", tooltip-placement="top", type="button", style="width: 90%", xo-click="suspendVM(VM.id)", ng-if = 'canOperate()')
i.fa.fa-pause.fa-2x.fa-fw
.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
.grid-cell.btn-group.dropdown(
ng-if="VM.power_state == ('Running' || 'Paused')"
dropdown
)
button.btn.disabled(
ng-if="canAdmin() && (hosts.all | count)==1"
tooltip = "No other host available"
style="width: 90%"
)
button.btn.disabled(
ng-if="canAdmin() && (hosts.all | count)==1"
tooltip = "No other host available"
style="width: 90%"
)
i.fa.fa-share.fa-2x.fa-fw
span.caret
button.btn.dropdown-toggle(
ng-if = "canAdmin() && (hosts.all | count)>1"
dropdown-toggle
tooltip="Migrate VM"
tooltip-placement="top"
type="button"
style="width: 90%"
)
i.fa.fa-share.fa-2x.fa-fw
span.caret
ul.dropdown-menu.left(role="menu", ng-if = 'canAdmin()')
li(ng-repeat="h in hosts.all | orderBy:natural('name_label') track by h.id" ng-if="h!=host")
a(xo-click="migrateVM(VM.id, h.id)")
i.xo-icon-host.fa-fw
| To {{h.name_label}}
.grid-cell.btn-group(ng-if="VM.power_state == ('Running' || 'Paused')")
button.btn(tooltip="Force Reboot", tooltip-placement="top", type="button", style="width: 90%", xo-click="force_rebootVM(VM.id)", ng-if = 'canOperate()')
i.fa.fa-flash.fa-2x.fa-fw
.grid-cell.btn-group(ng-if="VM.power_state == ('Halted')")
button.btn(tooltip="Delete VM", tooltip-placement="top", type="button", style="width: 90%", xo-click="destroyVM(VM.id)", ng-if = 'canAdmin()')
i.fa.fa-trash-o.fa-2x.fa-fw
.grid-cell.btn-group.dropdown(
ng-if="VM.power_state == ('Halted')"
dropdown
i.fa.fa-share.fa-2x.fa-fw
span.caret
button.btn.dropdown-toggle(
ng-if = "canAdmin() && (hosts.all | count)>1"
dropdown-toggle
tooltip="Migrate VM"
tooltip-placement="top"
type="button"
style="width: 90%"
)
button.btn.dropdown-toggle(
ng-if = 'canAdmin()'
dropdown-toggle
tooltip="Create a clone"
tooltip-placement="top"
style="width: 90%"
type="button"
)
i.fa.fa-files-o.fa-2x.fa-fw
span.caret
ul.dropdown-menu.left(role="menu")
li(ng-if = 'canAdmin()')
a(xo-click="cloneVM(VM.id,VM.name_label,false)")
i.fa.fa-code-fork.fa-fw
| Fast clone
li(ng-if = 'canAdmin()')
a(xo-click="cloneVM(VM.id,VM.name_label,true)")
i.xo-icon-disk.fa-fw
| Full disk copy
.grid-cell.btn-group(ng-if="VM.power_state == ('Halted')")
button.btn(tooltip="Convert to template", tooltip-placement="top", type="button", style="width: 90%", xo-click="convertVM(VM.id)", ng-if = 'canAdmin()')
i.fa.fa-thumb-tack.fa-2x.fa-fw
.grid.grid-cell
.grid-cell.btn-group(ng-if="VM.power_state == ('Running' || 'Paused')")
button.btn(tooltip="Force Shutdown", tooltip-placement="top", type="button", style="width: 90%", xo-click="force_stopVM(VM.id)", ng-if = 'canOperate()')
i.fa.fa-power-off.fa-2x.fa-fw
.grid-cell.btn-group(ng-if="VM.power_state == ('Suspended')")
button.btn(tooltip="Resume VM", tooltip-placement="top", type="button", style="width: 90%", xo-click="resumeVM(VM.id)", ng-if = 'canOperate()')
i.fa.fa-play.fa-2x.fa-fw
.grid-cell.btn-group
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"
)
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
tooltip="Copy the VM"
tooltip-placement="top"
style="width: 90%"
type="button"
)
i.fa.fa-clone.fa-2x.fa-fw
span.caret
ul.dropdown-menu.left(role="menu")
li(ng-repeat = 'SR in objects | selectHighLevel | filter:{type: "SR", content_type: "!iso"} | orderBy:"($container | resolve).name_label"')
a(xo-click = 'copyVM(VM.id, SR.id)') {{ SR.name_label}} ({{(SR.$container | resolve).name_label}})
.grid-cell.btn-group(style="margin-bottom: 0.5em")
button.btn(tooltip="VM Console", tooltip-placement="top", type="button", style="width: 90%", xo-sref="consoles_view({id: VM.id})")
i.xo-icon-console.fa-2x.fa-fw
i.fa.fa-share.fa-2x.fa-fw
span.caret
ul.dropdown-menu.left(role="menu", ng-if = 'canAdmin()')
li(ng-repeat="h in hosts.all | orderBy:natural('name_label') track by h.id" ng-if="h!=host")
a(xo-click="migrateVM(VM.id, h.id)")
i.xo-icon-host.fa-fw
| To {{h.name_label}}
.grid-cell.btn-group(ng-if="VM.power_state == ('Running' || 'Paused')")
button.btn(tooltip="Force Reboot", tooltip-placement="top", type="button", style="width: 90%", xo-click="force_rebootVM(VM.id)", ng-if = 'canOperate()')
i.fa.fa-flash.fa-2x.fa-fw
.grid-cell.btn-group
button.btn(tooltip="Delete VM", tooltip-placement="top", type="button", style="width: 90%", xo-click="destroyVM(VM.id)", ng-if = 'canAdmin()')
i.fa.fa-trash-o.fa-2x.fa-fw
.grid-cell.btn-group.dropdown(
ng-if="VM.power_state == ('Halted')"
dropdown
)
button.btn.dropdown-toggle(
ng-if = 'canAdmin()'
dropdown-toggle
tooltip="Create a clone"
tooltip-placement="top"
style="width: 90%"
type="button"
)
i.fa.fa-files-o.fa-2x.fa-fw
span.caret
ul.dropdown-menu.left(role="menu")
li(ng-if = 'canAdmin()')
a(xo-click="cloneVM(VM.id,VM.name_label,false)")
i.fa.fa-code-fork.fa-fw
| Fast clone
li(ng-if = 'canAdmin()')
a(xo-click="cloneVM(VM.id,VM.name_label,true)")
i.xo-icon-disk.fa-fw
| Full disk copy
.grid-cell.btn-group(ng-if="VM.power_state == ('Halted')")
button.btn(tooltip="Convert to template", tooltip-placement="top", type="button", style="width: 90%", xo-click="convertVM(VM.id)", ng-if = 'canAdmin()')
i.fa.fa-thumb-tack.fa-2x.fa-fw
.grid-cell.btn-group(ng-if="VM.power_state == ('Running' || 'Paused')")
button.btn(tooltip="Force Shutdown", tooltip-placement="top", type="button", style="width: 90%", xo-click="force_stopVM(VM.id)", ng-if = 'canOperate()')
i.fa.fa-power-off.fa-2x.fa-fw
.grid-cell.btn-group(ng-if="VM.power_state == ('Suspended')")
button.btn(tooltip="Resume VM", tooltip-placement="top", type="button", style="width: 90%", xo-click="resumeVM(VM.id)", ng-if = 'canOperate()')
i.fa.fa-play.fa-2x.fa-fw
.grid-cell.btn-group
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(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
.grid-cell.btn-group.dropdown(dropdown, ng-if="canAdmin()")
button.btn.dropdown-toggle(
dropdown-toggle
tooltip="Copy the VM"
tooltip-placement="top"
style="width: 90%"
type="button"
)
i.fa.fa-clone.fa-2x.fa-fw
span.caret
ul.dropdown-menu.left(role="menu")
li(ng-repeat = 'SR in objects | selectHighLevel | filter:{type: "SR", content_type: "!iso"} | orderBy:"($container | resolve).name_label"')
a(xo-click = 'copyVM(VM.id, SR.id)') {{ SR.name_label}} ({{(SR.$container | resolve).name_label}})
.grid-cell.btn-group(style="margin-bottom: 0.5em")
button.btn(tooltip="VM Console", tooltip-placement="top", type="button", style="width: 90%", xo-sref="consoles_view({id: VM.id})")
i.xo-icon-console.fa-2x.fa-fw
//- Docker Panel (if Docker VM)
.grid-sm(ng-if="VM.docker")
.panel.panel-default
@@ -449,16 +449,18 @@
)
i.fa.fa-undo.fa-fw
.panel-body
form(name = "disksForm", editable-form = '', onbeforesave = 'saveDisks($data)')
form(name = "disksForm", editable-form = '', onbeforesave = 'saveDisks($data, VDIs)')
table.table.table-hover
tr
th Name
th Description
th Tags
th Size
th SR
th Status
th(ng-show="disksForm.$visible")
th.col-md-2 Name
th.col-md-2 Description
th.col-md-2 Tags
th.col-md-1 Size
th.col-md-1(ng-show="disksForm.$visible")
th.col-md-2 SR
th.col-md-1 Bootable
th.col-md-2 Status
th.col-md-2(ng-show="disksForm.$visible")
//- FIXME: ng-init seems to disrupt the implicit $watch.
tr(ng-repeat = 'VDI in VDIs track by VDI.id')
td.oneliner
@@ -476,19 +478,41 @@
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.sizeValue"
e-name = '{{VDI.id}}/sizeValue'
)
| {{VDI.sizeValue}} {{VDI.sizeUnit}}
td(ng-show="disksForm.$visible")
span(
editable-select="VDI.sizeUnit"
e-ng-options="unit for unit in units"
e-name="{{VDI.id}}/sizeUnit"
)
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.physical_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
span.label.label-success(
ng-if="isBootable(VDI)"
ng-show="!disksForm.$visible"
) Bootable
span.label.label-default(
ng-if="!isBootable(VDI)"
ng-show="!disksForm.$visible"
) No Bootable
input(
ng-show="disksForm.$visible"
type="checkbox"
ng-model="VDI.xoBootable"
)
td(ng-if="isConnected(VDI)")
span.label.label-success Connected
span.pull-right.btn-group.quick-buttons(ng-if="canAdmin()")
@@ -565,7 +589,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 +630,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.physical_usage | bytesToSize) + ' free) ' + (SR.$container | resolve).name_label) for SR in writable_SRs")
option(value = '', disabled) Choose your SR
| &nbsp;
br
@@ -738,13 +762,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

@@ -74,10 +74,14 @@ export default angular.module('no-vnc', [])
let isSecure = url.protocol === 'wss:'
let canvas = $element.find('canvas')[0]
rfb = new RFB({
encrypt: isSecure,
target: canvas,
wsProtocols: ['chat']
wsProtocols: ['chat'],
onClipboard (rfb, text) {
setClipboard(text)
}
})
rfb._onUpdateState = (rfb, state) => {
@@ -101,6 +105,11 @@ export default angular.module('no-vnc', [])
}
this.remoteControl = {
pasteToClipboard (text) {
if (rfb) {
rfb.clipboardPasteFrom(text)
}
},
sendCtrlAltDel () {
if (rfb) {
rfb.sendCtrlAltDel()
@@ -108,7 +117,25 @@ export default angular.module('no-vnc', [])
}
}
let canvas = $element.find('canvas')[0]
const setClipboard = (text) => {
this.onClipboardChange({ clipboardContent: text })
}
$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 +149,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')

123
app/node_modules/xo-api/acl.js generated vendored
View File

@@ -1,123 +0,0 @@
// These global variables are not a problem because the algorithm is
// synchronous.
let permissionsByObject
let getObject
// -------------------------------------------------------------------
const authorized = () => true // eslint-disable-line no-unused-vars
const forbiddden = () => false // eslint-disable-line no-unused-vars
function and (...checkers) { // eslint-disable-line no-unused-vars
return function (object, permission) {
for (const checker of checkers) {
if (!checker(object, permission)) {
return false
}
}
return true
}
}
function or (...checkers) { // eslint-disable-line no-unused-vars
return function (object, permission) {
for (const checker of checkers) {
if (checker(object, permission)) {
return true
}
}
return false
}
}
// -------------------------------------------------------------------
function checkMember (memberName) {
return function (object, permission) {
const member = object[memberName]
return checkAuthorization(member, permission)
}
}
function checkSelf ({ id }, permission) {
const permissionsForObject = permissionsByObject[id]
return (
permissionsForObject &&
permissionsForObject[permission]
)
}
// ===================================================================
const checkAuthorizationByTypes = {
host: or(checkSelf, checkMember('$poolId')),
message: checkMember('$object'),
network: or(checkSelf, checkMember('$poolId')),
SR: or(checkSelf, checkMember('$poolId')),
task: checkMember('$host'),
VBD: checkMember('VDI'),
// Access to a VDI is granted if the user has access to the
// containing SR or to a linked VM.
VDI (vdi, permission) {
// Check authorization for the containing SR.
if (checkAuthorization(vdi.$SR, permission)) {
return true
}
// Check authorization for each of the connected VMs.
for (const {$VM: vm} of vdi.$VBDs) {
if (checkAuthorization(vm, permission)) {
return true
}
}
return false
},
VIF: or(checkMember('$network'), checkMember('$VM')),
VM: or(checkSelf, checkMember('$container')),
'VM-snapshot': checkMember('snapshot_of'),
'VM-template': authorized
}
function checkAuthorization (objectId, permission) {
const object = getObject(objectId)
const checker = checkAuthorizationByTypes[object.type] || checkSelf
return checker(object, permission)
}
// -------------------------------------------------------------------
export default function (
permissionsByObject_,
getObject_,
permissions
) {
// Assign global variables.
permissionsByObject = permissionsByObject_
getObject = getObject_
try {
for (const [objectId, permission] of permissions) {
if (!checkAuthorization(objectId, permission)) {
return false
}
}
return true
} finally {
// Free the global variables.
permissionsByObject = getObject = null
}
}

3
app/node_modules/xo-api/index.js generated vendored
View File

@@ -1,5 +1,6 @@
import angular from 'angular'
import angularCookies from 'angular-cookies'
import checkPermissions from 'xo-acl-resolver'
import cloneDeep from 'lodash.clonedeep'
import forEach from 'lodash.foreach'
import indexOf from 'lodash.indexof'
@@ -9,8 +10,6 @@ import xoLib from 'xo-lib'
import XoUniqueIndex from 'xo-collection/unique-index'
import XoView from 'xo-collection/view'
import checkPermissions from './acl'
const {defineProperty} = Object
const {isArray, isString} = angular

80
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,62 @@ export default angular.module('xoWebApp.filters', [
// Maximum 1 decimals.
bytes = ((bytes * 10) | 0) / 10
return `${bytes}${powers[i]}${unit}`
return `${bytes} ${powers[i]}${unit}`
}
})
// Takes a size (eg.: '8 GiB') and returns a number of bytes
.filter('sizeToBytes', () => {
/* eslint no-multi-spaces: 0 */
const powers = ['', 'K', 'M', 'G', 'T', 'P']
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')
return function sizeToBytes (size, unit = 'iB', base = 1024) {
const split = size.split(' ')
if (split.length !== 2) {
return size
}
let number = parseInt(split[0], 10)
const powerUnit = split[1]
const power = powerUnit.slice(0, 1)
const effUnit = powerUnit.slice(1)
const factors = {
k: 1024,
m: 1048576,
g: 1073741824,
t: 1099511627776,
p: 1125899906842624
if (!includes(powers, power) || unit !== effUnit) {
return size
}
let i = 0
while (powers[i] !== power) {
number *= base
++i
}
return number
}
})
return function sizeToBytes (size) {
const matches = RE.exec(size)
// Takes a number of bytes and converts it to the desired unit (GiB, MiB, etc.).
.filter('bytesConvert', () => {
return function bytesConvert (bytes, powerUnit = 'GiB', unit = 'iB', base = 1024) {
let powerUnits = ['', 'K', 'M', 'G', 'T', 'P']
powerUnits = powerUnits.map(pu => pu + unit)
// If the input is invalid, just returns null.
if (!matches) {
return null
if (!isNumber(bytes) || !includes(powerUnits, powerUnit)) {
return bytes
}
let value = +matches[1]
const modifier = matches[2]
if (modifier) {
const factor = factors[modifier.toLowerCase()]
if (factor) {
value *= factor
}
let i = 0
while (powerUnit !== powerUnits[i]) {
bytes /= base
++i
}
return Math.round(value)
if (bytes === -1) {
return '-'
}
// Maximum 1 decimals.
bytes = ((bytes * 10) | 0) / 10
return bytes
}
})
@@ -105,6 +126,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() || ''))

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

@@ -72,9 +72,21 @@ export default angular.module('xo', [
console.error('Error for %s:', method, error)
if (notification !== false) {
const message = (error && error.code === 2)
? 'You don\'t have the permission.'
: 'The action failed for unknown reason.'
let message
if (error) {
const code = error.code
if (code === 2) {
message = 'You don\'t have the permission.'
} else if (code === 5) {
message = error.data
}
}
if (!message) {
message = 'The action failed for unknown reason.'
}
xoNotify.warning({
title: name,
@@ -104,6 +116,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 +128,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 +147,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})
@@ -148,6 +169,9 @@ export default angular.module('xo', [
logs: {
get: action('Returns logs for one namespace', 'log.get', {
argsMapper: (namespace) => ({namespace})
}),
delete: action('Delete on or several logs for one namespace', 'log.delete', {
argsMapper: (namespace, id) => ({namespace, id})
})
},
message: {
@@ -228,6 +252,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 +292,26 @@ 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 })
argsMapper:
(vm, targetHost, mapVdisSrs = undefined, mapVifsNetworks = undefined, migrationNetwork = undefined) =>
({ vm, targetHost, mapVdisSrs, mapVifsNetworks, migrationNetwork })
}),
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 +321,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})
@@ -304,6 +350,9 @@ export default angular.module('xo', [
},
vbd: {
setBootable: action('Set bootable VBD', 'vbd.setBootable', {
argsMapper: (vbd, bootable) => ({vbd, bootable})
}),
delete: action('Delete VBD', 'vbd.delete'),
disconnect: action('Disconnect VBD', 'vbd.disconnect'),
connect: action('Connect VBD', 'vbd.connect')
@@ -319,20 +368,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 +423,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 +451,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

@@ -75,7 +75,6 @@ td.grab {padding: 0 !important; margin: 0 !important; width: 6px !important; cur
tr:hover .grab {background: url("../images/grip.png") no-repeat scroll 1px 50% transparent !important}
table { table-layout: fixed; }
table th, table td { overflow: hidden; }
td.vm-power-state {width: 20px; text-align: center;}

View File

@@ -1,6 +1,6 @@
{
"name": "xo-web",
"version": "4.9.0",
"version": "4.13.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",
@@ -71,7 +74,7 @@
"lodash.sortby": "^3.1.0",
"lodash.sum": "^3.6.1",
"lodash.throttle": "^3.0.1",
"lodash.trim": "^3.0.1",
"lodash.trim": "3.0.1",
"lodash.union": "^3.1.0",
"make-error": "^1.0.2",
"marked": "^0.3.5",
@@ -85,8 +88,10 @@
"vinyl": "^1.1.0",
"watchify": "^3.1.1",
"ws": "^0.8.0",
"xo-acl-resolver": "0.0.0-0",
"xo-collection": "^0.4.0",
"xo-lib": "^0.7.3"
"xo-lib": "^0.7.3",
"xo-remote-parser": "^0.1.0"
},
"repository": {
"type": "git",