Ext.define('PVE.dc.BackupEdit', { extend: 'Proxmox.window.Edit', alias: ['widget.pveDcBackupEdit'], mixins: ['Proxmox.Mixin.CBind'], defaultFocus: undefined, subject: gettext("Backup Job"), bodyPadding: 0, url: '/api2/extjs/cluster/backup', method: 'POST', isCreate: true, cbindData: function() { let me = this; if (me.jobid) { me.isCreate = false; me.method = 'PUT'; me.url += `/${me.jobid}`; } return {}; }, controller: { xclass: 'Ext.app.ViewController', onGetValues: function(values) { let me = this; let isCreate = me.getView().isCreate; if (!values.node) { if (!isCreate) { Proxmox.Utils.assemble_field_data(values, { 'delete': 'node' }); } delete values.node; } if (!values.id && isCreate) { values.id = 'backup-' + Ext.data.identifier.Uuid.Global.generate().slice(0, 13); } let selMode = values.selMode; delete values.selMode; if (selMode === 'all') { values.all = 1; values.exclude = ''; delete values.vmid; } else if (selMode === 'exclude') { values.all = 1; values.exclude = values.vmid; delete values.vmid; } else if (selMode === 'pool') { delete values.vmid; } if (selMode !== 'pool') { delete values.pool; } return values; }, nodeChange: function(f, value) { let me = this; me.lookup('storageSelector').setNodename(value); let vmgrid = me.lookup('vmgrid'); let store = vmgrid.getStore(); store.clearFilter(); store.filterBy(function(rec) { return !value || rec.get('node') === value; }); let mode = me.lookup('modeSelector').getValue(); if (mode === 'all') { vmgrid.selModel.selectAll(true); } if (mode === 'pool') { me.selectPoolMembers(); } }, storageChange: function(f, v) { let me = this; let rec = f.getStore().findRecord('storage', v, 0, false, true, true); let compressionSelector = me.lookup('compressionSelector'); if (rec?.data?.type === 'pbs') { compressionSelector.setValue('zstd'); compressionSelector.setDisabled(true); } else if (!compressionSelector.getEditable()) { compressionSelector.setDisabled(false); } }, selectPoolMembers: function() { let me = this; let vmgrid = me.lookup('vmgrid'); let poolid = me.lookup('poolSelector').getValue(); vmgrid.getSelectionModel().deselectAll(true); if (!poolid) { return; } vmgrid.getStore().filter([ { id: 'poolFilter', property: 'pool', value: poolid, }, ]); vmgrid.selModel.selectAll(true); }, modeChange: function(f, value, oldValue) { let me = this; let vmgrid = me.lookup('vmgrid'); vmgrid.getStore().removeFilter('poolFilter'); if (oldValue === 'all' && value !== 'all') { vmgrid.getSelectionModel().deselectAll(true); } if (value === 'all') { vmgrid.getSelectionModel().selectAll(true); } if (value === 'pool') { me.selectPoolMembers(); } }, init: function(view) { let me = this; if (view.isCreate) { me.lookup('modeSelector').setValue('include'); } else { view.load({ success: function(response, _options) { let data = response.result.data; if (data.exclude) { data.vmid = data.exclude; data.selMode = 'exclude'; } else if (data.all) { data.vmid = ''; data.selMode = 'all'; } else if (data.pool) { data.selMode = 'pool'; data.selPool = data.pool; } else { data.selMode = 'include'; } me.getViewModel().set('selMode', data.selMode); if (data['prune-backups']) { Object.assign(data, data['prune-backups']); delete data['prune-backups']; } else if (data.maxfiles !== undefined) { if (data.maxfiles > 0) { data['keep-last'] = data.maxfiles; } else { data['keep-all'] = 1; } delete data.maxfiles; } if (data['notes-template']) { data['notes-template'] = PVE.Utils.unEscapeNotesTemplate(data['notes-template']); } view.setValues(data); }, }); } }, }, viewModel: { data: { selMode: 'include', }, formulas: { poolMode: (get) => get('selMode') === 'pool', disableVMSelection: (get) => get('selMode') !== 'include' && get('selMode') !== 'exclude', }, }, items: [ { xtype: 'tabpanel', region: 'center', layout: 'fit', bodyPadding: 10, items: [ { xtype: 'container', title: gettext('General'), region: 'center', layout: { type: 'vbox', align: 'stretch', }, items: [ { xtype: 'inputpanel', onlineHelp: 'chapter_vzdump', column1: [ { xtype: 'pveNodeSelector', name: 'node', fieldLabel: gettext('Node'), allowBlank: true, editable: true, autoSelect: false, emptyText: '-- ' + gettext('All') + ' --', listeners: { change: 'nodeChange', }, }, { xtype: 'pveStorageSelector', reference: 'storageSelector', fieldLabel: gettext('Storage'), clusterView: true, storageContent: 'backup', allowBlank: false, name: 'storage', listeners: { change: 'storageChange', }, }, { xtype: 'pveCalendarEvent', fieldLabel: gettext('Schedule'), allowBlank: false, name: 'schedule', }, { xtype: 'proxmoxKVComboBox', reference: 'modeSelector', comboItems: [ ['include', gettext('Include selected VMs')], ['all', gettext('All')], ['exclude', gettext('Exclude selected VMs')], ['pool', gettext('Pool based')], ], fieldLabel: gettext('Selection mode'), name: 'selMode', value: '', bind: { value: '{selMode}', }, listeners: { change: 'modeChange', }, }, { xtype: 'pvePoolSelector', reference: 'poolSelector', fieldLabel: gettext('Pool to backup'), hidden: true, allowBlank: false, name: 'pool', listeners: { change: 'selectPoolMembers', }, bind: { hidden: '{!poolMode}', disabled: '{!poolMode}', }, }, ], column2: [ { xtype: 'textfield', fieldLabel: gettext('Send email to'), name: 'mailto', }, { xtype: 'pveEmailNotificationSelector', fieldLabel: gettext('Email'), name: 'mailnotification', cbind: { value: (get) => get('isCreate') ? 'always' : '', deleteEmpty: '{!isCreate}', }, }, { xtype: 'pveCompressionSelector', reference: 'compressionSelector', fieldLabel: gettext('Compression'), name: 'compress', cbind: { deleteEmpty: '{!isCreate}', }, value: 'zstd', }, { xtype: 'pveBackupModeSelector', fieldLabel: gettext('Mode'), value: 'snapshot', name: 'mode', }, { xtype: 'proxmoxcheckbox', fieldLabel: gettext('Enable'), name: 'enabled', uncheckedValue: 0, defaultValue: 1, checked: true, }, ], columnB: [ { xtype: 'proxmoxtextfield', name: 'comment', fieldLabel: gettext('Job Comment'), cbind: { deleteEmpty: '{!isCreate}', }, autoEl: { tag: 'div', 'data-qtip': gettext('Description of the job'), }, }, { xtype: 'vmselector', reference: 'vmgrid', height: 300, name: 'vmid', disabled: true, allowBlank: false, columnSelection: ['vmid', 'node', 'status', 'name', 'type'], bind: { disabled: '{disableVMSelection}', }, }, ], advancedColumn1: [ { xtype: 'proxmoxcheckbox', fieldLabel: gettext('Repeat missed'), name: 'repeat-missed', uncheckedValue: 0, defaultValue: 0, cbind: { deleteDefaultValue: '{!isCreate}', }, }, ], onGetValues: function(values) { return this.up('window').getController().onGetValues(values); }, }, ], }, { xtype: 'pveBackupJobPrunePanel', title: gettext('Retention'), cbind: { isCreate: '{isCreate}', }, keepAllDefaultForCreate: false, showPBSHint: false, fallbackHintHtml: gettext('Without any keep option, the storage\'s configuration or node\'s vzdump.conf is used as fallback'), }, { xtype: 'inputpanel', title: gettext('Note Template'), region: 'center', layout: { type: 'vbox', align: 'stretch', }, onGetValues: function(values) { if (values['notes-template']) { values['notes-template'] = PVE.Utils.escapeNotesTemplate(values['notes-template']); } return values; }, items: [ { xtype: 'textarea', name: 'notes-template', fieldLabel: gettext('Backup Notes'), height: 100, maxLength: 512, cbind: { deleteEmpty: '{!isCreate}', value: (get) => get('isCreate') ? '{{guestname}}' : undefined, }, }, { xtype: 'box', style: { margin: '8px 0px', 'line-height': '1.5em', }, html: gettext('The notes are added to each backup created by this job.') + '
' + Ext.String.format( gettext('Possible template variables are: {0}'), PVE.Utils.notesTemplateVars.map(v => `{{${v}}}`).join(', '), ), }, ], }, ], }, ], }); Ext.define('PVE.dc.BackupView', { extend: 'Ext.grid.GridPanel', alias: ['widget.pveDcBackupView'], onlineHelp: 'chapter_vzdump', allText: '-- ' + gettext('All') + ' --', initComponent: function() { let me = this; let store = new Ext.data.Store({ model: 'pve-cluster-backup', proxy: { type: 'proxmox', url: "/api2/json/cluster/backup", }, }); let not_backed_store = new Ext.data.Store({ sorters: 'vmid', proxy: { type: 'proxmox', url: 'api2/json/cluster/backup-info/not-backed-up', }, }); let noBackupJobInfoButton; let reload = function() { store.load(); not_backed_store.load({ callback: records => noBackupJobInfoButton.setVisible(records.length > 0), }); }; let sm = Ext.create('Ext.selection.RowModel', {}); let run_editor = function() { let rec = sm.getSelection()[0]; if (!rec) { return; } let win = Ext.create('PVE.dc.BackupEdit', { jobid: rec.data.id, }); win.on('destroy', reload); win.show(); }; let run_detail = function() { let record = sm.getSelection()[0]; if (!record) { return; } Ext.create('Ext.window.Window', { modal: true, width: 800, height: Ext.getBody().getViewSize().height > 1000 ? 800 : 600, // factor out as common infra? resizable: true, layout: 'fit', title: gettext('Backup Details'), items: [ { xtype: 'panel', region: 'center', layout: { type: 'vbox', align: 'stretch', }, items: [ { xtype: 'pveBackupInfo', flex: 0, layout: 'fit', record: record.data, }, { xtype: 'pveBackupDiskTree', title: gettext('Included disks'), flex: 1, jobid: record.data.id, }, ], }, ], }).show(); }; let run_backup_now = function(job) { job = Ext.clone(job); let jobNode = job.node; // Remove properties related to scheduling delete job.enabled; delete job.starttime; delete job.dow; delete job.id; delete job.schedule; delete job.type; delete job.node; delete job.comment; delete job['next-run']; delete job['repeat-missed']; job.all = job.all === true ? 1 : 0; ['performance', 'prune-backups'].forEach(key => { if (job[key]) { job[key] = PVE.Parser.printPropertyString(job[key]); } }); let allNodes = PVE.data.ResourceStore.getNodes(); let nodes = allNodes.filter(node => node.status === 'online').map(node => node.node); let errors = []; if (jobNode !== undefined) { if (!nodes.includes(jobNode)) { Ext.Msg.alert('Error', "Node '"+ jobNode +"' from backup job isn't online!"); return; } nodes = [jobNode]; } else { let unkownNodes = allNodes.filter(node => node.status !== 'online'); if (unkownNodes.length > 0) {errors.push(unkownNodes.map(node => node.node + ": " + gettext("Node is offline")));} } let jobTotalCount = nodes.length, jobsStarted = 0; Ext.Msg.show({ title: gettext('Please wait...'), closable: false, progress: true, progressText: '0/' + jobTotalCount, }); let postRequest = function() { jobsStarted++; Ext.Msg.updateProgress(jobsStarted / jobTotalCount, jobsStarted + '/' + jobTotalCount); if (jobsStarted === jobTotalCount) { Ext.Msg.hide(); if (errors.length > 0) { Ext.Msg.alert('Error', 'Some errors have been encountered:
' + errors.join('
')); } } }; nodes.forEach(node => Proxmox.Utils.API2Request({ url: '/nodes/' + node + '/vzdump', method: 'POST', params: job, failure: function(response, opts) { errors.push(node + ': ' + response.htmlStatus); postRequest(); }, success: postRequest, })); }; var edit_btn = new Proxmox.button.Button({ text: gettext('Edit'), disabled: true, selModel: sm, handler: run_editor, }); var run_btn = new Proxmox.button.Button({ text: gettext('Run now'), disabled: true, selModel: sm, handler: function() { var rec = sm.getSelection()[0]; if (!rec) { return; } Ext.Msg.show({ title: gettext('Confirm'), icon: Ext.Msg.QUESTION, msg: gettext('Start the selected backup job now?'), buttons: Ext.Msg.YESNO, callback: function(btn) { if (btn !== 'yes') { return; } run_backup_now(rec.data); }, }); }, }); var remove_btn = Ext.create('Proxmox.button.StdRemoveButton', { selModel: sm, baseurl: '/cluster/backup', callback: function() { reload(); }, }); var detail_btn = new Proxmox.button.Button({ text: gettext('Job Detail'), disabled: true, tooltip: gettext('Show job details and which guests and volumes are affected by the backup job'), selModel: sm, handler: run_detail, }); noBackupJobInfoButton = new Proxmox.button.Button({ text: `${gettext('Show')}: ${gettext('Guests Without Backup Job')}`, tooltip: gettext('Some guests are not covered by any backup job.'), iconCls: 'fa fa-fw fa-exclamation-circle', hidden: true, handler: () => { Ext.create('Ext.window.Window', { autoShow: true, modal: true, width: 600, height: 500, resizable: true, layout: 'fit', title: gettext('Guests Without Backup Job'), items: [ { xtype: 'panel', region: 'center', layout: { type: 'vbox', align: 'stretch', }, items: [ { xtype: 'pveBackedGuests', flex: 1, layout: 'fit', store: not_backed_store, }, ], }, ], }); }, }); Proxmox.Utils.monStoreErrors(me, store); Ext.apply(me, { store: store, selModel: sm, stateful: true, stateId: 'grid-dc-backup', viewConfig: { trackOver: false, }, dockedItems: [{ xtype: 'toolbar', overflowHandler: 'scroller', dock: 'top', items: [ { text: gettext('Add'), handler: function() { var win = Ext.create('PVE.dc.BackupEdit', {}); win.on('destroy', reload); win.show(); }, }, '-', remove_btn, edit_btn, detail_btn, '-', run_btn, '->', noBackupJobInfoButton, '-', { xtype: 'proxmoxButton', selModel: null, text: gettext('Schedule Simulator'), handler: () => { let record = sm.getSelection()[0]; let schedule; if (record) { schedule = record.data.schedule; } Ext.create('PVE.window.ScheduleSimulator', { autoShow: true, schedule, }); }, }, ], }], columns: [ { header: gettext('Enabled'), width: 80, dataIndex: 'enabled', align: 'center', renderer: Proxmox.Utils.renderEnabledIcon, sortable: true, }, { header: gettext('ID'), dataIndex: 'id', hidden: true, }, { header: gettext('Node'), width: 100, sortable: true, dataIndex: 'node', renderer: function(value) { if (value) { return value; } return me.allText; }, }, { header: gettext('Schedule'), width: 150, dataIndex: 'schedule', }, { text: gettext('Next Run'), dataIndex: 'next-run', width: 150, renderer: PVE.Utils.render_next_event, }, { header: gettext('Storage'), width: 100, sortable: true, dataIndex: 'storage', }, { header: gettext('Comment'), dataIndex: 'comment', renderer: Ext.htmlEncode, sorter: (a, b) => (a.data.comment || '').localeCompare(b.data.comment || ''), flex: 1, }, { header: gettext('Retention'), dataIndex: 'prune-backups', renderer: v => v ? PVE.Parser.printPropertyString(v) : gettext('Fallback from storage config'), flex: 2, }, { header: gettext('Selection'), flex: 4, sortable: false, dataIndex: 'vmid', renderer: PVE.Utils.render_backup_selection, }, ], listeners: { activate: reload, itemdblclick: run_editor, }, }); me.callParent(); }, }, function() { Ext.define('pve-cluster-backup', { extend: 'Ext.data.Model', fields: [ 'id', 'compress', 'dow', 'exclude', 'mailto', 'mode', 'node', 'pool', 'prune-backups', 'starttime', 'storage', 'vmid', { name: 'enabled', type: 'boolean' }, { name: 'all', type: 'boolean' }, ], }); });