isAdd: true,
subject: gettext('Repository'),
+ width: 600,
initComponent: function() {
let me = this;
name: 'handle',
allowBlank: false,
comboItems: me.repoInfo.map(info => [info.handle, info.name]),
- isValid: function() {
- const handle = this.value;
+ validator: function(renderedValue) {
+ let handle = this.value;
+ // we cannot use this.callParent in instantiations
+ let valid = Proxmox.form.KVComboBox.prototype.validator.call(this, renderedValue);
- if (!handle) {
+ if (!valid || !handle) {
return false;
}
const info = me.repoInfo.find(elem => elem.handle === handle);
-
if (!info) {
return false;
}
- // not yet configured
- return info.status === undefined || info.status === null;
+ if (info.status) {
+ return Ext.String.format(gettext('{0} is already configured'), renderedValue);
+ }
+ return valid;
},
listeners: {
change: function(f, value) {
repoSelector.setValue(me.repoInfo[0].handle);
- let items = [
- repoSelector,
- description,
- status,
- ];
-
Ext.apply(me, {
- items: items,
+ items: [
+ repoSelector,
+ description,
+ status,
+ ],
repoSelector: repoSelector,
});
xtype: 'proxmoxNodeAPTRepositoriesErrors',
- title: gettext('Errors'),
-
store: {},
- border: false,
+ scrollable: true,
viewConfig: {
stripeRows: false,
- getRowClass: () => 'proxmox-invalid-row',
+ getRowClass: (record) => {
+ switch (record.data.status) {
+ case 'warning': return 'proxmox-warning-row';
+ case 'critical': return 'proxmox-invalid-row';
+ default: return '';
+ }
+ },
},
+ hideHeaders: true,
+
columns: [
{
- header: gettext('File'),
- dataIndex: 'path',
- renderer: value => `<i class='pve-grid-fa fa fa-fw fa-exclamation-triangle'></i>${value}`,
- width: 350,
+ dataIndex: 'status',
+ renderer: (value) => `<i class="fa fa-fw ${Proxmox.Utils.get_health_icon(value, true)}"></i>`,
+ width: 50,
},
{
- header: gettext('Error'),
- dataIndex: 'error',
+ dataIndex: 'message',
flex: 1,
},
],
Ext.define('Proxmox.node.APTRepositoriesGrid', {
extend: 'Ext.grid.GridPanel',
xtype: 'proxmoxNodeAPTRepositoriesGrid',
+ mixins: ['Proxmox.Mixin.CBind'],
title: gettext('APT Repositories'),
},
{
text: gettext('Add'),
- id: 'addButton',
+ name: 'addRepo',
disabled: true,
repoInfo: undefined,
+ cbind: {
+ onlineHelp: '{onlineHelp}',
+ },
handler: function(button, event, record) {
Proxmox.Utils.checked_command(() => {
let me = this;
Ext.create('Proxmox.window.APTRepositoryAdd', {
repoInfo: me.repoInfo,
- url: `/api2/json/nodes/${panel.nodename}/apt/repositories`,
+ url: `/api2/extjs/nodes/${panel.nodename}/apt/repositories`,
method: 'PUT',
extraRequestParams: extraParams,
+ onlineHelp: me.onlineHelp,
listeners: {
destroy: function() {
panel.reload();
},
'-',
{
- xtype: 'proxmoxButton',
- text: gettext('Enable'),
+ xtype: 'proxmoxAltTextButton',
defaultText: gettext('Enable'),
altText: gettext('Disable'),
- id: 'repoEnableButton',
+ name: 'repoEnable',
disabled: true,
+ bind: {
+ text: '{enableButtonText}',
+ },
handler: function(button, event, record) {
let me = this;
let panel = me.up('proxmoxNodeAPTRepositories');
},
});
},
- listeners: {
- render: function(btn) {
- // HACK: calculate the max button width on first render to avoid toolbar glitches
- let defSize = btn.getSize().width;
-
- btn.setText(btn.altText);
- let altSize = btn.getSize().width;
-
- btn.setText(btn.defaultText);
- btn.setSize({ width: altSize > defSize ? altSize : defSize });
- },
- },
},
],
sortableColumns: false,
+ viewConfig: {
+ stripeRows: false,
+ getRowClass: (record, index) => record.get('Enabled') ? '' : 'proxmox-disabled-row',
+ },
columns: [
{
- xtype: 'checkcolumn',
header: gettext('Enabled'),
dataIndex: 'Enabled',
- listeners: {
- beforecheckchange: () => false, // veto, we don't want to allow inline change - to subtle
- },
+ align: 'center',
+ renderer: Proxmox.Utils.renderEnabledIcon,
width: 90,
},
{
{
header: gettext('Suites'),
dataIndex: 'Suites',
- renderer: function(suites, cell, record) {
- return suites.join(' ');
+ renderer: function(suites, metaData, record) {
+ let err = '';
+ if (record.data.warnings && record.data.warnings.length > 0) {
+ let txt = [gettext('Warning')];
+ record.data.warnings.forEach((warning) => {
+ if (warning.property === 'Suites') {
+ txt.push(warning.message);
+ }
+ });
+ metaData.tdAttr = `data-qtip="${Ext.htmlEncode(txt.join('<br>'))}"`;
+ if (record.data.Enabled) {
+ metaData.tdCls = 'proxmox-invalid-row';
+ err = '<i class="fa fa-fw critical fa-exclamation-circle"></i> ';
+ } else {
+ metaData.tdCls = 'proxmox-warning-row';
+ err = '<i class="fa fa-fw warning fa-exclamation-circle"></i> ';
+ }
+ }
+ return suites.join(' ') + err;
},
width: 130,
},
{
header: gettext('Components'),
dataIndex: 'Components',
- renderer: function(components, cell, record) {
- return components.join(' ');
+ renderer: function(components, metaData, record) {
+ if (components === undefined) {
+ return '';
+ }
+ let err = '';
+ if (components.length === 1) {
+ // FIXME: this should be a flag set to the actual repsotiories, i.e., a tristate
+ // like production-ready = <yes|no|other> (Option<bool>)
+ if (components[0].match(/\w+(-no-subscription|test)\s*$/i)) {
+ metaData.tdCls = 'proxmox-warning-row';
+ err = '<i class="fa fa-fw warning fa-exclamation-circle"></i> ';
+
+ let qtip = components[0].match(/no-subscription/)
+ ? gettext('The no-subscription repository is NOT production-ready')
+ : gettext('The test repository may contain unstable updates')
+ ;
+ metaData.tdAttr = `data-qtip="${Ext.htmlEncode(qtip)}"`;
+ }
+ }
+ return components.join(' ') + err;
},
width: 170,
},
let values = option.Values.join(' ');
text += `${key}: ${values}<br>`;
} else {
- throw "unkown file type";
+ throw "unknown file type";
}
});
return text;
header: gettext('Origin'),
dataIndex: 'Origin',
width: 120,
- renderer: (value, meta, rec) => {
+ renderer: function(value, meta, rec) {
+ if (typeof value !== 'string' || value.length === 0) {
+ value = gettext('Other');
+ }
let cls = 'fa fa-fw fa-question-circle-o';
- if (value.match(/^\s*Proxmox\s*$/i)) {
+ let originType = this.up('proxmoxNodeAPTRepositories').classifyOrigin(value);
+ if (originType === 'Proxmox') {
cls = 'pmx-itype-icon pmx-itype-icon-proxmox-x';
- } else if (value.match(/^\s*Debian\s*$/i)) {
+ } else if (originType === 'Debian') {
cls = 'pmx-itype-icon pmx-itype-icon-debian-swirl';
}
return `<i class='${cls}'></i> ${value}`;
header: gettext('Comment'),
dataIndex: 'Comment',
flex: 2,
+ renderer: Ext.String.htmlEncode,
},
],
- addAdditionalInfos: function(gridData, infos) {
- let me = this;
-
- let warnings = {};
- let origins = {};
-
- let addLine = function(obj, key, line) {
- if (obj[key]) {
- obj[key] += "\n";
- obj[key] += line;
- } else {
- obj[key] = line;
- }
- };
-
- for (const info of infos) {
- const key = `${info.path}:${info.index}`;
- if (info.kind === 'warning' ||
- (info.kind === 'ignore-pre-upgrade-warning' && !me.majorUpgradeAllowed)
- ) {
- addLine(warnings, key, gettext('Warning') + ": " + info.message);
- } else if (info.kind === 'origin') {
- origins[key] = info.message;
- }
- }
-
- gridData.forEach(function(record) {
- const key = `${record.Path}:${record.Index}`;
- record.Origin = origins[key];
- });
-
- me.rowBodyFeature.getAdditionalData = function(innerData, rowIndex, record, orig) {
- let headerCt = this.view.headerCt;
- let colspan = headerCt.getColumnCount();
-
- const key = `${innerData.Path}:${innerData.Index}`;
- const warning_text = warnings[key];
+ features: [
+ {
+ ftype: 'grouping',
+ groupHeaderTpl: '{[ "File: " + values.name ]} ({rows.length} repositor{[values.rows.length > 1 ? "ies" : "y"]})',
+ enableGroupingMenu: false,
+ },
+ ],
- return {
- rowBody: '<div style="color: red; white-space: pre-line">' +
- Ext.String.htmlEncode(warning_text) + '</div>',
- rowBodyCls: warning_text ? '' : Ext.baseCSSPrefix + 'grid-row-body-hidden',
- rowBodyColspan: colspan,
- };
- };
+ store: {
+ model: 'apt-repolist',
+ groupField: 'Path',
+ sorters: [
+ {
+ property: 'Index',
+ direction: 'ASC',
+ },
+ ],
},
initComponent: function() {
throw "no node name specified";
}
- let store = Ext.create('Ext.data.Store', {
- model: 'apt-repolist',
- groupField: 'Path',
- sorters: [
- {
- property: 'Index',
- direction: 'ASC',
- },
- ],
- });
-
- let rowBodyFeature = Ext.create('Ext.grid.feature.RowBody', {});
-
- let groupingFeature = Ext.create('Ext.grid.feature.Grouping', {
- groupHeaderTpl: '{[ "File: " + values.name ]} ({rows.length} ' +
- 'repositor{[values.rows.length > 1 ? "ies" : "y"]})',
- enableGroupingMenu: false,
- });
-
- let sm = Ext.create('Ext.selection.RowModel', {});
-
- Ext.apply(me, {
- store: store,
- selModel: sm,
- rowBodyFeature: rowBodyFeature,
- features: [groupingFeature, rowBodyFeature],
- });
-
me.callParent();
},
-
- listeners: {
- selectionchange: function() {
- let me = this;
-
- if (me.onSelectionChange) {
- let sm = me.getSelectionModel();
- let rec = sm.getSelection()[0];
-
- me.onSelectionChange(rec, sm);
- }
- },
- },
});
Ext.define('Proxmox.node.APTRepositories', {
digest: undefined,
+ onlineHelp: undefined,
+
product: 'Proxmox VE', // default
- viewModel: {
- data: {
- product: 'Proxmox VE', // default
- errorCount: 0,
- subscriptionActive: '',
- noSubscriptionRepo: '',
- enterpriseRepo: '',
+ classifyOrigin: function(origin) {
+ origin ||= '';
+ if (origin.match(/^\s*Proxmox\s*$/i)) {
+ return 'Proxmox';
+ } else if (origin.match(/^\s*Debian\s*(:?Backports)?$/i)) {
+ return 'Debian';
+ }
+ return 'Other';
+ },
+
+ controller: {
+ xclass: 'Ext.app.ViewController',
+
+ selectionChange: function(grid, selection) {
+ let me = this;
+ if (!selection || selection.length < 1) {
+ return;
+ }
+ let rec = selection[0];
+ let vm = me.getViewModel();
+ vm.set('selectionenabled', rec.get('Enabled'));
+ vm.notify();
},
- formulas: {
- noErrors: (get) => get('errorCount') === 0,
- mainWarning: function(get) {
- // Not yet initialized
- if (get('subscriptionActive') === '' ||
- get('enterpriseRepo') === '') {
- return '';
+
+ updateState: function() {
+ let me = this;
+ let vm = me.getViewModel();
+
+ let store = vm.get('errorstore');
+ store.removeAll();
+
+ let status = 'good'; // start with best, the helper below will downgrade if needed
+ let text = gettext('All OK, you have production-ready repositories configured!');
+
+ let addGood = message => store.add({ status: 'good', message });
+ let addWarn = (message, important) => {
+ if (status !== 'critical') {
+ status = 'warning';
+ text = important ? message : gettext('Warning');
}
+ store.add({ status: 'warning', message });
+ };
+ let addCritical = (message, important) => {
+ status = 'critical';
+ text = important ? message : gettext('Error');
+ store.add({ status: 'critical', message });
+ };
- let icon = `<i class='fa fa-fw fa-exclamation-triangle critical'></i>`;
- let fmt = (msg) => `<div class="black">${icon}${gettext('Warning')}: ${msg}</div>`;
+ let errors = vm.get('errors');
+ errors.forEach(error => addCritical(`${error.path} - ${error.error}`));
+
+ let activeSubscription = vm.get('subscriptionActive');
+ let enterprise = vm.get('enterpriseRepo');
+ let nosubscription = vm.get('noSubscriptionRepo');
+ let test = vm.get('testRepo');
+ let cephRepos = {
+ enterprise: vm.get('cephEnterpriseRepo'),
+ nosubscription: vm.get('cephNoSubscriptionRepo'),
+ test: vm.get('cephTestRepo'),
+ };
+ let wrongSuites = vm.get('suitesWarning');
+ let mixedSuites = vm.get('mixedSuites');
+
+ if (!enterprise && !nosubscription && !test) {
+ addCritical(
+ Ext.String.format(gettext('No {0} repository is enabled, you do not get any updates!'), vm.get('product')),
+ );
+ } else if (errors.length > 0) {
+ // nothing extra, just avoid that we show "get updates"
+ } else if (enterprise && !nosubscription && !test && activeSubscription) {
+ addGood(Ext.String.format(gettext('You get supported updates for {0}'), vm.get('product')));
+ } else if (nosubscription || test) {
+ addGood(Ext.String.format(gettext('You get updates for {0}'), vm.get('product')));
+ }
- if (!get('subscriptionActive') && get('enterpriseRepo')) {
- return fmt(gettext('The enterprise repository is enabled, but there is no active subscription!'));
+ if (wrongSuites) {
+ addWarn(gettext('Some suites are misconfigured'));
+ }
+
+ if (mixedSuites) {
+ addWarn(gettext('Detected mixed suites before upgrade'));
+ }
+
+ let productionReadyCheck = (repos, type, noSubAlternateName) => {
+ if (!activeSubscription && repos.enterprise) {
+ addWarn(Ext.String.format(
+ gettext('The {0}enterprise repository is enabled, but there is no active subscription!'),
+ type,
+ ));
}
- if (get('noSubscriptionRepo')) {
- return fmt(gettext('The no-subscription repository is not recommended for production use!'));
+ if (repos.nosubscription) {
+ addWarn(Ext.String.format(
+ gettext('The {0}no-subscription{1} repository is not recommended for production use!'),
+ type,
+ noSubAlternateName,
+ ));
}
- if (!get('enterpriseRepo') && !get('noSubscriptionRepo')) {
- let msg = Ext.String.format(gettext('No {0} repository is enabled!'), get('product'));
- return fmt(msg);
+ if (repos.test) {
+ addWarn(Ext.String.format(
+ gettext('The {0}test repository may pull in unstable updates and is not recommended for production use!'),
+ type,
+ ));
}
+ };
- return '';
- },
+ productionReadyCheck({ enterprise, nosubscription, test }, '', '');
+ // TODO drop alternate 'main' name when no longer relevant
+ productionReadyCheck(cephRepos, 'Ceph ', '/main');
+
+ if (errors.length > 0) {
+ text = gettext('Fatal parsing error for at least one repository');
+ }
+
+ let iconCls = Proxmox.Utils.get_health_icon(status, true);
+
+ vm.set('state', {
+ iconCls,
+ text,
+ });
},
},
- items: [
- {
- xtype: 'header',
- baseCls: 'x-panel-header',
- bind: {
- hidden: '{!mainWarning}',
- title: '{mainWarning}',
- },
+ viewModel: {
+ data: {
+ product: 'Proxmox VE', // default
+ errors: [],
+ suitesWarning: false,
+ mixedSuites: false, // used before major upgrade
+ subscriptionActive: '',
+ noSubscriptionRepo: '',
+ enterpriseRepo: '',
+ testRepo: '',
+ cephEnterpriseRepo: '',
+ cephNoSubscriptionRepo: '',
+ cephTestRepo: '',
+ selectionenabled: false,
+ state: {},
},
- {
- xtype: 'box',
- bind: {
- hidden: '{!mainWarning}',
+ formulas: {
+ enableButtonText: (get) => get('selectionenabled')
+ ? gettext('Disable') : gettext('Enable'),
+ },
+ stores: {
+ errorstore: {
+ fields: ['status', 'message'],
},
- height: 5,
},
+ },
+
+ scrollable: true,
+ layout: {
+ type: 'vbox',
+ align: 'stretch',
+ },
+
+ items: [
{
- xtype: 'proxmoxNodeAPTRepositoriesErrors',
- name: 'repositoriesErrors',
- hidden: true,
- padding: '0 0 5 0',
- bind: {
- hidden: '{noErrors}',
+ xtype: 'panel',
+ border: false,
+ layout: {
+ type: 'hbox',
+ align: 'stretch',
},
+ height: 200,
+ title: gettext('Status'),
+ items: [
+ {
+ xtype: 'box',
+ flex: 2,
+ margin: 10,
+ data: {
+ iconCls: Proxmox.Utils.get_health_icon(undefined, true),
+ text: '',
+ },
+ bind: {
+ data: '{state}',
+ },
+ tpl: [
+ '<center class="centered-flex-column" style="font-size:15px;line-height: 25px;">',
+ '<i class="fa fa-4x {iconCls}"></i>',
+ '{text}',
+ '</center>',
+ ],
+ },
+ {
+ xtype: 'proxmoxNodeAPTRepositoriesErrors',
+ name: 'repositoriesErrors',
+ flex: 7,
+ margin: 10,
+ bind: {
+ store: '{errorstore}',
+ },
+ },
+ ],
},
{
xtype: 'proxmoxNodeAPTRepositoriesGrid',
name: 'repositoriesGrid',
+ flex: 1,
cbind: {
nodename: '{nodename}',
+ onlineHelp: '{onlineHelp}',
},
majorUpgradeAllowed: false, // TODO get release information from an API call?
- onSelectionChange: function(rec, sm) {
- let me = this;
- if (rec) {
- let btn = me.up('proxmoxNodeAPTRepositories').down('#repoEnableButton');
- btn.setText(rec.get('Enabled') ? gettext('Disable') : gettext('Enable'));
- }
+ listeners: {
+ selectionchange: 'selectionChange',
},
},
],
const res = response.result;
const subscription = !(!res || !res.data || res.data.status.toLowerCase() !== 'active');
vm.set('subscriptionActive', subscription);
+ me.getController().updateState();
},
});
},
let me = this;
let vm = me.getViewModel();
- let addButton = me.down('#addButton');
- addButton.repoInfo = [];
+ let addButton = me.down('button[name=addRepo]');
+ addButton.repoInfo = [];
for (const standardRepo of standardRepos) {
const handle = standardRepo.handle;
const status = standardRepo.status;
vm.set('enterpriseRepo', status);
} else if (handle === "no-subscription") {
vm.set('noSubscriptionRepo', status);
+ } else if (handle === 'test') {
+ vm.set('testRepo', status);
+ } else if (handle.match(/^ceph-[a-zA-Z]+-enterprise$/)) {
+ vm.set('cephEnterpriseRepo', status);
+ } else if (handle.match(/^ceph-[a-zA-Z]+-no-subscription$/)) {
+ vm.set('cephNoSubscriptionRepo', status);
+ } else if (handle.match(/^ceph-[a-zA-Z]+-test$/)) {
+ vm.set('cephTestRepo', status);
}
+ me.getController().updateState();
addButton.repoInfo.push(standardRepo);
addButton.digest = me.digest;
let me = this;
let vm = me.getViewModel();
let repoGrid = me.down('proxmoxNodeAPTRepositoriesGrid');
- let errorGrid = me.down('proxmoxNodeAPTRepositoriesErrors');
me.store.load(function(records, operation, success) {
let gridData = [];
let errors = [];
let digest;
+ let suitesWarning = false;
+
+ // Usually different suites will give errors anyways, but before a major upgrade the
+ // current and the next suite are allowed, so it makes sense to check for mixed suites.
+ let checkMixedSuites = false;
+ let mixedSuites = false;
if (success && records.length > 0) {
let data = records[0].data;
errors = data.errors;
digest = data.digest;
+ let infos = {};
+ for (const info of data.infos) {
+ let path = info.path;
+ let idx = info.index;
+
+ if (!infos[path]) {
+ infos[path] = {};
+ }
+ if (!infos[path][idx]) {
+ infos[path][idx] = {
+ origin: '',
+ warnings: [],
+ // Used as a heuristic to detect mixed repositories pre-upgrade. The
+ // warning is set on all repositories that do configure the next suite.
+ gotIgnorePreUpgradeWarning: false,
+ };
+ }
+
+ if (info.kind === 'origin') {
+ infos[path][idx].origin = info.message;
+ } else if (info.kind === 'warning') {
+ infos[path][idx].warnings.push(info);
+ } else if (info.kind === 'ignore-pre-upgrade-warning') {
+ infos[path][idx].gotIgnorePreUpgradeWarning = true;
+ if (!repoGrid.majorUpgradeAllowed) {
+ infos[path][idx].warnings.push(info);
+ } else {
+ checkMixedSuites = true;
+ }
+ }
+ }
+
+
files.forEach(function(file) {
for (let n = 0; n < file.repositories.length; n++) {
let repo = file.repositories[n];
repo.Path = file.path;
repo.Index = n;
+ if (infos[file.path] && infos[file.path][n]) {
+ repo.Origin = infos[file.path][n].origin || Proxmox.Utils.unknownText;
+ repo.warnings = infos[file.path][n].warnings || [];
+
+ if (repo.Enabled) {
+ if (repo.warnings.some(w => w.property === 'Suites')) {
+ suitesWarning = true;
+ }
+
+ let originType = me.classifyOrigin(repo.Origin);
+ // Only Proxmox and Debian repositories checked here, because the
+ // warning can be missing for others for a different reason (e.g.
+ // using 'stable' or non-Debian code names).
+ if (checkMixedSuites && repo.Types.includes('deb') &&
+ (originType === 'Proxmox' || originType === 'Debian') &&
+ !infos[file.path][n].gotIgnorePreUpgradeWarning
+ ) {
+ mixedSuites = true;
+ }
+ }
+ }
gridData.push(repo);
}
});
- repoGrid.addAdditionalInfos(gridData, data.infos);
repoGrid.store.loadData(gridData);
me.updateStandardRepos(data['standard-repos']);
me.digest = digest;
- vm.set('errorCount', errors.length);
- errorGrid.store.loadData(errors);
+ vm.set('errors', errors);
+ vm.set('suitesWarning', suitesWarning);
+ vm.set('mixedSuites', mixedSuites);
+ me.getController().updateState();
});
me.check_subscription();