]> git.proxmox.com Git - pve-manager.git/blob - www/manager6/dc/Backup.js
close #438: ui: backup job: allow setting a notes-template for a job
[pve-manager.git] / www / manager6 / dc / Backup.js
1 Ext.define('PVE.dc.BackupEdit', {
2 extend: 'Proxmox.window.Edit',
3 alias: ['widget.pveDcBackupEdit'],
4
5 defaultFocus: undefined,
6
7 initComponent: function() {
8 let me = this;
9
10 me.isCreate = !me.jobid;
11
12 let url, method;
13 if (me.isCreate) {
14 url = '/api2/extjs/cluster/backup';
15 method = 'POST';
16 } else {
17 url = '/api2/extjs/cluster/backup/' + me.jobid;
18 method = 'PUT';
19 }
20
21 let vmidField = Ext.create('Ext.form.field.Hidden', {
22 name: 'vmid',
23 });
24
25 // 'value' can be assigned a string or an array
26 let selModeField = Ext.create('Proxmox.form.KVComboBox', {
27 xtype: 'proxmoxKVComboBox',
28 comboItems: [
29 ['include', gettext('Include selected VMs')],
30 ['all', gettext('All')],
31 ['exclude', gettext('Exclude selected VMs')],
32 ['pool', gettext('Pool based')],
33 ],
34 fieldLabel: gettext('Selection mode'),
35 name: 'selMode',
36 value: '',
37 });
38
39 let sm = Ext.create('Ext.selection.CheckboxModel', {
40 mode: 'SIMPLE',
41 listeners: {
42 selectionchange: function(model, selected) {
43 let sel = selected.map(record => record.data.vmid);
44 // to avoid endless recursion suspend the vmidField change
45 // event temporary as it calls us again
46 vmidField.suspendEvent('change');
47 vmidField.setValue(sel);
48 vmidField.resumeEvent('change');
49 },
50 },
51 });
52
53 let storagesel = Ext.create('PVE.form.StorageSelector', {
54 fieldLabel: gettext('Storage'),
55 clusterView: true,
56 storageContent: 'backup',
57 allowBlank: false,
58 name: 'storage',
59 listeners: {
60 change: function(f, v) {
61 let store = f.getStore();
62 let rec = store.findRecord('storage', v, 0, false, true, true);
63 let compressionSelector = me.down('pveCompressionSelector');
64
65 if (rec && rec.data && rec.data.type === 'pbs') {
66 compressionSelector.setValue('zstd');
67 compressionSelector.setDisabled(true);
68 } else if (!compressionSelector.getEditable()) {
69 compressionSelector.setDisabled(false);
70 }
71 },
72 },
73 });
74
75 let store = new Ext.data.Store({
76 model: 'PVEResources',
77 sorters: {
78 property: 'vmid',
79 direction: 'ASC',
80 },
81 });
82
83 let vmgrid = Ext.createWidget('grid', {
84 store: store,
85 border: true,
86 height: 300,
87 selModel: sm,
88 disabled: true,
89 columns: [
90 {
91 header: 'ID',
92 dataIndex: 'vmid',
93 width: 60,
94 },
95 {
96 header: gettext('Node'),
97 dataIndex: 'node',
98 },
99 {
100 header: gettext('Status'),
101 dataIndex: 'uptime',
102 renderer: function(value) {
103 if (value) {
104 return Proxmox.Utils.runningText;
105 } else {
106 return Proxmox.Utils.stoppedText;
107 }
108 },
109 },
110 {
111 header: gettext('Name'),
112 dataIndex: 'name',
113 flex: 1,
114 },
115 {
116 header: gettext('Type'),
117 dataIndex: 'type',
118 },
119 ],
120 });
121
122 let selectPoolMembers = function(poolid) {
123 if (!poolid) {
124 return;
125 }
126 sm.deselectAll(true);
127 store.filter([
128 {
129 id: 'poolFilter',
130 property: 'pool',
131 value: poolid,
132 },
133 ]);
134 sm.selectAll(true);
135 };
136
137 let selPool = Ext.create('PVE.form.PoolSelector', {
138 fieldLabel: gettext('Pool to backup'),
139 hidden: true,
140 allowBlank: true,
141 name: 'pool',
142 listeners: {
143 change: function(selpool, newValue, oldValue) {
144 selectPoolMembers(newValue);
145 },
146 },
147 });
148
149 let nodesel = Ext.create('PVE.form.NodeSelector', {
150 name: 'node',
151 fieldLabel: gettext('Node'),
152 allowBlank: true,
153 editable: true,
154 autoSelect: false,
155 emptyText: '-- ' + gettext('All') + ' --',
156 listeners: {
157 change: function(f, value) {
158 storagesel.setNodename(value);
159 let mode = selModeField.getValue();
160 store.clearFilter();
161 store.filterBy(function(rec) {
162 return !value || rec.get('node') === value;
163 });
164 if (mode === 'all') {
165 sm.selectAll(true);
166 }
167 if (mode === 'pool') {
168 selectPoolMembers(selPool.value);
169 }
170 },
171 },
172 });
173
174 let column1 = [
175 nodesel,
176 storagesel,
177 {
178 xtype: 'pveCalendarEvent',
179 fieldLabel: gettext('Schedule'),
180 allowBlank: false,
181 name: 'schedule',
182 },
183 selModeField,
184 selPool,
185 ];
186
187 let column2 = [
188 {
189 xtype: 'textfield',
190 fieldLabel: gettext('Send email to'),
191 name: 'mailto',
192 },
193 {
194 xtype: 'pveEmailNotificationSelector',
195 fieldLabel: gettext('Email'),
196 name: 'mailnotification',
197 deleteEmpty: !me.isCreate,
198 value: me.isCreate ? 'always' : '',
199 },
200 {
201 xtype: 'pveCompressionSelector',
202 fieldLabel: gettext('Compression'),
203 name: 'compress',
204 deleteEmpty: !me.isCreate,
205 value: 'zstd',
206 },
207 {
208 xtype: 'pveBackupModeSelector',
209 fieldLabel: gettext('Mode'),
210 value: 'snapshot',
211 name: 'mode',
212 },
213 {
214 xtype: 'proxmoxcheckbox',
215 fieldLabel: gettext('Enable'),
216 name: 'enabled',
217 uncheckedValue: 0,
218 defaultValue: 1,
219 checked: true,
220 },
221 vmidField,
222 ];
223
224 let ipanel = Ext.create('Proxmox.panel.InputPanel', {
225 onlineHelp: 'chapter_vzdump',
226 column1: column1,
227 column2: column2,
228 columnB: [
229 {
230 xtype: 'proxmoxtextfield',
231 name: 'comment',
232 fieldLabel: gettext('Job Comment'),
233 deleteEmpty: !me.isCreate,
234 autoEl: {
235 tag: 'div',
236 'data-qtip': gettext('Description of the job'),
237 },
238 },
239 ],
240 onGetValues: function(values) {
241 if (!values.node) {
242 if (!me.isCreate) {
243 Proxmox.Utils.assemble_field_data(values, { 'delete': 'node' });
244 }
245 delete values.node;
246 }
247
248 if (!values.id && me.isCreate) {
249 values.id = 'backup-' + Ext.data.identifier.Uuid.Global.generate().slice(0, 13);
250 }
251
252 let selMode = values.selMode;
253 delete values.selMode;
254
255 if (selMode === 'all') {
256 values.all = 1;
257 values.exclude = '';
258 delete values.vmid;
259 } else if (selMode === 'exclude') {
260 values.all = 1;
261 values.exclude = values.vmid;
262 delete values.vmid;
263 } else if (selMode === 'pool') {
264 delete values.vmid;
265 }
266
267 if (selMode !== 'pool') {
268 delete values.pool;
269 }
270 return values;
271 },
272 });
273
274 let update_vmid_selection = function(list, mode) {
275 if (mode !== 'all' && mode !== 'pool') {
276 sm.deselectAll(true);
277 if (list) {
278 Ext.Array.each(list.split(','), function(vmid) {
279 var rec = store.findRecord('vmid', vmid, 0, false, true, true);
280 if (rec) {
281 sm.select(rec, true);
282 }
283 });
284 }
285 }
286 };
287
288 vmidField.on('change', function(f, value) {
289 let mode = selModeField.getValue();
290 update_vmid_selection(value, mode);
291 });
292
293 selModeField.on('change', function(f, value, oldValue) {
294 if (oldValue === 'pool') {
295 store.removeFilter('poolFilter');
296 }
297
298 if (oldValue === 'all') {
299 sm.deselectAll(true);
300 vmidField.setValue('');
301 }
302
303 if (value === 'all') {
304 sm.selectAll(true);
305 vmgrid.setDisabled(true);
306 } else {
307 vmgrid.setDisabled(false);
308 }
309
310 if (value === 'pool') {
311 vmgrid.setDisabled(true);
312 vmidField.setValue('');
313 selPool.setVisible(true);
314 selPool.allowBlank = false;
315 selectPoolMembers(selPool.value);
316 } else {
317 selPool.setVisible(false);
318 selPool.allowBlank = true;
319 }
320 let list = vmidField.getValue();
321 update_vmid_selection(list, value);
322 });
323
324 let reload = function() {
325 store.load({
326 params: {
327 type: 'vm',
328 },
329 callback: function() {
330 let node = nodesel.getValue();
331 store.clearFilter();
332 store.filterBy(rec => !node || node.length === 0 || rec.get('node') === node);
333 let list = vmidField.getValue();
334 let mode = selModeField.getValue();
335 if (mode === 'all') {
336 sm.selectAll(true);
337 } else if (mode === 'pool') {
338 selectPoolMembers(selPool.value);
339 } else {
340 update_vmid_selection(list, mode);
341 }
342 },
343 });
344 };
345
346 Ext.applyIf(me, {
347 subject: gettext("Backup Job"),
348 url: url,
349 method: method,
350 bodyPadding: 0,
351 items: [
352 {
353 xtype: 'tabpanel',
354 region: 'center',
355 layout: 'fit',
356 bodyPadding: 10,
357 items: [
358 {
359 xtype: 'container',
360 title: gettext('General'),
361 region: 'center',
362 layout: {
363 type: 'vbox',
364 align: 'stretch',
365 },
366 items: [
367 ipanel,
368 vmgrid,
369 ],
370 },
371 {
372 xtype: 'pveBackupJobPrunePanel',
373 title: gettext('Retention'),
374 isCreate: me.isCreate,
375 keepAllDefaultForCreate: false,
376 showPBSHint: false,
377 fallbackHintHtml: gettext('Without any keep option, the storage\'s configuration or node\'s vzdump.conf is used as fallback'),
378 },
379 {
380 xtype: 'inputpanel',
381 title: gettext('Note Template'),
382 region: 'center',
383 layout: {
384 type: 'vbox',
385 align: 'stretch',
386 },
387 onGetValues: function(values) {
388 if (values['notes-template']) {
389 values['notes-template'] = PVE.Utils.escapeNotesTemplate(
390 values['notes-template']);
391 }
392 return values;
393 },
394 items: [
395 {
396 xtype: 'textarea',
397 name: 'notes-template',
398 fieldLabel: gettext('Backup Notes'),
399 height: 100,
400 maxLength: 512,
401 deleteEmpty: !me.isCreate,
402 value: me.isCreate ? '{{guestname}}' : undefined,
403 },
404 {
405 xtype: 'box',
406 style: {
407 margin: '8px 0px',
408 'line-height': '1.5em',
409 },
410 html: gettext('The notes are added to each backup created by this job.')
411 + '<br>'
412 + Ext.String.format(
413 gettext('Possible template variables are: {0}'),
414 ['cluster', 'guestname', 'node', 'vmid'].map(v => `<code>{{${v}}}</code>`).join(', '),
415 ),
416 },
417 ],
418 },
419 ],
420 },
421 ],
422
423 });
424
425 me.callParent();
426
427 if (me.isCreate) {
428 selModeField.setValue('include');
429 } else {
430 me.load({
431 success: function(response, options) {
432 let data = response.result.data;
433
434 data.dow = (data.dow || '').split(',');
435
436 if (data.all || data.exclude) {
437 if (data.exclude) {
438 data.vmid = data.exclude;
439 data.selMode = 'exclude';
440 } else {
441 data.vmid = '';
442 data.selMode = 'all';
443 }
444 } else if (data.pool) {
445 data.selMode = 'pool';
446 data.selPool = data.pool;
447 } else {
448 data.selMode = 'include';
449 }
450
451 if (data['prune-backups']) {
452 Object.assign(data, data['prune-backups']);
453 delete data['prune-backups'];
454 } else if (data.maxfiles !== undefined) {
455 if (data.maxfiles > 0) {
456 data['keep-last'] = data.maxfiles;
457 } else {
458 data['keep-all'] = 1;
459 }
460 delete data.maxfiles;
461 }
462
463 if (data['notes-template']) {
464 data['notes-template'] = PVE.Utils.unEscapeNotesTemplate(
465 data['notes-template']);
466 }
467
468 me.setValues(data);
469 },
470 });
471 }
472
473 reload();
474 },
475 });
476
477 Ext.define('PVE.dc.BackupView', {
478 extend: 'Ext.grid.GridPanel',
479
480 alias: ['widget.pveDcBackupView'],
481
482 onlineHelp: 'chapter_vzdump',
483
484 allText: '-- ' + gettext('All') + ' --',
485
486 initComponent: function() {
487 let me = this;
488
489 let store = new Ext.data.Store({
490 model: 'pve-cluster-backup',
491 proxy: {
492 type: 'proxmox',
493 url: "/api2/json/cluster/backup",
494 },
495 });
496
497 let not_backed_store = new Ext.data.Store({
498 sorters: 'vmid',
499 proxy: {
500 type: 'proxmox',
501 url: 'api2/json/cluster/backup-info/not-backed-up',
502 },
503 });
504
505 let noBackupJobWarning, noBackupJobInfoButton;
506 let reload = function() {
507 store.load();
508 not_backed_store.load({
509 callback: function(records, operation, success) {
510 noBackupJobWarning.setVisible(records.length > 0);
511 noBackupJobInfoButton.setVisible(records.length > 0);
512 },
513 });
514 };
515
516 let sm = Ext.create('Ext.selection.RowModel', {});
517
518 let run_editor = function() {
519 let rec = sm.getSelection()[0];
520 if (!rec) {
521 return;
522 }
523
524 let win = Ext.create('PVE.dc.BackupEdit', {
525 jobid: rec.data.id,
526 });
527 win.on('destroy', reload);
528 win.show();
529 };
530
531 let run_detail = function() {
532 let record = sm.getSelection()[0];
533 if (!record) {
534 return;
535 }
536 Ext.create('Ext.window.Window', {
537 modal: true,
538 width: 800,
539 height: 700,
540 resizable: true,
541 layout: 'fit',
542 title: gettext('Backup Details'),
543 items: [
544 {
545 xtype: 'panel',
546 region: 'center',
547 layout: {
548 type: 'vbox',
549 align: 'stretch',
550 },
551 items: [
552 {
553 xtype: 'pveBackupInfo',
554 flex: 0,
555 layout: 'fit',
556 record: record.data,
557 },
558 {
559 xtype: 'pveBackupDiskTree',
560 title: gettext('Included disks'),
561 flex: 1,
562 jobid: record.data.id,
563 },
564 ],
565 },
566 ],
567 }).show();
568 };
569
570 let run_backup_now = function(job) {
571 job = Ext.clone(job);
572
573 let jobNode = job.node;
574 // Remove properties related to scheduling
575 delete job.enabled;
576 delete job.starttime;
577 delete job.dow;
578 delete job.id;
579 delete job.schedule;
580 delete job.type;
581 delete job.node;
582 delete job.comment;
583 delete job['next-run'];
584 job.all = job.all === true ? 1 : 0;
585
586 if (job['prune-backups']) {
587 job['prune-backups'] = PVE.Parser.printPropertyString(job['prune-backups']);
588 }
589
590 let allNodes = PVE.data.ResourceStore.getNodes();
591 let nodes = allNodes.filter(node => node.status === 'online').map(node => node.node);
592 let errors = [];
593
594 if (jobNode !== undefined) {
595 if (!nodes.includes(jobNode)) {
596 Ext.Msg.alert('Error', "Node '"+ jobNode +"' from backup job isn't online!");
597 return;
598 }
599 nodes = [jobNode];
600 } else {
601 let unkownNodes = allNodes.filter(node => node.status !== 'online');
602 if (unkownNodes.length > 0) {errors.push(unkownNodes.map(node => node.node + ": " + gettext("Node is offline")));}
603 }
604 let jobTotalCount = nodes.length, jobsStarted = 0;
605
606 Ext.Msg.show({
607 title: gettext('Please wait...'),
608 closable: false,
609 progress: true,
610 progressText: '0/' + jobTotalCount,
611 });
612
613 let postRequest = function() {
614 jobsStarted++;
615 Ext.Msg.updateProgress(jobsStarted / jobTotalCount, jobsStarted + '/' + jobTotalCount);
616
617 if (jobsStarted === jobTotalCount) {
618 Ext.Msg.hide();
619 if (errors.length > 0) {
620 Ext.Msg.alert('Error', 'Some errors have been encountered:<br />' + errors.join('<br />'));
621 }
622 }
623 };
624
625 nodes.forEach(node => Proxmox.Utils.API2Request({
626 url: '/nodes/' + node + '/vzdump',
627 method: 'POST',
628 params: job,
629 failure: function(response, opts) {
630 errors.push(node + ': ' + response.htmlStatus);
631 postRequest();
632 },
633 success: postRequest,
634 }));
635 };
636
637 let run_show_not_backed = function() {
638 Ext.create('Ext.window.Window', {
639 modal: true,
640 width: 600,
641 height: 500,
642 resizable: true,
643 layout: 'fit',
644 title: gettext('Guests without backup job'),
645 items: [
646 {
647 xtype: 'panel',
648 region: 'center',
649 layout: {
650 type: 'vbox',
651 align: 'stretch',
652 },
653 items: [
654 {
655 xtype: 'pveBackedGuests',
656 flex: 1,
657 layout: 'fit',
658 store: not_backed_store,
659 },
660 ],
661 },
662 ],
663 }).show();
664 };
665
666 var edit_btn = new Proxmox.button.Button({
667 text: gettext('Edit'),
668 disabled: true,
669 selModel: sm,
670 handler: run_editor,
671 });
672
673 var run_btn = new Proxmox.button.Button({
674 text: gettext('Run now'),
675 disabled: true,
676 selModel: sm,
677 handler: function() {
678 var rec = sm.getSelection()[0];
679 if (!rec) {
680 return;
681 }
682
683 Ext.Msg.show({
684 title: gettext('Confirm'),
685 icon: Ext.Msg.QUESTION,
686 msg: gettext('Start the selected backup job now?'),
687 buttons: Ext.Msg.YESNO,
688 callback: function(btn) {
689 if (btn !== 'yes') {
690 return;
691 }
692 run_backup_now(rec.data);
693 },
694 });
695 },
696 });
697
698 var remove_btn = Ext.create('Proxmox.button.StdRemoveButton', {
699 selModel: sm,
700 baseurl: '/cluster/backup',
701 callback: function() {
702 reload();
703 },
704 });
705
706 var detail_btn = new Proxmox.button.Button({
707 text: gettext('Job Detail'),
708 disabled: true,
709 tooltip: gettext('Show job details and which guests and volumes are affected by the backup job'),
710 selModel: sm,
711 handler: run_detail,
712 });
713
714 noBackupJobWarning = Ext.create('Ext.toolbar.TextItem', {
715 html: '<i class="fa fa-fw fa-exclamation-circle"></i>' + gettext('Some guests are not covered by any backup job.'),
716 hidden: true,
717 });
718
719 noBackupJobInfoButton = new Proxmox.button.Button({
720 text: gettext('Show'),
721 hidden: true,
722 handler: run_show_not_backed,
723 });
724
725 Proxmox.Utils.monStoreErrors(me, store);
726
727 Ext.apply(me, {
728 store: store,
729 selModel: sm,
730 stateful: true,
731 stateId: 'grid-dc-backup',
732 viewConfig: {
733 trackOver: false,
734 },
735 tbar: [
736 {
737 text: gettext('Add'),
738 handler: function() {
739 var win = Ext.create('PVE.dc.BackupEdit', {});
740 win.on('destroy', reload);
741 win.show();
742 },
743 },
744 '-',
745 remove_btn,
746 edit_btn,
747 detail_btn,
748 '-',
749 run_btn,
750 '->',
751 noBackupJobWarning,
752 noBackupJobInfoButton,
753 '-',
754 {
755 xtype: 'proxmoxButton',
756 selModel: null,
757 text: gettext('Schedule Simulator'),
758 handler: () => {
759 let record = sm.getSelection()[0];
760 let schedule;
761 if (record) {
762 schedule = record.data.schedule;
763 }
764 Ext.create('PVE.window.ScheduleSimulator', {
765 schedule,
766 }).show();
767 },
768 },
769 ],
770 columns: [
771 {
772 header: gettext('Enabled'),
773 width: 80,
774 dataIndex: 'enabled',
775 xtype: 'checkcolumn',
776 sortable: true,
777 disabled: true,
778 disabledCls: 'x-item-enabled',
779 stopSelection: false,
780 },
781 {
782 header: gettext('ID'),
783 dataIndex: 'id',
784 hidden: true,
785 },
786 {
787 header: gettext('Node'),
788 width: 100,
789 sortable: true,
790 dataIndex: 'node',
791 renderer: function(value) {
792 if (value) {
793 return value;
794 }
795 return me.allText;
796 },
797 },
798 {
799 header: gettext('Schedule'),
800 width: 150,
801 dataIndex: 'schedule',
802 },
803 {
804 text: gettext('Next Run'),
805 dataIndex: 'next-run',
806 width: 150,
807 renderer: PVE.Utils.render_next_event,
808 },
809 {
810 header: gettext('Storage'),
811 width: 100,
812 sortable: true,
813 dataIndex: 'storage',
814 },
815 {
816 header: gettext('Comment'),
817 dataIndex: 'comment',
818 renderer: Ext.htmlEncode,
819 sorter: (a, b) => (a.data.comment || '').localeCompare(b.data.comment || ''),
820 flex: 1,
821 },
822 {
823 header: gettext('Retention'),
824 dataIndex: 'prune-backups',
825 renderer: v => v ? PVE.Parser.printPropertyString(v) : gettext('Fallback from storage config'),
826 flex: 2,
827 },
828 {
829 header: gettext('Selection'),
830 flex: 4,
831 sortable: false,
832 dataIndex: 'vmid',
833 renderer: PVE.Utils.render_backup_selection,
834 },
835 ],
836 listeners: {
837 activate: reload,
838 itemdblclick: run_editor,
839 },
840 });
841
842 me.callParent();
843 },
844 }, function() {
845 Ext.define('pve-cluster-backup', {
846 extend: 'Ext.data.Model',
847 fields: [
848 'id',
849 'compress',
850 'dow',
851 'exclude',
852 'mailto',
853 'mode',
854 'node',
855 'pool',
856 'prune-backups',
857 'starttime',
858 'storage',
859 'vmid',
860 { name: 'enabled', type: 'boolean' },
861 { name: 'all', type: 'boolean' },
862 ],
863 });
864 });