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