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