From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [45.144.208.40]) by lore.proxmox.com (Postfix) with ESMTPS id D8F151FF0E1 for ; Mon, 27 Jul 2026 16:37:17 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 366E721554; Mon, 27 Jul 2026 16:36:13 +0200 (CEST) From: "Max R. Carrara" To: pve-devel@lists.proxmox.com Subject: [PATCH pve-manager v3 16/17] ui: storage: add basic UI integration for custom storage plugins Date: Mon, 27 Jul 2026 16:34:40 +0200 Message-ID: <20260727143445.513900-17-m.carrara@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260727143445.513900-1-m.carrara@proxmox.com> References: <20260727143445.513900-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: 1785162884167 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.030 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) RCVD_IN_DNSWL_LOW -0.7 Sender listed at https://www.dnswl.org/, low trust 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: DXUTP4KM5HGBWXM3X5JFGVQ5EGLRYB3X X-Message-ID-Hash: DXUTP4KM5HGBWXM3X5JFGVQ5EGLRYB3X 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 | 198 +++++++++++++++++++++++++++++ 4 files changed, 298 insertions(+), 34 deletions(-) create mode 100644 www/manager6/storage/CustomEdit.js diff --git a/www/manager6/Makefile b/www/manager6/Makefile index eb0e9d9c..84fe74bf 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -362,6 +362,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..1fe4c849 --- /dev/null +++ b/www/manager6/storage/CustomEdit.js @@ -0,0 +1,198 @@ +Ext.define('PVE.storage.CustomInputPanel', { + extend: 'PVE.panel.StorageBase', + + buildFormFieldFromProperty: function (propertyName) { + const me = this; + const 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['x-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, isAdvanced) { + const me = this; + + let col1, col2, colB; + + if (isAdvanced) { + col1 = me.advancedColumn1 = me.advancedColumn1 || []; + col2 = me.advancedColumn2 = me.advancedColumn2 || []; + colB = me.advancedColumnB = me.advancedColumnB || []; + } else { + col1 = me.column1 = me.column1 || []; + col2 = me.column2 = me.column2 || []; + colB = me.columnB = me.columnB || []; + } + + const hintClasses = ['pmx-hint']; + + const wideFields = [ + 'textarea', + 'textareafield', + 'Ext.form.field.TextArea', + 'proxmoxtextarea', + 'Proxmox.form.field.TextArea', + ]; + + if (wideFields.includes(widget.xtype)) { + colB.push(widget); + return; + } + + for (const cls of hintClasses) { + if (widget.userCls && widget.userCls.search(cls) !== -1) { + colB.push(widget); + return; + } + } + + if (col2.length >= col1.length) { + col1.push(widget); + } else { + col2.push(widget); + } + }, + + initComponent: function () { + const me = this; + const 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', + + // handled separately for consistency + 'content', + 'shared', + ]); + + // 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, propertySchema] of Object.entries(schema).sort()) { + if (reservedFields.has(propertyName)) { + continue; + } + + if (propertySchema['x-hidden']) { + continue; + } + + let fieldDef = me.buildFormFieldFromProperty(propertyName); + + if (fieldDef === undefined) { + continue; + } + + me.addWidget(fieldDef, propertySchema['x-advanced']); + + if (propertyName === 'snapshot-as-volume-chain') { + me.addWidget( + { + xtype: 'displayfield', + name: 'external-snapshot-hint', + userCls: 'pmx-hint', + value: gettext('Snapshots as Volume-Chain are a technology preview.'), + }, + propertySchema['x-advanced'], + ); + } + } + + me.callParent(); + }, +}); -- 2.47.3