public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: "Max R. Carrara" <m.carrara@proxmox.com>
To: pve-devel@lists.proxmox.com
Cc: Alfonso Kuen <gerencia@idkmanager.com>,
	Thomas Ellmenreich <t.ellmenreich@proxmox.com>,
	Jakob Klocker <j.klocker@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	[thread overview]
Message-ID: <20261106154554.2182986-17-m.carrara@proxmox.com> (raw)
In-Reply-To: <20261106154554.2182986-1-m.carrara@proxmox.com>

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 <m.carrara@proxmox.com>
---
 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





  parent reply	other threads:[~2026-11-06 15:48 UTC|newest]

Thread overview: 18+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-11-06 15:45 [PATCH common/manager/proxmox-widget-toolkit/storage/storage-plugin-examples v4 00/17] GUI Support for Custom Storage Plugins Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-common v4 01/17] json schema: add multiline string format Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-storage v4 02/17] api: plugins/storage: add initial routes and endpoints Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-storage v4 03/17] api: plugins/storage/plugin: include schema in plugin metadata Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-storage v4 04/17] api: plugins/storage/plugin: mark sensitive properties in schema Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-storage v4 05/17] api: plugins/storage/plugin: factor plugin metadata code into helper Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-storage v4 06/17] api: plugins/storage/plugin: add plugins' 'content' to their metadata Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-storage v4 07/17] all plugins: add 'title' to properties, adapt 'description's Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-storage v4 08/17] all plugins, api: mark certain properties as advanced in plugindata() Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-storage v4 09/17] all plugins, api: mark certain properties as hidden " Max R. Carrara
2026-11-06 15:45 ` [PATCH proxmox-widget-toolkit v4 10/17] form: introduce new 'proxmoxtextarea' field Max R. Carrara
2026-11-06 15:45 ` [PATCH proxmox-widget-toolkit v4 11/17] utils: introduce helper function getFieldDefFromPropertySchema Max R. Carrara
2026-11-06 15:45 ` [PATCH proxmox-widget-toolkit v4 12/17] acme: use helper to construct ExtJS fields from property schemas Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-manager v4 13/17] api: sort modules and API method registrations Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-manager v4 14/17] api: add API routes 'plugins' and 'plugins/storage' Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-manager v4 15/17] ui: storage view: display error when no editor for storage type exists Max R. Carrara
2026-11-06 15:45 ` Max R. Carrara [this message]
2026-11-06 15:45 ` [PATCH pve-storage-plugin-examples v4 17/17] sshfs: Update SSHFS plugin Max R. Carrara

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20261106154554.2182986-17-m.carrara@proxmox.com \
    --to=m.carrara@proxmox.com \
    --cc=gerencia@idkmanager.com \
    --cc=j.klocker@proxmox.com \
    --cc=pve-devel@lists.proxmox.com \
    --cc=t.ellmenreich@proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal