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