Files
dualfroz d033dfa51b fix #7623: ui: backup: only list guests of the selected pool
The guest list in the backup job editor filters the resource store by
the selected pool. With a plain string value, ExtJS matches any pool
that starts with it, ignoring case, so selecting 'Clients' also listed
the guests of 'Clients/Client1', 'Clients/Client2' and 'clients'.

The job itself only backs up the guests of the selected pool, so the
editor suggested that guests are covered which are not.

Match the pool id exactly and case-sensitively.

Signed-off-by: Michal Fox <me@dualfroz.com>
2026-09-25 10:04:45 +02:00

984 lines
36 KiB
JavaScript

Ext.define('PVE.dc.BackupEdit', {
extend: 'Proxmox.window.Edit',
alias: ['widget.pveDcBackupEdit'],
mixins: ['Proxmox.Mixin.CBind'],
defaultFocus: undefined,
subject: gettext('Backup Job'),
width: 720,
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;
}
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();
me.resetSearch();
store.clearFilter();
store.filterBy(function (rec) {
return !value || rec.get('node') === value;
});
if (value) {
let selModel = vmgrid.getSelectionModel();
let selections = selModel.getSelection();
let hiddenSelections = selections.filter((rec) => rec.get('node') !== value);
if (hiddenSelections.length > 0) {
selModel.deselect(hiddenSelections, true);
}
}
let mode = me.lookup('modeSelector').getValue();
if (mode === 'all') {
vmgrid.selModel.selectAll(true);
}
if (mode === 'pool') {
me.selectPoolMembers();
}
me.updateSelectionCount();
},
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 mode = me.lookup('modeSelector').getValue();
if (mode !== 'pool') {
return;
}
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,
// a plain string filter would also match child pools like 'poolid/child'
exactMatch: true,
caseSensitive: true,
},
]);
vmgrid.selModel.selectAll(true);
me.updateSelectionCount();
},
modeChange: function (f, value, oldValue) {
let me = this;
let vmgrid = me.lookup('vmgrid');
vmgrid.getStore().removeFilter('poolFilter');
me.resetSearch();
if (
(oldValue === 'all' && value !== 'all') ||
(oldValue === 'pool' && (value === 'include' || value === 'exclude'))
) {
vmgrid.getSelectionModel().deselectAll(true);
}
if (value === 'all') {
vmgrid.getSelectionModel().selectAll(true);
}
if (value === 'pool') {
me.selectPoolMembers();
}
me.updateSelectionCount();
},
compressionChange: function (f, value, oldValue) {
this.getView().lookup('backupAdvanced').updateCompression(value, f.isDisabled());
},
compressionDisable: function (f) {
this.getView().lookup('backupAdvanced').updateCompression(f.getValue(), true);
},
compressionEnable: function (f) {
this.getView().lookup('backupAdvanced').updateCompression(f.getValue(), false);
},
prepareValues: function (data) {
let me = this;
let viewModel = me.getViewModel();
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';
}
viewModel.set('selMode', data.selMode);
if (data['prune-backups']) {
Object.assign(data, data['prune-backups']);
delete data['prune-backups'];
}
if (data['notes-template']) {
data['notes-template'] = PVE.Utils.unEscapeNotesTemplate(data['notes-template']);
}
if (data.performance) {
Object.assign(data, data.performance);
delete data.performance;
}
return data;
},
resetSearch: function () {
this.lookup('searchField').setValue('');
},
selectionChange: function (_, selected) {
let me = this;
let store = me.lookup('vmgrid').getStore();
// re-apply the review filter to drop just-deselected rows from view
if (store.getFilters().contains(me.reviewFilter)) {
store.removeFilter(me.reviewFilter);
store.addFilter(me.reviewFilter);
}
me.updateSelectionCount(selected);
},
updateSelectionCount: function (selected) {
let me = this;
let selection = selected || me.lookup('vmgrid').getSelectionModel().getSelection();
let count = selection.length;
let label = me.lookup('selectionCount');
let text = Ext.String.format(gettext('Selected ({0})'), count);
label.setText(text);
},
reviewFn: function (record) {
let me = this;
return me.lookup('vmgrid').getSelectionModel().isSelected(record);
},
reviewModeChange: function (_, value) {
let me = this;
let store = me.lookup('vmgrid').getStore();
me.resetSearch();
if (value) {
store.addFilter(me.reviewFilter);
} else {
store.removeFilter(me.reviewFilter);
}
},
init: function (view) {
let me = this;
me.reviewFilter = new Ext.util.Filter({
id: 'vmgrid-review',
scope: me,
filterFn: me.reviewFn,
});
me.lookup('searchField').setTargetStore(me.lookup('vmgrid').getStore());
if (view.isCreate) {
me.lookup('modeSelector').setValue('include');
} else {
view.load({
success: function (response, _options) {
let values = me.prepareValues(response.result.data);
view.setValues(values);
},
});
}
},
},
viewModel: {
data: {
selMode: 'include',
},
formulas: {
poolMode: (get) => get('selMode') === 'pool',
disableVMSelection: (get) =>
get('selMode') !== 'include' && get('selMode') !== 'exclude',
selectionLabel: function (get) {
let mode = get('selMode');
if (mode === 'include') {
return gettext('Guests to Include');
} else if (mode === 'exclude') {
return gettext('Guests to Exclude');
}
return '';
},
},
},
items: [
{
xtype: 'tabpanel',
region: 'center',
layout: 'fit',
bodyPadding: 10,
items: [
{
title: gettext('General'),
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: 'pveBackupCompressionSelector',
reference: 'compressionSelector',
fieldLabel: gettext('Compression'),
name: 'compress',
cbind: {
deleteEmpty: '{!isCreate}',
},
value: 'zstd',
listeners: {
change: 'compressionChange',
disable: 'compressionDisable',
enable: 'compressionEnable',
},
},
{
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,
padding: '0 0 2 0',
name: 'vmid',
disabled: true,
allowBlank: false,
columnSelection: ['vmid', 'node', 'status', 'name', 'type'],
bind: {
disabled: '{disableVMSelection}',
},
listeners: {
selectionChange: 'selectionChange',
},
// override to keep selections hidden by the search/review filters
// and to return the joined string the backup API expects.
getValue: function () {
let me = this;
let selection = me.getSelectionModel().getSelection();
return selection.map((rec) => rec.get('vmid')).join(',');
},
tbar: [
{
xtype: 'tbtext',
bind: {
text: '{selectionLabel}',
hidden: '{!selectionLabel}',
},
},
'->',
gettext('Search') + ':',
' ',
{
xtype: 'pveRecordSearchField',
reference: 'searchField',
emptyText: gettext('Name, VMID, Type'),
searchFields: ['name', 'vmid', 'type'],
},
],
bbar: {
xtype: 'toolbar',
padding: '4 0',
items: [
{
xtype: 'tbtext',
reference: 'selectionCount',
text: Ext.String.format(gettext('Selected ({0})'), 0),
},
'->',
{
xtype: 'proxmoxcheckbox',
boxLabel: gettext('Review'),
submitValue: false,
listeners: {
change: 'reviewModeChange',
},
},
],
},
},
],
onGetValues: function (values) {
return this.up('window').getController().onGetValues(values);
},
},
{
xtype: 'pveBackupNotificationOptionsPanel',
title: gettext('Notifications'),
cbind: {
isCreate: '{isCreate}',
},
},
{
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',
onlineHelp: 'chapter_vzdump',
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.') +
'<br>' +
Ext.String.format(
gettext('Possible template variables are: {0}'),
PVE.Utils.notesTemplateVars
.map((v) => `<code>{{${v}}}</code>`)
.join(', '),
),
},
],
},
{
xtype: 'pveBackupAdvancedOptionsPanel',
onlineHelp: 'chapter_vzdump',
reference: 'backupAdvanced',
title: gettext('Advanced'),
cbind: {
isCreate: '{isCreate}',
},
},
],
},
],
});
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;
}
Ext.create('PVE.dc.BackupEdit', {
autoShow: true,
jobid: rec.data.id,
listeners: {
destroy: () => reload(),
},
});
};
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', 'fleecing'].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:<br />' + errors.join('<br />'),
);
}
}
};
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' },
],
});
},
);