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 CA0851FF0AF for ; Fri, 06 Nov 2026 16:48:24 +0100 (CET) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id DF23A2181A; Fri, 06 Nov 2026 16:47:01 +0100 (CET) From: "Max R. Carrara" To: pve-devel@lists.proxmox.com Subject: [PATCH pve-manager v4 16/17] ui: storage: add basic UI integration for custom storage plugins Date: Fri, 6 Nov 2026 16:45:52 +0100 Message-ID: <20261106154554.2182986-17-m.carrara@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20261106154554.2182986-1-m.carrara@proxmox.com> References: <20261106154554.2182986-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: 1793979988500 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.320 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_MED -2.3 Sender listed at https://www.dnswl.org/, medium 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: 6PTK7VAHNJXGIFGP3ZPBNLQYCKBOKWRI X-Message-ID-Hash: 6PTK7VAHNJXGIFGP3ZPBNLQYCKBOKWRI 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 CC: Alfonso Kuen , Thomas Ellmenreich , Jakob Klocker 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. Values of sensitive properties are never filled in, so leaving such a field empty keeps the stored value. Checkboxes that are left at their default are not submitted when adding a storage, so that only explicitly set properties end up in the config. Custom storage plugins are appended to the "Add" dropdown menu once the request to `plugins/storage/plugin` succeeds. Built-in storage types keep using their own editors and do not depend on the request, so they can still be added and edited right away, and also if the request fails. Editing a storage of any other type before the request finishes opens the editor once it does. Custom plugins whose type the UI already knows, but deliberately does not offer in the "Add" menu stay hidden there. Signed-off-by: Max R. Carrara --- www/manager6/Makefile | 1 + www/manager6/dc/StorageView.js | 93 ++++++++++- www/manager6/storage/Base.js | 7 +- www/manager6/storage/CustomEdit.js | 254 +++++++++++++++++++++++++++++ 4 files changed, 347 insertions(+), 8 deletions(-) create mode 100644 www/manager6/storage/CustomEdit.js diff --git a/www/manager6/Makefile b/www/manager6/Makefile index d2ea786b..e8e2bd0b 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -361,10 +361,11 @@ JSSRC= \ storage/BackupView.js \ storage/Base.js \ storage/Browser.js \ storage/CIFSEdit.js \ storage/CephFSEdit.js \ + storage/CustomEdit.js \ storage/DirEdit.js \ storage/ImageView.js \ storage/IScsiEdit.js \ storage/LVMEdit.js \ storage/LvmThinEdit.js \ diff --git a/www/manager6/dc/StorageView.js b/www/manager6/dc/StorageView.js index e779e9c4..8f48d552 100644 --- a/www/manager6/dc/StorageView.js +++ b/www/manager6/dc/StorageView.js @@ -8,31 +8,58 @@ Ext.define( onlineHelp: 'chapter_storage', stateful: true, stateId: 'grid-dc-storage', + isCustomPlugin: function (metadata) { + return metadata.module.startsWith('PVE::Storage::Custom::'); + }, + createStorageEditWindow: function (type, sid) { + let me = this; + let schema = PVE.Utils.storageSchema[type]; - if (!schema || !schema.ipanel) { + + let paneltype; + let canDoBackups; + let metadata; + + // Built-in types have their own editor and must not depend on the plugin metadata, + // which might not be loaded (yet). + if (schema?.ipanel) { + paneltype = 'PVE.storage.' + schema.ipanel; + canDoBackups = schema.backups; + } else if (me.pluginMetadata[type] && me.isCustomPlugin(me.pluginMetadata[type])) { + metadata = me.pluginMetadata[type]; + paneltype = 'PVE.storage.CustomInputPanel'; + canDoBackups = + metadata.content.supported.includes('backup') && + Object.hasOwn(metadata.schema, 'prune-backups'); + } else if (!me.pluginMetadataLoaded) { + // might be a custom plugin, so open the editor once the metadata is there + me.pendingEditor = () => me.createStorageEditWindow(type, sid); + return; + } else { Ext.Msg.alert( gettext('Error'), Ext.String.format( gettext("No editor registered for storage type '{0}'"), Ext.htmlEncode(type), ), ); return; } 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, }, }); }, initComponent: function () { @@ -50,10 +77,13 @@ Ext.define( }, }); let sm = Ext.create('Ext.selection.RowModel', {}); + me.pluginMetadata = {}; + me.pluginMetadataLoaded = false; + let run_editor = function () { let rec = sm.getSelection()[0]; if (!rec) { return; } @@ -89,23 +119,72 @@ Ext.define( iconCls: 'fa fa-fw fa-' + storage.faIcon, handler: addHandleGenerator(type), }); } + let addMenu = new Ext.menu.Menu({ + items: addMenuItems, + }); + + let finishLoading = function (success) { + me.pluginMetadataLoaded = true; + + let openEditor = me.pendingEditor; + delete me.pendingEditor; + + // on failure, the error is shown instead + if (success && openEditor) { + openEditor(); + } + }; + + Proxmox.Utils.API2Request({ + url: '/api2/extjs/plugins/storage/plugin', + method: 'GET', + success: function ({ result: { data } }) { + if (me.destroyed) { + return; + } + + for (const metadata of data) { + me.pluginMetadata[metadata.type] = metadata; + + // built-in UI knows the type, but does not offer to add it + let hideAdd = PVE.Utils.storageSchema[metadata.type]?.hideAdd; + + if (me.isCustomPlugin(metadata) && !hideAdd) { + addMenu.add({ + text: Ext.htmlEncode(PVE.Utils.format_storage_type(metadata.type)), + iconCls: 'fa fa-fw fa-folder', + handler: addHandleGenerator(metadata.type), + }); + } + } + + finishLoading(true); + }, + failure: function ({ htmlStatus }) { + if (me.destroyed) { + return; + } + + Ext.Msg.alert(gettext('Error'), htmlStatus); + finishLoading(false); + }, + }); + Ext.apply(me, { store: store, reloadStore: () => store.load(), selModel: sm, viewConfig: { trackOver: false, }, tbar: [ { text: gettext('Add'), - menu: new Ext.menu.Menu({ - items: addMenuItems, - }), + menu: addMenu, }, remove_btn, edit_btn, ], columns: [ diff --git a/www/manager6/storage/Base.js b/www/manager6/storage/Base.js index 8961460b..f72b703e 100644 --- a/www/manager6/storage/Base.js +++ b/www/manager6/storage/Base.js @@ -97,10 +97,11 @@ Ext.define('PVE.panel.StorageBase', { name: 'external-snapshot-hint-lvm', userCls: 'pmx-hint', 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', userCls: 'pmx-hint', value: gettext('Snapshots as Volume-Chain are a technology preview.'), @@ -137,10 +138,11 @@ Ext.define('PVE.storage.BaseEdit', { me.ipanel = Ext.create(me.paneltype, { title: gettext('General'), type: me.type, isCreate: me.isCreate, storageId: me.storageId, + metadata: me.metadata, }); Ext.apply(me, { subject: PVE.Utils.format_storage_type(me.type), isAdd: true, @@ -153,11 +155,14 @@ Ext.define('PVE.storage.BaseEdit', { items: [ me.ipanel, { xtype: 'pveBackupJobPrunePanel', title: gettext('Backup Retention'), - hasMaxProtected: true, + // custom plugins do not necessarily support it + hasMaxProtected: + !me.metadata || + Object.hasOwn(me.metadata.schema, 'max-protected-backups'), isCreate: me.isCreate, keepAllDefaultForCreate: true, showPBSHint: me.ipanel.isPBS, fallbackHintHtml: gettext( "Without any keep option, the node's vzdump.conf or `keep-all` is used as fallback for backup jobs", diff --git a/www/manager6/storage/CustomEdit.js b/www/manager6/storage/CustomEdit.js new file mode 100644 index 00000000..b6692e7e --- /dev/null +++ b/www/manager6/storage/CustomEdit.js @@ -0,0 +1,254 @@ +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; + } + + // Fixed properties can only be set on creation. Do not rely on the + // field being disabled alone, as the storage edit controller + // re-enables the preallocation field. + let isFixed = !me.isCreate && Boolean(property.fixed); + let fixedProps = { disabled: isFixed, readOnly: isFixed, submitValue: !isFixed }; + + const predefinedFields = { + content: { + xtype: 'pveContentTypeSelector', + cts: me.metadata.content.supported, + fieldLabel: gettext('Content'), + name: 'content', + value: me.metadata.content.default, + multiSelect: true, + allowBlank: false, + ...fixedProps, + validator: function () { + let contentTypes = Ext.Array.from(this.getValue()); + if (contentTypes.includes('none') && contentTypes.length > 1) { + return gettext("'none' cannot be combined with other content types"); + } + return true; + }, + }, + preallocation: { + xtype: 'pvePreallocationSelector', + name: 'preallocation', + fieldLabel: gettext('Preallocation'), + allowBlank: false, + deleteEmpty: !me.isCreate && Boolean(property.optional), + ...fixedProps, + 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, + ...fixedProps, + 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) { + let 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); + } + }, + + onGetValues: function (values) { + let me = this; + let schema = me.metadata.schema; + + values = me.callParent([values]); + + // Checkboxes always submit a value, but defaults should not end up in + // the config. + if (me.isCreate) { + for (const [name, property] of Object.entries(schema)) { + if ( + property.type !== 'boolean' || + !property.optional || + values[name] === undefined + ) { + continue; + } + + let defaultValue = PVE.Parser.parseBoolean(String(property.default), false); + if (PVE.Parser.parseBoolean(String(values[name])) === defaultValue) { + delete values[name]; + } + } + } + + return values; + }, + + onSetValues: function (values) { + let me = this; + + // Never show and thus resubmit values of sensitive properties, empty means unchanged. + for (const [name, property] of Object.entries(me.metadata.schema)) { + if (property['x-sensitive']) { + delete values[name]; + } + } + + return values; + }, + + 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', + + // 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); + } + } + + // We use regular sort without `localeCompare()`, since we want all properties to be + // *always* in the same order, regardless of language. + for (const [propertyName, propertySchema] of Object.entries(schema).sort()) { + if (reservedFields.has(propertyName)) { + continue; + } + + // a required property must stay settable on creation + if (propertySchema['x-hidden'] && (propertySchema.optional || !me.isCreate)) { + 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