From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id E5C801FF146 for ; Tue, 23 Jun 2026 16:35:25 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id BC75D3CD2; Tue, 23 Jun 2026 16:34:46 +0200 (CEST) From: "Max R. Carrara" To: pve-devel@lists.proxmox.com Subject: [PATCH pve-manager 13/13] ui: storage: add basic UI integration for custom storage plugins Date: Tue, 23 Jun 2026 16:33:30 +0200 Message-ID: <20260623143402.772452-14-m.carrara@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260623143402.772452-1-m.carrara@proxmox.com> References: <20260623143402.772452-1-m.carrara@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1782225258417 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.080 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: E7AB5UR45SCM6GDGBVHIUSA6OCG76T2C X-Message-ID-Hash: E7AB5UR45SCM6GDGBVHIUSA6OCG76T2C X-MailFrom: m.carrara@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: This commit adds a basic / rudimentary UI integration for custom storage plugins. Do this by issuing a request to the new `GET plugins/storage/plugin` endpoint and checking whether a plugin is custom or not via its Perl module path. When a user then adds or edits a storage config entry belonging to a custom storage plugin, the new `PVE.storage.CustomInputPanel` opens and builds the form's view from the schemas of the plugin's properties. In other words, the UI is built completely from the information provided by the plugin's SectionConfig schema. It is worth noting that the "Add" dropdown menu button's items are added to the menu on the fly once the request to `plugins/storage/plugin` succeeds. For the short moment that the request is being awaited, the "Add" button is disabled. This is not noticeable at all for regular (i.e. decently fast) connections. Users with (very) slow connections will however notice that the button stays disabled until the request succeeded. If that were not the case, the dropdown menu of the "Add" button would not even show up when clicked. Therefore keep the button disabled (greyed out, unclickable) for the little time that the user cannot do anything with it anyway. Signed-off-by: Max R. Carrara --- www/manager6/Makefile | 1 + www/manager6/dc/StorageView.js | 131 +++++++++++++----- www/manager6/storage/Base.js | 2 + www/manager6/storage/CustomEdit.js | 208 +++++++++++++++++++++++++++++ 4 files changed, 308 insertions(+), 34 deletions(-) create mode 100644 www/manager6/storage/CustomEdit.js diff --git a/www/manager6/Makefile b/www/manager6/Makefile index d4dd3f35..b10504cd 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -361,6 +361,7 @@ JSSRC= \ storage/Browser.js \ storage/CIFSEdit.js \ storage/CephFSEdit.js \ + storage/CustomEdit.js \ storage/DirEdit.js \ storage/ImageView.js \ storage/IScsiEdit.js \ diff --git a/www/manager6/dc/StorageView.js b/www/manager6/dc/StorageView.js index bcc02ed5..7bcb5637 100644 --- a/www/manager6/dc/StorageView.js +++ b/www/manager6/dc/StorageView.js @@ -11,20 +11,47 @@ Ext.define( stateId: 'grid-dc-storage', createStorageEditWindow: function (type, sid) { - let schema = PVE.Utils.storageSchema[type]; - if (!schema || !schema.ipanel) { - Ext.Msg.alert(gettext('Error'), `No editor registered for storage type '${type}'`); + let me = this; + + const metadata = me.pluginMetadata[type]; + + // Should never happen, but still handle it here just in case + if (!metadata) { + Ext.Msg.alert(gettext('Error'), `Plugin '${type}' has no metadata`); return; } + let isCustom = metadata.module.startsWith('PVE::Storage::Custom'); + + let paneltype; + let canDoBackups; + + if (isCustom) { + paneltype = 'PVE.storage.CustomInputPanel'; + canDoBackups = metadata.content.supported.includes('backup'); + } else { + let schema = PVE.Utils.storageSchema[type]; + if (!schema || !schema.ipanel) { + Ext.Msg.alert( + gettext('Error'), + `No editor registered for storage type '${type}'`, + ); + return; + } + + paneltype = 'PVE.storage.' + schema.ipanel; + canDoBackups = schema.backups; + } + Ext.create('PVE.storage.BaseEdit', { - paneltype: 'PVE.storage.' + schema.ipanel, + paneltype: paneltype, type: type, storageId: sid, - canDoBackups: schema.backups, + canDoBackups: canDoBackups, + metadata: metadata, autoShow: true, listeners: { - destroy: this.reloadStore, + destroy: me.reloadStore, }, }); }, @@ -46,6 +73,69 @@ Ext.define( let sm = Ext.create('Ext.selection.RowModel', {}); + me.pluginMetadata = {}; + + let menuButtonAdd = new Ext.menu.Menu({ + items: [], + }); + + let addBtn = new Ext.Button({ + menu: menuButtonAdd, + text: gettext('Add'), + disabled: true, + }); + + let pushBuiltinPluginsToMenu = function () { + for (const [type, storage] of Object.entries(PVE.Utils.storageSchema)) { + if (storage.hideAdd) { + continue; + } + + menuButtonAdd.add({ + text: PVE.Utils.format_storage_type(type), + iconCls: 'fa fa-fw fa-' + storage.faIcon, + handler: () => me.createStorageEditWindow(type), + }); + } + }; + + let pushCustomPluginsToMenu = function () { + for (const type in me.pluginMetadata) { + if (!Object.hasOwn(me.pluginMetadata, type)) { + continue; + } + + const metadata = me.pluginMetadata[type]; + let isCustom = metadata.module.startsWith('PVE::Storage::Custom'); + + if (isCustom) { + menuButtonAdd.add({ + text: PVE.Utils.format_storage_type(type), + iconCls: 'fa fa-fw fa-folder', + handler: () => me.createStorageEditWindow(type), + }); + } + } + }; + + Proxmox.Utils.API2Request({ + url: `/api2/extjs/plugins/storage/plugin`, + method: 'GET', + success: function ({ result: { data } }) { + data.forEach((metadata) => { + me.pluginMetadata[metadata.type] = metadata; + }); + + pushBuiltinPluginsToMenu(); + pushCustomPluginsToMenu(); + + addBtn.setDisabled(false); + }, + failure: function ({ htmlStatus }) { + Ext.Msg.alert('Error', htmlStatus); + }, + }); + let run_editor = function () { let rec = sm.getSelection()[0]; if (!rec) { @@ -67,24 +157,6 @@ Ext.define( callback: () => store.load(), }); - // else we cannot dynamically generate the add menu handlers - let addHandleGenerator = function (type) { - return function () { - me.createStorageEditWindow(type); - }; - }; - let addMenuItems = []; - for (const [type, storage] of Object.entries(PVE.Utils.storageSchema)) { - if (storage.hideAdd) { - continue; - } - addMenuItems.push({ - text: PVE.Utils.format_storage_type(type), - iconCls: 'fa fa-fw fa-' + storage.faIcon, - handler: addHandleGenerator(type), - }); - } - Ext.apply(me, { store: store, reloadStore: () => store.load(), @@ -92,16 +164,7 @@ Ext.define( viewConfig: { trackOver: false, }, - tbar: [ - { - text: gettext('Add'), - menu: new Ext.menu.Menu({ - items: addMenuItems, - }), - }, - remove_btn, - edit_btn, - ], + tbar: [addBtn, remove_btn, edit_btn], columns: [ { header: 'ID', diff --git a/www/manager6/storage/Base.js b/www/manager6/storage/Base.js index 8961460b..7d416c97 100644 --- a/www/manager6/storage/Base.js +++ b/www/manager6/storage/Base.js @@ -99,6 +99,7 @@ Ext.define('PVE.panel.StorageBase', { value: gettext('Keep Snapshots as Volume-Chain enabled if qcow2 images exist!'), }); } + // Note: This hint is also added in CustomEdit.js. me.advancedColumnB.unshift({ xtype: 'displayfield', name: 'external-snapshot-hint', @@ -139,6 +140,7 @@ Ext.define('PVE.storage.BaseEdit', { type: me.type, isCreate: me.isCreate, storageId: me.storageId, + metadata: me.metadata, }); Ext.apply(me, { diff --git a/www/manager6/storage/CustomEdit.js b/www/manager6/storage/CustomEdit.js new file mode 100644 index 00000000..45fda7c8 --- /dev/null +++ b/www/manager6/storage/CustomEdit.js @@ -0,0 +1,208 @@ +Ext.define('PVE.storage.CustomInputPanel', { + extend: 'PVE.panel.StorageBase', + + buildFormFieldFromProperty: function (propertyName) { + let me = this; + let schema = me.metadata.schema; + + let property = schema[propertyName]; + + if (!property) { + console.warn( + `Tried to create field for unknown property '${propertyName}'` + + ` for storage type '${me.type}'`, + ); + return; + } + + const predefinedFields = { + content: { + xtype: 'pveContentTypeSelector', + cts: me.metadata.content.supported, + fieldLabel: gettext('Content'), + name: 'content', + value: me.metadata.content.default, + multiSelect: true, + allowBlank: false, + }, + preallocation: { + xtype: 'pvePreallocationSelector', + name: 'preallocation', + fieldLabel: gettext('Preallocation'), + allowBlank: false, + deleteEmpty: !me.isCreate, + value: '__default__', + }, + 'snapshot-as-volume-chain': { + xtype: 'proxmoxcheckbox', + name: 'snapshot-as-volume-chain', + // TRANSLATORS: As in "a chain of volumes, each referencing the next one". + boxLabel: gettext('Allow Snapshots as Volume-Chain'), + deleteEmpty: !me.isCreate, + // can only allow to enable this on creation for storages that + // previously already supported qcow2 to avoid ambiguity with + // existing volumes. + disabled: !me.isCreate, + checked: false, + }, + }; + + if (predefinedFields[propertyName]) { + return predefinedFields[propertyName]; + } + + let fieldName = propertyName; + let extraAttributes = { + readonly: property.fixed && !me.isCreate, + sensitive: property.sensitive, + }; + let context = { isCreate: me.isCreate }; + + let fieldDef = Proxmox.Utils.getFieldDefFromPropertySchema( + fieldName, + property, + extraAttributes, + context, + ); + + // Fix up any field labels for properties that do not have a title key + // by transforming the name of the field like e.g. + // "foo_bar-baz" --> "Foo Bar Baz" + if (!property.title) { + let fieldLabel = fieldName + .replace(/([-_]|\s)+/g, ' ') + .replace( + /\w*/g, + (label) => label.charAt(0).toUpperCase() + label.slice(1).toLowerCase(), + ); + fieldDef.fieldLabel = Ext.htmlEncode(fieldLabel); + } + + return fieldDef; + }, + + addWidget: function (widget) { + let me = this; + + me.column1 = me.column1 || []; + me.column2 = me.column2 || []; + me.columnB = me.columnB || []; + + const wideFields = [ + 'textarea', + 'textareafield', + 'Ext.form.field.TextArea', + 'proxmoxtextarea', + 'Proxmox.form.field.TextArea', + ]; + + if (wideFields.includes(widget.xtype)) { + me.columnB.push(widget); + return; + } + + if (me.column2.length >= me.column1.length) { + me.column1.push(widget); + } else { + me.column2.push(widget); + } + }, + + addAdvancedWidget: function (widget) { + let me = this; + + me.advancedColumn1 = me.advancedColumn1 || []; + me.advancedColumn2 = me.advancedColumn2 || []; + + if (me.advancedColumn2.length >= me.advancedColumn1.length) { + me.advancedColumn1.push(widget); + } else { + me.advancedColumn2.push(widget); + } + }, + + initComponent: function () { + let me = this; + let schema = me.metadata.schema; + + me.column1 = me.column1 || []; + me.column2 = me.column2 || []; + + const reservedFields = new Set([ + // automatically added in PVE.panel.StorageBase + // --> must not be added here + 'storage', + 'nodes', + 'disable', + + // handled by the "Backup Retention" panel + // --> must not be added here + 'prune-backups', + 'max-protected-backups', + + // not an actual property, but used by the UI as an inverse of the + // 'disable' property + // --> must not be added here + 'enable', + + // not exposed in the UI for any inbuilt storage types + // --> must not be added here (in order to remain consistent) + 'bwlimit', + 'content-dirs', + 'create-base-path', + 'create-subdirs', + 'format', + + // handled separately for consistency + 'content', + 'shared', + ]); + + const advancedFields = new Set([ + 'preallocation', + + // note that the technology preview hint is automatically added + // further below if a plugin uses this property + 'snapshot-as-volume-chain', + ]); + + // Added first for consistency's sake + for (const propertyName of ['content', 'shared']) { + let property = schema[propertyName]; + if (property) { + let fieldDef = me.buildFormFieldFromProperty(propertyName); + me.column1.push(fieldDef); + } + } + + for (const propertyName of Object.keys(schema).sort()) { + if (reservedFields.has(propertyName)) { + continue; + } + + let fieldDef = me.buildFormFieldFromProperty(propertyName); + + if (fieldDef === undefined) { + continue; + } + + if (advancedFields.has(propertyName)) { + me.addAdvancedWidget(fieldDef); + } else { + me.addWidget(fieldDef); + } + + if (propertyName === 'snapshot-as-volume-chain') { + me.advancedColumnB = me.advancedColumnB || []; + me.advancedColumnB.unshift({ + xtype: 'displayfield', + name: 'external-snapshot-hint', + userCls: 'pmx-hint', + value: gettext('Snapshots as Volume-Chain are a technology preview.'), + }); + } + } + + me.callParent(); + }, +}); -- 2.47.3