public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
* [PATCH common/manager/proxmox-widget-toolkit/storage 00/13] GUI Support for Custom Storage Plugins
@ 2026-06-23 14:33 Max R. Carrara
  2026-06-23 14:33 ` [PATCH pve-common 01/13] json schema: add multiline string format Max R. Carrara
                   ` (12 more replies)
  0 siblings, 13 replies; 14+ messages in thread
From: Max R. Carrara @ 2026-06-23 14:33 UTC (permalink / raw)
  To: pve-devel

GUI Support for Custom Storage Plugins
======================================

This series builds upon my previous RFC (see below), with some issues
fixed and some further improvements made. The RFC status is dropped,
rerolling this series as a proper v1.

Big thanks to @Daniel H. for testing the RFC v2 and providing feedback!

tl;dr: Integrate custom storage plugins into our web UI by deriving
ExtJS field definitions from our SectionConfig JSON schema. JSON schema
keys are converted into ExtJS field attributes in the front end.

Notable Changes Since rfc-v2
----------------------------

- Rebase on master

- Add a new "multiline" format in pve-common that prevents strings with
  line breaks from being folded (thanks @Daniel H. for spotting this!)

- Add 'title' keys to every inbuilt property and adapt some properties'
  descriptions (see patch #07 for the details)

- Add a new 'proxmoxtextarea' field in the widget toolkit (see patch #08)
  --> If a property has the "multiline" format, this field is used
      instead the one matching its type

- Use some of our custom ExtJS fields for the inbuilt 'content',
  'preallocation' and 'snapshot-as-volume-chain' properties

- Refrain from exposing properties in the UI that are exposed for any
  inbuilt storage types (in order to remain consistent)

- As with inbuilt plugins, make 'preallocation' and
  'snapshot-as-volume-chain' properties advanced fields
  --> The tech-preview hint for the SAVC property is also added if a
      plugin uses that property

- If any field uses some kind of textarea field (such as "multiline"
  format properties), put those fields into the wide column at the
  bottom of the editing window instead

- If no `title` key is provided in a given plugin's schema, transform
  its property name into title case instead, e.g. "foo_bar" --> "Foo
  Bar", "foo-bar" --> "Foo Bar"

Overall, this results in a lot more polish. Testing would be much
appreciated -- any custom plugin should work out of the box with this
series.

If you need prebuilt packages, holler at me! :)

Previous Versions
-----------------

rfc-v1: https://lore.proxmox.com/pve-devel/20250908180058.530119-1-m.carrara@proxmox.com/
rfc-v2: https://lore.proxmox.com/pve-devel/20251121165858.818307-1-m.carrara@proxmox.com/

Summary of Changes
------------------


pve-common:

Max R. Carrara (1):
  json schema: add multiline string format

 src/PVE/JSONSchema.pm | 5 +++++
 1 file changed, 5 insertions(+)


pve-storage:

Max R. Carrara (6):
  api: plugins/storage: add initial routes and endpoints
  api: plugins/storage/plugin: include schema in plugin metadata
  api: plugins/storage/plugin: mark sensitive properties in schema
  api: plugins/storage/plugin: factor plugin metadata code into helper
  api: plugins/storage/plugin: add plugins' 'content' to their metadata
  all plugins: add 'title' to properties, adapt 'description's

 src/PVE/API2/Makefile                  |   1 +
 src/PVE/API2/Plugins/Makefile          |  18 +++
 src/PVE/API2/Plugins/Storage.pm        |  54 ++++++++
 src/PVE/API2/Plugins/Storage/Makefile  |  17 +++
 src/PVE/API2/Plugins/Storage/Plugin.pm | 163 +++++++++++++++++++++++++
 src/PVE/Storage/BTRFSPlugin.pm         |   1 +
 src/PVE/Storage/CIFSPlugin.pm          |  10 +-
 src/PVE/Storage/CephFSPlugin.pm        |   6 +-
 src/PVE/Storage/DirPlugin.pm           |  11 +-
 src/PVE/Storage/ESXiPlugin.pm          |   1 +
 src/PVE/Storage/ISCSIPlugin.pm         |   6 +-
 src/PVE/Storage/LVMPlugin.pm           |  10 +-
 src/PVE/Storage/LvmThinPlugin.pm       |   3 +-
 src/PVE/Storage/NFSPlugin.pm           |   6 +-
 src/PVE/Storage/PBSPlugin.pm           |   5 +-
 src/PVE/Storage/Plugin.pm              |  46 +++++--
 src/PVE/Storage/RBDPlugin.pm           |  16 ++-
 src/PVE/Storage/ZFSPlugin.pm           |  16 ++-
 src/PVE/Storage/ZFSPoolPlugin.pm       |   9 +-
 19 files changed, 362 insertions(+), 37 deletions(-)
 create mode 100644 src/PVE/API2/Plugins/Makefile
 create mode 100644 src/PVE/API2/Plugins/Storage.pm
 create mode 100644 src/PVE/API2/Plugins/Storage/Makefile
 create mode 100644 src/PVE/API2/Plugins/Storage/Plugin.pm


proxmox-widget-toolkit:

Max R. Carrara (3):
  form: introduce new 'proxmoxtextarea' field
  utils: introduce helper function getFieldDefFromPropertySchema
  acme: use helper to construct ExtJS fields from property schemas

 src/Utils.js                 | 150 +++++++++++++++++++++++++++++++++++
 src/form/TextAreaField.js    |  47 +++++++++++
 src/window/ACMEPluginEdit.js |  42 +++-------
 3 files changed, 210 insertions(+), 29 deletions(-)


pve-manager:

Max R. Carrara (3):
  api: add API routes 'plugins' and 'plugins/storage'
  ui: storage view: display error when no editor for storage type exists
  ui: storage: add basic UI integration for custom storage plugins

 PVE/API2.pm                        |   6 +
 PVE/API2/Makefile                  |   1 +
 PVE/API2/Plugins.pm                |  61 +++++++++
 www/manager6/Makefile              |   1 +
 www/manager6/dc/StorageView.js     | 132 +++++++++++++-----
 www/manager6/storage/Base.js       |   2 +
 www/manager6/storage/CustomEdit.js | 208 +++++++++++++++++++++++++++++
 7 files changed, 377 insertions(+), 34 deletions(-)
 create mode 100644 PVE/API2/Plugins.pm
 create mode 100644 www/manager6/storage/CustomEdit.js


Summary over all repositories:
  30 files changed, 954 insertions(+), 100 deletions(-)

-- 
Generated by murpp 0.11.0




^ permalink raw reply	[flat|nested] 14+ messages in thread

* [PATCH pve-common 01/13] json schema: add multiline string format
  2026-06-23 14:33 [PATCH common/manager/proxmox-widget-toolkit/storage 00/13] GUI Support for Custom Storage Plugins Max R. Carrara
@ 2026-06-23 14:33 ` Max R. Carrara
  2026-06-23 14:33 ` [PATCH pve-storage 02/13] api: plugins/storage: add initial routes and endpoints Max R. Carrara
                   ` (11 subsequent siblings)
  12 siblings, 0 replies; 14+ messages in thread
From: Max R. Carrara @ 2026-06-23 14:33 UTC (permalink / raw)
  To: pve-devel

Add a new format named "multiline".

This format explicitly signals to other components (e.g. a client)
that a string is a multiline string and should remain one, without
folding newlines or replacing them with spaces, for example.

Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
 src/PVE/JSONSchema.pm | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/src/PVE/JSONSchema.pm b/src/PVE/JSONSchema.pm
index c740641..62e8d1b 100644
--- a/src/PVE/JSONSchema.pm
+++ b/src/PVE/JSONSchema.pm
@@ -235,6 +235,11 @@ sub get_renderer {
 
 register_format('string', sub { }); # allow format => 'string-list'
 
+# A multiline string.
+# Used to explicitly tell other components that a string should not
+# have its newlines folded or replaced with spaces.
+register_format('multiline', sub { });
+
 register_format('urlencoded', \&pve_verify_urlencoded);
 
 sub pve_verify_urlencoded {
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 14+ messages in thread

* [PATCH pve-storage 02/13] api: plugins/storage: add initial routes and endpoints
  2026-06-23 14:33 [PATCH common/manager/proxmox-widget-toolkit/storage 00/13] GUI Support for Custom Storage Plugins Max R. Carrara
  2026-06-23 14:33 ` [PATCH pve-common 01/13] json schema: add multiline string format Max R. Carrara
@ 2026-06-23 14:33 ` Max R. Carrara
  2026-06-23 14:33 ` [PATCH pve-storage 03/13] api: plugins/storage/plugin: include schema in plugin metadata Max R. Carrara
                   ` (10 subsequent siblings)
  12 siblings, 0 replies; 14+ messages in thread
From: Max R. Carrara @ 2026-06-23 14:33 UTC (permalink / raw)
  To: pve-devel

Add the following routes / endpoints:

* `GET plugins/storage`
  Subdir index--lists the direct child path components (so, just
  'plugin' currently).

* `GET plugins/storage/plugin`
  Lists all installed storage plugins and their metadata.
  Currently, this is just an array of objects, with each object
  containing the plugin's `type()` and Perl module path.

* `GET plugins/storage/plugin/{type}`
  Returns the metadata for a single plugin given by `{type}`.

Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
 src/PVE/API2/Makefile                  |   1 +
 src/PVE/API2/Plugins/Makefile          |  18 +++++
 src/PVE/API2/Plugins/Storage.pm        |  54 +++++++++++++
 src/PVE/API2/Plugins/Storage/Makefile  |  17 ++++
 src/PVE/API2/Plugins/Storage/Plugin.pm | 104 +++++++++++++++++++++++++
 5 files changed, 194 insertions(+)
 create mode 100644 src/PVE/API2/Plugins/Makefile
 create mode 100644 src/PVE/API2/Plugins/Storage.pm
 create mode 100644 src/PVE/API2/Plugins/Storage/Makefile
 create mode 100644 src/PVE/API2/Plugins/Storage/Plugin.pm

diff --git a/src/PVE/API2/Makefile b/src/PVE/API2/Makefile
index fe316c52..01b7b28f 100644
--- a/src/PVE/API2/Makefile
+++ b/src/PVE/API2/Makefile
@@ -5,3 +5,4 @@ install:
 	install -D -m 0644 Disks.pm ${DESTDIR}${PERLDIR}/PVE/API2/Disks.pm
 	make -C Storage install
 	make -C Disks install
+	make -C Plugins install
diff --git a/src/PVE/API2/Plugins/Makefile b/src/PVE/API2/Plugins/Makefile
new file mode 100644
index 00000000..f59e75b6
--- /dev/null
+++ b/src/PVE/API2/Plugins/Makefile
@@ -0,0 +1,18 @@
+SOURCES = Storage.pm		\
+
+
+SUBDIRS = Storage		\
+
+
+INSTALL_PATH = ${DESTDIR}${PERLDIR}/PVE/API2/Plugins
+
+
+.PHONY: install
+install:
+	set -e && for SOURCE in ${SOURCES}; \
+		do install -D -m 0644 $$SOURCE ${INSTALL_PATH}/$$SOURCE; \
+	done
+	set -e && for SUBDIR in ${SUBDIRS}; \
+		do make -C $$SUBDIR install; \
+	done
+
diff --git a/src/PVE/API2/Plugins/Storage.pm b/src/PVE/API2/Plugins/Storage.pm
new file mode 100644
index 00000000..53b883fd
--- /dev/null
+++ b/src/PVE/API2/Plugins/Storage.pm
@@ -0,0 +1,54 @@
+package PVE::API2::Plugins::Storage;
+
+use v5.36;
+
+use PVE::Storage;
+use PVE::Storage::Plugin;
+
+use PVE::API2::Plugins::Storage::Plugin;
+
+use PVE::RESTHandler;
+use base qw(PVE::RESTHandler);
+
+__PACKAGE__->register_method({
+    subclass => 'PVE::API2::Plugins::Storage::Plugin',
+    path => 'plugin',
+});
+
+# plugins/storage
+
+__PACKAGE__->register_method({
+    name => 'index',
+    path => '',
+    method => 'GET',
+    description => 'Directory index.',
+    permissions => {
+        user => 'all',
+    },
+    parameters => {
+        additionalProperties => 0,
+        properties => {},
+    },
+    returns => {
+        type => 'array',
+        items => {
+            type => "object",
+            additionalProperties => 0,
+            properties => {
+                subdir => {
+                    type => 'string',
+                },
+            },
+        },
+        links => [{ rel => 'child', href => "{subdir}" }],
+    },
+    code => sub($param) {
+        my $result = [
+            { subdir => 'plugin' },
+        ];
+
+        return $result;
+    },
+});
+
+1;
diff --git a/src/PVE/API2/Plugins/Storage/Makefile b/src/PVE/API2/Plugins/Storage/Makefile
new file mode 100644
index 00000000..06473a2f
--- /dev/null
+++ b/src/PVE/API2/Plugins/Storage/Makefile
@@ -0,0 +1,17 @@
+SOURCES = Plugin.pm		\
+
+
+SUBDIRS =
+
+
+INSTALL_PATH = ${DESTDIR}${PERLDIR}/PVE/API2/Plugins/Storage
+
+.PHONY: install
+install:
+	set -e && for SOURCE in ${SOURCES}; \
+		do install -D -m 0644 $$SOURCE ${INSTALL_PATH}/$$SOURCE; \
+	done
+	set -e && for SUBDIR in ${SUBDIRS}; \
+		do make -C $$SUBDIR install; \
+	done
+
diff --git a/src/PVE/API2/Plugins/Storage/Plugin.pm b/src/PVE/API2/Plugins/Storage/Plugin.pm
new file mode 100644
index 00000000..fd0f734b
--- /dev/null
+++ b/src/PVE/API2/Plugins/Storage/Plugin.pm
@@ -0,0 +1,104 @@
+package PVE::API2::Plugins::Storage::Plugin;
+
+use v5.36;
+
+use HTTP::Status qw(:constants);
+
+use PVE::Exception qw(raise);
+use PVE::Storage;
+use PVE::Storage::Plugin;
+use PVE::Tools qw(extract_param);
+
+use PVE::RESTHandler;
+use base qw(PVE::RESTHandler);
+
+my $ALL_PLUGIN_TYPES = PVE::Storage::Plugin->lookup_types();
+
+my $PLUGIN_METADATA_SCHEMA = {
+    type => 'object',
+    additionalProperties => 0,
+    properties => {
+        module => {
+            type => 'string',
+            optional => 0,
+        },
+        type => {
+            type => 'string',
+            optional => 0,
+            enum => $ALL_PLUGIN_TYPES,
+        },
+    },
+};
+
+# plugins/storage/plugin
+
+__PACKAGE__->register_method({
+    name => 'index',
+    path => '',
+    method => 'GET',
+    description => 'List all available storage plugins and their metadata.',
+    permissions => {
+        user => 'all',
+    },
+    parameters => {
+        additionalProperties => 0,
+        properties => {},
+    },
+    returns => {
+        type => 'array',
+        items => $PLUGIN_METADATA_SCHEMA,
+    },
+    code => sub($param) {
+        my $result = [];
+
+        for my $type ($ALL_PLUGIN_TYPES->@*) {
+            my $plugin = PVE::Storage::Plugin->lookup($type);
+
+            my $item = {
+                module => $plugin,
+                type => $type,
+            };
+
+            push($result->@*, $item);
+        }
+
+        return $result;
+    },
+});
+
+# plugins/storage/plugin/{type}
+
+__PACKAGE__->register_method({
+    name => 'info',
+    path => '{type}',
+    method => 'GET',
+    description => 'Fetch metadata of a storage plugin.',
+    permissions => {
+        user => 'all',
+    },
+    parameters => {
+        additionalProperties => 0,
+        properties => {
+            type => {
+                type => 'string',
+            },
+        },
+    },
+    returns => $PLUGIN_METADATA_SCHEMA,
+    code => sub($param) {
+        my $param_type = extract_param($param, 'type');
+
+        my $plugin = eval { PVE::Storage::Plugin->lookup($param_type) };
+        if ($@) {
+            raise("Plugin '$param_type' not found - $@", code => HTTP_NOT_FOUND);
+        }
+
+        my $result = {
+            module => $plugin,
+            type => $param_type,
+        };
+
+        return $result;
+    },
+});
+1;
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 14+ messages in thread

* [PATCH pve-storage 03/13] api: plugins/storage/plugin: include schema in plugin metadata
  2026-06-23 14:33 [PATCH common/manager/proxmox-widget-toolkit/storage 00/13] GUI Support for Custom Storage Plugins Max R. Carrara
  2026-06-23 14:33 ` [PATCH pve-common 01/13] json schema: add multiline string format Max R. Carrara
  2026-06-23 14:33 ` [PATCH pve-storage 02/13] api: plugins/storage: add initial routes and endpoints Max R. Carrara
@ 2026-06-23 14:33 ` Max R. Carrara
  2026-06-23 14:33 ` [PATCH pve-storage 04/13] api: plugins/storage/plugin: mark sensitive properties in schema Max R. Carrara
                   ` (9 subsequent siblings)
  12 siblings, 0 replies; 14+ messages in thread
From: Max R. Carrara @ 2026-06-23 14:33 UTC (permalink / raw)
  To: pve-devel

Return a simple schema describing the plugin as part of its metadata.

This schema is simply a hash consisting of a given plugin's
properties' schemas. Each property schema is obtained by calling the
`get_property_schema()` method of `PVE::SectionConfig` (which
`PVE::Storage::Plugin` inherits) for each property that a given plugin
uses in its `options()`. Additionally, each plugin's `options()` are
also taken into account, and the schemas are memoized.

This means that each returned schema is completely derived from the
given plugin's section config—the (globally) available properties and
the plugin's `options()`. At the same time, this should be adaptable
enough to include extra hints, such as whether a property is sensitive
or not, for example.

Deriving a schema for each plugin like this is more preferable over
exposing `createSchema()` and `updateSchema()` of
`PVE::Storage::Plugin` directly. In particular, these two schemas come
with some drawbacks when it comes to describing an *individual*
plugin's data:

- Neither schema contains any information on which properties are used
  by which plugins.

- It is not possible to determine whether a property is actually
  optional (or not) for a given plugin. If one plugin declares a
  property as optional somewhere, the property will be optional in the
  schema in most cases, despite being non-optional for other plugins.
  (This is generally true; skipping over a bunch of SectionConfig
  implementation details here for the sake of brevity).

- The same is true even more so for fixed properties. While the
  `updateSchema()` will oftentimes *not* contain fixed properties,
  meaning that you can compare it with `createSchema()` and figure out
  which properties *may* be fixed, this only holds if the property is
  fixed in *every* plugin's `options()`. As soon as a plugin declares
  a usually fixed property as non-fixed, the property is included in
  the `updateSchema()`.

- Even when switching to property isolation for
  `PVE::Storage::Plugin`, which properties are fixed / optional for
  individual plugins is still not consistently determinable for the
  previous two reasons.

This means that in addition to exposing `createSchema()` and
`updateSchema()`, we would also have to expose the `options()` of each
plugin, and then stitch all of that information together again just to
obtain the hash that the `get_schema_for_plugin()` helper being added
here returns.

Additionally, `get_property_schema()` takes property isolation into
account. More precisely, if one were to enable property isolation and
copy-paste the formerly global property definitions into the
`properties()` of each plugin where needed, the returned schema would
stay the same.

Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
 src/PVE/API2/Plugins/Storage/Plugin.pm | 34 ++++++++++++++++++++++++++
 1 file changed, 34 insertions(+)

diff --git a/src/PVE/API2/Plugins/Storage/Plugin.pm b/src/PVE/API2/Plugins/Storage/Plugin.pm
index fd0f734b..573d3e4d 100644
--- a/src/PVE/API2/Plugins/Storage/Plugin.pm
+++ b/src/PVE/API2/Plugins/Storage/Plugin.pm
@@ -22,6 +22,10 @@ my $PLUGIN_METADATA_SCHEMA = {
             type => 'string',
             optional => 0,
         },
+        schema => {
+            type => 'object',
+            optional => 0,
+        },
         type => {
             type => 'string',
             optional => 0,
@@ -30,6 +34,34 @@ my $PLUGIN_METADATA_SCHEMA = {
     },
 };
 
+my $PLUGIN_SCHEMAS = {};
+
+my sub get_schema_for_plugin : prototype($) ($plugin) {
+    my $type = $plugin->type();
+
+    return $PLUGIN_SCHEMAS->{$type} if defined($PLUGIN_SCHEMAS->{$type});
+
+    my $options = $plugin->options();
+
+    my $schema = {};
+    $PLUGIN_SCHEMAS->{$type} = $schema;
+
+    for my $option (keys $options->%*) {
+        my $prop_schema = PVE::RESTHandler::api_dump_remove_refs(
+            PVE::Storage::Plugin->get_property_schema($type, $option));
+
+        # shallow copy
+        my $property = { $prop_schema->%* };
+        $schema->{$option} = $property;
+
+        for my $opt_key (keys $options->{$option}->%*) {
+            $property->{$opt_key} = $options->{$option}->{$opt_key};
+        }
+    }
+
+    return $schema;
+}
+
 # plugins/storage/plugin
 
 __PACKAGE__->register_method({
@@ -56,6 +88,7 @@ __PACKAGE__->register_method({
 
             my $item = {
                 module => $plugin,
+                schema => get_schema_for_plugin($plugin),
                 type => $type,
             };
 
@@ -95,6 +128,7 @@ __PACKAGE__->register_method({
 
         my $result = {
             module => $plugin,
+            schema => get_schema_for_plugin($plugin),
             type => $param_type,
         };
 
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 14+ messages in thread

* [PATCH pve-storage 04/13] api: plugins/storage/plugin: mark sensitive properties in schema
  2026-06-23 14:33 [PATCH common/manager/proxmox-widget-toolkit/storage 00/13] GUI Support for Custom Storage Plugins Max R. Carrara
                   ` (2 preceding siblings ...)
  2026-06-23 14:33 ` [PATCH pve-storage 03/13] api: plugins/storage/plugin: include schema in plugin metadata Max R. Carrara
@ 2026-06-23 14:33 ` Max R. Carrara
  2026-06-23 14:33 ` [PATCH pve-storage 05/13] api: plugins/storage/plugin: factor plugin metadata code into helper Max R. Carrara
                   ` (8 subsequent siblings)
  12 siblings, 0 replies; 14+ messages in thread
From: Max R. Carrara @ 2026-06-23 14:33 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
 src/PVE/API2/Plugins/Storage/Plugin.pm | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/src/PVE/API2/Plugins/Storage/Plugin.pm b/src/PVE/API2/Plugins/Storage/Plugin.pm
index 573d3e4d..757ae1e8 100644
--- a/src/PVE/API2/Plugins/Storage/Plugin.pm
+++ b/src/PVE/API2/Plugins/Storage/Plugin.pm
@@ -42,6 +42,7 @@ my sub get_schema_for_plugin : prototype($) ($plugin) {
     return $PLUGIN_SCHEMAS->{$type} if defined($PLUGIN_SCHEMAS->{$type});
 
     my $options = $plugin->options();
+    my $plugindata = $plugin->plugindata();
 
     my $schema = {};
     $PLUGIN_SCHEMAS->{$type} = $schema;
@@ -52,6 +53,8 @@ my sub get_schema_for_plugin : prototype($) ($plugin) {
 
         # shallow copy
         my $property = { $prop_schema->%* };
+        $property->{sensitive} = defined($plugindata->{'sensitive-properties'}->{$option}) || 0;
+
         $schema->{$option} = $property;
 
         for my $opt_key (keys $options->{$option}->%*) {
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 14+ messages in thread

* [PATCH pve-storage 05/13] api: plugins/storage/plugin: factor plugin metadata code into helper
  2026-06-23 14:33 [PATCH common/manager/proxmox-widget-toolkit/storage 00/13] GUI Support for Custom Storage Plugins Max R. Carrara
                   ` (3 preceding siblings ...)
  2026-06-23 14:33 ` [PATCH pve-storage 04/13] api: plugins/storage/plugin: mark sensitive properties in schema Max R. Carrara
@ 2026-06-23 14:33 ` Max R. Carrara
  2026-06-23 14:33 ` [PATCH pve-storage 06/13] api: plugins/storage/plugin: add plugins' 'content' to their metadata Max R. Carrara
                   ` (7 subsequent siblings)
  12 siblings, 0 replies; 14+ messages in thread
From: Max R. Carrara @ 2026-06-23 14:33 UTC (permalink / raw)
  To: pve-devel

in order to reduce the number of places that need to be touched when
adding more properties to the returned metadata.

Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
 src/PVE/API2/Plugins/Storage/Plugin.pm | 35 +++++++++++---------------
 1 file changed, 15 insertions(+), 20 deletions(-)

diff --git a/src/PVE/API2/Plugins/Storage/Plugin.pm b/src/PVE/API2/Plugins/Storage/Plugin.pm
index 757ae1e8..457c070d 100644
--- a/src/PVE/API2/Plugins/Storage/Plugin.pm
+++ b/src/PVE/API2/Plugins/Storage/Plugin.pm
@@ -65,6 +65,19 @@ my sub get_schema_for_plugin : prototype($) ($plugin) {
     return $schema;
 }
 
+my sub build_plugin_metadata : prototype($) ($type) {
+    my $plugin = eval { PVE::Storage::Plugin->lookup($type) };
+    if ($@) {
+        raise("Plugin '$type' not found - $@", code => HTTP_NOT_FOUND);
+    }
+
+    return {
+        module => $plugin,
+        schema => get_schema_for_plugin($plugin),
+        type => $type,
+    };
+}
+
 # plugins/storage/plugin
 
 __PACKAGE__->register_method({
@@ -87,14 +100,7 @@ __PACKAGE__->register_method({
         my $result = [];
 
         for my $type ($ALL_PLUGIN_TYPES->@*) {
-            my $plugin = PVE::Storage::Plugin->lookup($type);
-
-            my $item = {
-                module => $plugin,
-                schema => get_schema_for_plugin($plugin),
-                type => $type,
-            };
-
+            my $item = build_plugin_metadata($type);
             push($result->@*, $item);
         }
 
@@ -124,18 +130,7 @@ __PACKAGE__->register_method({
     code => sub($param) {
         my $param_type = extract_param($param, 'type');
 
-        my $plugin = eval { PVE::Storage::Plugin->lookup($param_type) };
-        if ($@) {
-            raise("Plugin '$param_type' not found - $@", code => HTTP_NOT_FOUND);
-        }
-
-        my $result = {
-            module => $plugin,
-            schema => get_schema_for_plugin($plugin),
-            type => $param_type,
-        };
-
-        return $result;
+        return build_plugin_metadata($param_type);
     },
 });
 1;
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 14+ messages in thread

* [PATCH pve-storage 06/13] api: plugins/storage/plugin: add plugins' 'content' to their metadata
  2026-06-23 14:33 [PATCH common/manager/proxmox-widget-toolkit/storage 00/13] GUI Support for Custom Storage Plugins Max R. Carrara
                   ` (4 preceding siblings ...)
  2026-06-23 14:33 ` [PATCH pve-storage 05/13] api: plugins/storage/plugin: factor plugin metadata code into helper Max R. Carrara
@ 2026-06-23 14:33 ` Max R. Carrara
  2026-06-23 14:33 ` [PATCH pve-storage 07/13] all plugins: add 'title' to properties, adapt 'description's Max R. Carrara
                   ` (6 subsequent siblings)
  12 siblings, 0 replies; 14+ messages in thread
From: Max R. Carrara @ 2026-06-23 14:33 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
 src/PVE/API2/Plugins/Storage/Plugin.pm | 27 ++++++++++++++++++++++++++
 1 file changed, 27 insertions(+)

diff --git a/src/PVE/API2/Plugins/Storage/Plugin.pm b/src/PVE/API2/Plugins/Storage/Plugin.pm
index 457c070d..6db9ffca 100644
--- a/src/PVE/API2/Plugins/Storage/Plugin.pm
+++ b/src/PVE/API2/Plugins/Storage/Plugin.pm
@@ -18,6 +18,27 @@ my $PLUGIN_METADATA_SCHEMA = {
     type => 'object',
     additionalProperties => 0,
     properties => {
+        content => {
+            type => 'object',
+            optional => 0,
+            properties => {
+                supported => {
+                    type => 'array',
+                    optional => 0,
+                    items => {
+                        type => 'string',
+                    },
+                },
+                default => {
+                    type => 'array',
+                    optional => 0,
+                    items => {
+                        type => 'string',
+                    },
+                },
+            },
+            additionalProperties => 0,
+        },
         module => {
             type => 'string',
             optional => 0,
@@ -71,7 +92,13 @@ my sub build_plugin_metadata : prototype($) ($type) {
         raise("Plugin '$type' not found - $@", code => HTTP_NOT_FOUND);
     }
 
+    my $plugindata = $plugin->plugindata();
+
     return {
+        content => {
+            supported => [sort keys $plugindata->{content}->[0]->%*],
+            default => [sort keys $plugindata->{content}->[1]->%*],
+        },
         module => $plugin,
         schema => get_schema_for_plugin($plugin),
         type => $type,
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 14+ messages in thread

* [PATCH pve-storage 07/13] all plugins: add 'title' to properties, adapt 'description's
  2026-06-23 14:33 [PATCH common/manager/proxmox-widget-toolkit/storage 00/13] GUI Support for Custom Storage Plugins Max R. Carrara
                   ` (5 preceding siblings ...)
  2026-06-23 14:33 ` [PATCH pve-storage 06/13] api: plugins/storage/plugin: add plugins' 'content' to their metadata Max R. Carrara
@ 2026-06-23 14:33 ` Max R. Carrara
  2026-06-23 14:33 ` [PATCH proxmox-widget-toolkit 08/13] form: introduce new 'proxmoxtextarea' field Max R. Carrara
                   ` (5 subsequent siblings)
  12 siblings, 0 replies; 14+ messages in thread
From: Max R. Carrara @ 2026-06-23 14:33 UTC (permalink / raw)
  To: pve-devel

Add a `title` [1] to almost all plugins' properties. Since this
keyword is included when querying the `plugins/storage/plugin`
endpoint, it can be used for cosmetic purposes, such as field labels
in the UI or similar.

The only properties that do not get a `title` are `mkdir` and
`authsupported`; the former is deprecated and the latter is currently
unused.

If a property is exposed in the UI, the label of its corresponding
field is used as its `title`, with minor alterations at most (e.g.
spelling out "Filesystem" instead of "FS"). Otherwise, something
sensible and simple is chosen instead.

Additionally, adapt some `description` [1] keywords because they are
either very short, not general enough (if used by multiple plugins),
or not really descriptive. In certain cases the wording is improved a
little.

Notably, fix the line-breaking of the descriptions of the fields in
the `$prune_backups_format` hash by adding a space at the beginning of
the concatenated strings.

There is still room for improvement here, especially since some
properties are kind of awkward to describe in more general terms, so
this is nothing that should be considered set in stone.

All of this is done as preparation for supporting custom (third-party)
storage plugins in the web UI.

Note that altering the `description` keywords affects the descriptions
of the flags in the `pvesm` manpage, in particular for the `pvesm add`
and `pvesm set` subcommands, and also possibly other parts of our
documentation.

[1]: https://json-schema.org/draft/2020-12/json-schema-validation#name-title-and-description

Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
 src/PVE/Storage/BTRFSPlugin.pm   |  1 +
 src/PVE/Storage/CIFSPlugin.pm    | 10 ++++---
 src/PVE/Storage/CephFSPlugin.pm  |  6 +++--
 src/PVE/Storage/DirPlugin.pm     | 11 +++++---
 src/PVE/Storage/ESXiPlugin.pm    |  1 +
 src/PVE/Storage/ISCSIPlugin.pm   |  6 +++--
 src/PVE/Storage/LVMPlugin.pm     | 10 +++++--
 src/PVE/Storage/LvmThinPlugin.pm |  3 ++-
 src/PVE/Storage/NFSPlugin.pm     |  6 +++--
 src/PVE/Storage/PBSPlugin.pm     |  5 +++-
 src/PVE/Storage/Plugin.pm        | 46 +++++++++++++++++++++++++-------
 src/PVE/Storage/RBDPlugin.pm     | 16 ++++++++---
 src/PVE/Storage/ZFSPlugin.pm     | 16 +++++++----
 src/PVE/Storage/ZFSPoolPlugin.pm |  9 ++++---
 14 files changed, 109 insertions(+), 37 deletions(-)

diff --git a/src/PVE/Storage/BTRFSPlugin.pm b/src/PVE/Storage/BTRFSPlugin.pm
index fb47aa0b..6b2e09ad 100644
--- a/src/PVE/Storage/BTRFSPlugin.pm
+++ b/src/PVE/Storage/BTRFSPlugin.pm
@@ -52,6 +52,7 @@ sub plugindata {
 sub properties {
     return {
         nocow => {
+            title => "Set NOCOW Flag",
             description => "Set the NOCOW flag on files."
                 . " Disables data checksumming and causes data errors to be unrecoverable from"
                 . " while allowing direct I/O. Only use this if data does not need to be any more"
diff --git a/src/PVE/Storage/CIFSPlugin.pm b/src/PVE/Storage/CIFSPlugin.pm
index 54f0f4ec..23b505cd 100644
--- a/src/PVE/Storage/CIFSPlugin.pm
+++ b/src/PVE/Storage/CIFSPlugin.pm
@@ -132,21 +132,25 @@ sub plugindata {
 sub properties {
     return {
         share => {
-            description => "CIFS share.",
+            title => "Share",
+            description => "The name of the share to use.",
             type => 'string',
         },
         password => {
-            description => "Password for accessing the share/datastore.",
+            title => "Password",
+            description => "The password used for authentication.",
             type => 'string',
             maxLength => 256,
         },
         domain => {
-            description => "CIFS domain.",
+            title => "Domain",
+            description => "The name of the user domain (workgroup) to use.",
             type => 'string',
             optional => 1,
             maxLength => 256,
         },
         smbversion => {
+            title => "SMB Version",
             description =>
                 "SMB protocol version. 'default' if not set, negotiates the highest SMB2+"
                 . " version supported by both the client and server.",
diff --git a/src/PVE/Storage/CephFSPlugin.pm b/src/PVE/Storage/CephFSPlugin.pm
index fbc97113..fcb8a9e6 100644
--- a/src/PVE/Storage/CephFSPlugin.pm
+++ b/src/PVE/Storage/CephFSPlugin.pm
@@ -125,11 +125,13 @@ sub plugindata {
 sub properties {
     return {
         fuse => {
-            description => "Mount CephFS through FUSE.",
+            title => "Mount with FUSE",
+            description => "Mount the filesystem through FUSE.",
             type => 'boolean',
         },
         'fs-name' => {
-            description => "The Ceph filesystem name.",
+            title => "Filesystem Name",
+            description => "The name or identifier of the filesystem.",
             type => 'string',
             format => 'pve-configid',
         },
diff --git a/src/PVE/Storage/DirPlugin.pm b/src/PVE/Storage/DirPlugin.pm
index 80c4a031..cc52feae 100644
--- a/src/PVE/Storage/DirPlugin.pm
+++ b/src/PVE/Storage/DirPlugin.pm
@@ -45,7 +45,9 @@ sub plugindata {
 sub properties {
     return {
         path => {
-            description => "File system path.",
+            title => "Path",
+            description => "The path on the filesystem that leads to the"
+                . " mountpoint or base directory of the storage.",
             type => 'string',
             format => 'pve-storage-path',
         },
@@ -57,23 +59,26 @@ sub properties {
             default => 'yes',
         },
         'create-base-path' => {
+            title => "Create Base Directory",
             description => "Create the base directory if it doesn't exist.",
             type => 'boolean',
             default => 'yes',
         },
         'create-subdirs' => {
-            description => "Populate the directory with the default structure.",
+            title => "Populate Base Directory",
+            description => "Populate the base directory with the default structure.",
             type => 'boolean',
             default => 'yes',
         },
         is_mountpoint => {
+            title => "Is Mountpoint",
             description => "Assume the given path is an externally managed mountpoint "
                 . "and consider the storage offline if it is not mounted. "
                 . "Using a boolean (yes/no) value serves as a shortcut to using the target path in this field.",
             type => 'string',
             default => 'no',
         },
-        bwlimit => get_standard_option('bwlimit'),
+        bwlimit => get_standard_option('bwlimit', { title => "Bandwidth Limit" }),
     };
 }
 
diff --git a/src/PVE/Storage/ESXiPlugin.pm b/src/PVE/Storage/ESXiPlugin.pm
index 19f23bb9..b5e4135e 100644
--- a/src/PVE/Storage/ESXiPlugin.pm
+++ b/src/PVE/Storage/ESXiPlugin.pm
@@ -38,6 +38,7 @@ sub plugindata {
 sub properties {
     return {
         'skip-cert-verification' => {
+            title => "Skip Certificate Verification",
             description =>
                 'Disable TLS certificate verification, only enable on fully trusted networks!',
             type => 'boolean',
diff --git a/src/PVE/Storage/ISCSIPlugin.pm b/src/PVE/Storage/ISCSIPlugin.pm
index 801b5d1b..a35a9882 100644
--- a/src/PVE/Storage/ISCSIPlugin.pm
+++ b/src/PVE/Storage/ISCSIPlugin.pm
@@ -341,11 +341,13 @@ sub plugindata {
 sub properties {
     return {
         target => {
-            description => "iSCSI target.",
+            title => "Target",
+            description => "iSCSI target name (an iSCSI Qualified Name - IQN).",
             type => 'string',
         },
         portal => {
-            description => "iSCSI portal (IP or DNS name with optional port).",
+            title => "Portal",
+            description => "iSCSI portal (IP or hostname, optionally with port).",
             type => 'string',
             format => 'pve-storage-portal-dns',
         },
diff --git a/src/PVE/Storage/LVMPlugin.pm b/src/PVE/Storage/LVMPlugin.pm
index a313ecc8..903eda3a 100644
--- a/src/PVE/Storage/LVMPlugin.pm
+++ b/src/PVE/Storage/LVMPlugin.pm
@@ -415,20 +415,24 @@ sub plugindata {
 sub properties {
     return {
         vgname => {
-            description => "Volume group name.",
+            title => "Volume Group",
+            description => "LVM volume group name.",
             type => 'string',
             format => 'pve-storage-vgname',
         },
         base => {
-            description => "Base volume. This volume is automatically activated.",
+            title => "Base Storage",
+            description => "Name of the base volume (automatically activated).",
             type => 'string',
             format => 'pve-volume-id',
         },
         saferemove => {
+            title => "Wipe Removed Volumes",
             description => "Zero-out data when removing LVs.",
             type => 'boolean',
         },
         'saferemove-stepsize' => {
+            title => "Wipe Step Size",
             description => "Wipe step size in MiB."
                 . " It will be capped to the maximum supported by the storage.",
             default => 32,
@@ -436,10 +440,12 @@ sub properties {
             type => 'integer',
         },
         saferemove_throughput => {
+            title => "Wipe Throughput",
             description => "Wipe throughput (cstream -t parameter value).",
             type => 'string',
         },
         tagged_only => {
+            title => "Tagged Volumes Only",
             description => "Only list logical volumes tagged with 'pve-vm-ID'.",
             type => 'boolean',
         },
diff --git a/src/PVE/Storage/LvmThinPlugin.pm b/src/PVE/Storage/LvmThinPlugin.pm
index cadf343f..0f3b21d4 100644
--- a/src/PVE/Storage/LvmThinPlugin.pm
+++ b/src/PVE/Storage/LvmThinPlugin.pm
@@ -39,7 +39,8 @@ sub plugindata {
 sub properties {
     return {
         thinpool => {
-            description => "LVM thin pool LV name.",
+            title => "Thin Pool",
+            description => "Name of the LVM thin pool logical volume.",
             type => 'string',
             format => 'pve-storage-vgname',
         },
diff --git a/src/PVE/Storage/NFSPlugin.pm b/src/PVE/Storage/NFSPlugin.pm
index 4cc02c9e..e54c4a4a 100644
--- a/src/PVE/Storage/NFSPlugin.pm
+++ b/src/PVE/Storage/NFSPlugin.pm
@@ -73,12 +73,14 @@ sub plugindata {
 sub properties {
     return {
         export => {
-            description => "NFS export path.",
+            title => "Export Path",
+            description => "Path exported by the NFS server (e.g., /srv/nfs).",
             type => 'string',
             format => 'pve-storage-path',
         },
         server => {
-            description => "Server IP or DNS name.",
+            title => "Server",
+            description => "IP address or hostname of the server.",
             type => 'string',
             format => 'pve-storage-server',
         },
diff --git a/src/PVE/Storage/PBSPlugin.pm b/src/PVE/Storage/PBSPlugin.pm
index 6b049b47..eb907aa3 100644
--- a/src/PVE/Storage/PBSPlugin.pm
+++ b/src/PVE/Storage/PBSPlugin.pm
@@ -42,17 +42,20 @@ sub plugindata {
 sub properties {
     return {
         datastore => {
+            title => "Datastore",
             description => "Proxmox Backup Server datastore name.",
             type => 'string',
         },
         # openssl s_client -connect <host>:8007 2>&1 |openssl x509 -fingerprint -sha256
-        fingerprint => get_standard_option('fingerprint-sha256'),
+        fingerprint => get_standard_option('fingerprint-sha256', { title => "Fingerprint" }),
         'encryption-key' => {
+            title => "Encryption Key",
             description =>
                 "Encryption key. Use 'autogen' to generate one automatically without passphrase.",
             type => 'string',
         },
         'master-pubkey' => {
+            title => "Master Public Key",
             description =>
                 "Base64-encoded, PEM-formatted public RSA key. Used to encrypt a copy of the encryption-key which will be added to each encrypted backup.",
             type => 'string',
diff --git a/src/PVE/Storage/Plugin.pm b/src/PVE/Storage/Plugin.pm
index 4f69f9b5..d8f6987a 100644
--- a/src/PVE/Storage/Plugin.pm
+++ b/src/PVE/Storage/Plugin.pm
@@ -88,36 +88,44 @@ my %prune_option = (
 our $prune_backups_format = {
     'keep-all' => {
         type => 'boolean',
+        title => "Keep All Backups",
         description => 'Keep all backups. Conflicts with the other options when true.',
         optional => 1,
     },
     'keep-last' => {
-        %prune_option, description => 'Keep the last <N> backups.',
+        %prune_option,
+        title => "Keep Last",
+        description => 'Keep the last <N> backups.',
     },
     'keep-hourly' => {
         %prune_option,
+        title => "Keep Hourly",
         description => 'Keep backups for the last <N> different hours. If there is more'
-            . 'than one backup for a single hour, only the latest one is kept.',
+            . ' than one backup for a single hour, only the latest one is kept.',
     },
     'keep-daily' => {
         %prune_option,
+        title => "Keep Daily",
         description => 'Keep backups for the last <N> different days. If there is more'
-            . 'than one backup for a single day, only the latest one is kept.',
+            . ' than one backup for a single day, only the latest one is kept.',
     },
     'keep-weekly' => {
         %prune_option,
+        title => "Keep Weekly",
         description => 'Keep backups for the last <N> different weeks. If there is more'
-            . 'than one backup for a single week, only the latest one is kept.',
+            . ' than one backup for a single week, only the latest one is kept.',
     },
     'keep-monthly' => {
         %prune_option,
+        title => "Keep Monthly",
         description => 'Keep backups for the last <N> different months. If there is more'
-            . 'than one backup for a single month, only the latest one is kept.',
+            . ' than one backup for a single month, only the latest one is kept.',
     },
     'keep-yearly' => {
         %prune_option,
+        title => "Keep Yearly",
         description => 'Keep backups for the last <N> different years. If there is more'
-            . 'than one backup for a single year, only the latest one is kept.',
+            . ' than one backup for a single year, only the latest one is kept.',
     },
 };
 PVE::JSONSchema::register_format('prune-backups', $prune_backups_format, \&validate_prune_backups);
@@ -140,6 +148,7 @@ sub validate_prune_backups {
 register_standard_option(
     'prune-backups',
     {
+        title => "Backup Retention",
         description => "The retention options with shorter intervals are processed first "
             . "with --keep-last being the very first one. Each option covers a "
             . "specific period of time. We say that backups within this period "
@@ -153,19 +162,27 @@ register_standard_option(
 
 my $defaultData = {
     propertyList => {
-        type => { description => "Storage type." },
+        type => {
+            title => "Type",
+            description => "Storage type.",
+        },
         storage => get_standard_option(
             'pve-storage-id',
-            { completion => \&PVE::Storage::complete_storage },
+            {
+                title => "ID",
+                completion => \&PVE::Storage::complete_storage,
+            },
         ),
         nodes => get_standard_option(
             'pve-node-list',
             {
+                title => "Nodes",
                 description => "List of nodes for which the storage configuration applies.",
                 optional => 1,
             },
         ),
         content => {
+            title => "Content",
             description => "Allowed content types.\n\nNOTE: the value "
                 . "'rootdir' is used for Containers, and value 'images' for VMs.\n",
             type => 'string',
@@ -174,12 +191,14 @@ my $defaultData = {
             completion => \&PVE::Storage::complete_content_type,
         },
         disable => {
+            title => "Disable",
             description => "Flag to disable the storage.",
             type => 'boolean',
             optional => 1,
         },
         'prune-backups' => get_standard_option('prune-backups'),
         'max-protected-backups' => {
+            title => "Maximum Protected",
             description =>
                 "Maximal number of protected backups per guest. Use '-1' for unlimited.",
             type => 'integer',
@@ -189,6 +208,7 @@ my $defaultData = {
                 "Unlimited for users with Datastore.Allocate privilege, 5 for other users",
         },
         shared => {
+            title => "Shared",
             description =>
                 "Indicate that this is a single storage with the same contents on all "
                 . "nodes (or all listed in the 'nodes' option). It will not make the contents of a "
@@ -198,6 +218,7 @@ my $defaultData = {
             optional => 1,
         },
         subdir => {
+            title => "Subdirectory",
             description => "Subdir to mount.",
             type => 'string',
             format => 'pve-storage-path',
@@ -206,11 +227,13 @@ my $defaultData = {
         format => get_standard_option(
             'pve-storage-image-format',
             {
+                title => "Default Format",
                 description => "Default image format.",
                 optional => 1,
             },
         ),
         preallocation => {
+            title => "Preallocation",
             description => "Preallocation mode for raw and qcow2 images. "
                 . "Using 'metadata' on raw images results in preallocation=off.",
             type => 'string',
@@ -219,18 +242,22 @@ my $defaultData = {
             optional => 1,
         },
         'content-dirs' => {
+            title => "Content Directories",
             description => "Overrides for default content type directories.",
             type => "string",
             format => "pve-dir-override-list",
             optional => 1,
         },
         options => {
-            description => "NFS/CIFS mount options (see 'man nfs' or 'man mount.cifs')",
+            title => "Mount Options",
+            description => "A list of comma-separated mount options for the filesystem."
+                . " See 'man 5 fstab' and 'man 8 mount' for more information.",
             type => 'string',
             format => 'pve-storage-options',
             optional => 1,
         },
         port => {
+            title => "Port",
             description =>
                 "Use this port to connect to the storage instead of the default one (for"
                 . " example, with PBS or ESXi). For NFS and CIFS, use the 'options' option to"
@@ -242,6 +269,7 @@ my $defaultData = {
         },
         'snapshot-as-volume-chain' => {
             type => 'boolean',
+            title => "Allow Snapshots as Volume-Chain",
             description => 'Enable support for creating storage-vendor agnostic snapshot'
                 . ' through volume backing-chains.',
             default => 0,
diff --git a/src/PVE/Storage/RBDPlugin.pm b/src/PVE/Storage/RBDPlugin.pm
index b5374251..bf05f739 100644
--- a/src/PVE/Storage/RBDPlugin.pm
+++ b/src/PVE/Storage/RBDPlugin.pm
@@ -419,24 +419,30 @@ sub plugindata {
 sub properties {
     return {
         monhost => {
+            title => "Monitor(s)",
             description => "IP addresses of monitors (for external clusters).",
             type => 'string',
             format => 'pve-storage-portal-dns-list',
         },
         pool => {
-            description => "Pool.",
+            title => "Pool",
+            description => "Name of the pool that stores images.",
             type => 'string',
         },
         'data-pool' => {
-            description => "Data Pool (for erasure coding only)",
+            title => "Data Pool",
+            description => "Data pool name for erasure-coded images.",
             type => 'string',
         },
         namespace => {
-            description => "Namespace.",
+            title => "Namespace",
+            description =>
+                "The namespace used for grouping, isolation, or partitioning purposes.",
             type => 'string',
         },
         username => {
-            description => "RBD Id.",
+            title => "Username",
+            description => "The username used for authentication.",
             type => 'string',
         },
         authsupported => {
@@ -444,11 +450,13 @@ sub properties {
             type => 'string',
         },
         krbd => {
+            title => "KRBD",
             description => "Always access rbd through krbd kernel module.",
             type => 'boolean',
             default => 0,
         },
         keyring => {
+            title => "Keyring",
             description => "Client keyring contents (for external clusters).",
             type => 'string',
         },
diff --git a/src/PVE/Storage/ZFSPlugin.pm b/src/PVE/Storage/ZFSPlugin.pm
index 74e0a084..7fef9303 100644
--- a/src/PVE/Storage/ZFSPlugin.pm
+++ b/src/PVE/Storage/ZFSPlugin.pm
@@ -183,29 +183,35 @@ sub plugindata {
 sub properties {
     return {
         iscsiprovider => {
-            description => "iscsi provider",
+            title => "iSCSI Provider",
+            description => "The iSCSI target implementation used on the remote machine.",
             type => 'string',
         },
         # this will disable write caching on comstar and istgt.
         # it is not implemented for iet. iet blockio always operates with
         # writethrough caching when not in readonly mode
         nowritecache => {
-            description => "disable write caching on the target",
+            title => "Disable Write Cache",
+            description => "Disable write caching on the iSCSI target.",
             type => 'boolean',
         },
         comstar_tg => {
-            description => "target group for comstar views",
+            title => "Target Group",
+            description => "Target group for Comstar views.",
             type => 'string',
         },
         comstar_hg => {
-            description => "host group for comstar views",
+            title => "Host Group",
+            description => "Host group for Comstar views.",
             type => 'string',
         },
         lio_tpg => {
-            description => "target portal group for Linux LIO targets",
+            title => "Target Portal Group",
+            description => "Target portal group for Linux LIO targets.",
             type => 'string',
         },
         'zfs-base-path' => {
+            title => "Base Path",
             description => "Base path where to look for the created ZFS block devices. Set"
                 . " automatically during creation if not specified. Usually '/dev/zvol'.",
             type => 'string',
diff --git a/src/PVE/Storage/ZFSPoolPlugin.pm b/src/PVE/Storage/ZFSPoolPlugin.pm
index 86307449..0612c25d 100644
--- a/src/PVE/Storage/ZFSPoolPlugin.pm
+++ b/src/PVE/Storage/ZFSPoolPlugin.pm
@@ -63,17 +63,20 @@ sub plugindata {
 sub properties {
     return {
         blocksize => {
-            description => "ZFS block size",
+            title => "Block Size",
+            description => "ZFS block size.",
             type => 'string',
             format => 'pve-storage-zfs-blocksize',
             format_description => 'a power of 2 with optional k or m suffix',
         },
         sparse => {
-            description => "use sparse volumes",
+            title => "Thin Provision",
+            description => "Use sparse (thin‑provisioned) volumes.",
             type => 'boolean',
         },
         mountpoint => {
-            description => "mount point",
+            title => "Mountpoint",
+            description => "Path to the directory where the storage is mounted.",
             type => 'string',
             format => 'pve-storage-path',
         },
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 14+ messages in thread

* [PATCH proxmox-widget-toolkit 08/13] form: introduce new 'proxmoxtextarea' field
  2026-06-23 14:33 [PATCH common/manager/proxmox-widget-toolkit/storage 00/13] GUI Support for Custom Storage Plugins Max R. Carrara
                   ` (6 preceding siblings ...)
  2026-06-23 14:33 ` [PATCH pve-storage 07/13] all plugins: add 'title' to properties, adapt 'description's Max R. Carrara
@ 2026-06-23 14:33 ` Max R. Carrara
  2026-06-23 14:33 ` [PATCH proxmox-widget-toolkit 09/13] utils: introduce helper function getFieldDefFromPropertySchema Max R. Carrara
                   ` (4 subsequent siblings)
  12 siblings, 0 replies; 14+ messages in thread
From: Max R. Carrara @ 2026-06-23 14:33 UTC (permalink / raw)
  To: pve-devel

Define a new field named `proxmoxtextarea` and let it extend the
`TextArea` field that Ext JS ships.

`proxmoxtextarea` uses the exact same logic as `proxmoxtextfield`.
This means that it also supports the `skipEmptyText` attribute, which
is used to omit sending an empty string to the backend if the field is
left empty.

Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
 src/form/TextAreaField.js | 47 +++++++++++++++++++++++++++++++++++++++
 1 file changed, 47 insertions(+)

diff --git a/src/form/TextAreaField.js b/src/form/TextAreaField.js
index f4b0bb4..ae2e922 100644
--- a/src/form/TextAreaField.js
+++ b/src/form/TextAreaField.js
@@ -59,3 +59,50 @@ Ext.define('Proxmox.form.field.Base64TextArea', {
         this.validate();
     },
 });
+
+Ext.define('Proxmox.form.field.TextArea', {
+    extend: 'Ext.form.field.TextArea',
+    alias: ['widget.proxmoxtextarea'],
+
+    config: {
+        skipEmptyText: true,
+        deleteEmpty: false,
+        trimValue: false,
+    },
+
+    getSubmitData: function () {
+        let me = this,
+            data = null,
+            val;
+        if (!me.disabled && me.submitValue && !me.isFileUpload()) {
+            val = me.getSubmitValue();
+            if (val !== null) {
+                data = {};
+                data[me.getName()] = val;
+            } else if (me.getDeleteEmpty()) {
+                data = {};
+                data.delete = me.getName();
+            }
+        }
+        return data;
+    },
+
+    getSubmitValue: function () {
+        let me = this;
+
+        let value = this.processRawValue(this.getRawValue());
+        if (me.getTrimValue() && typeof value === 'string') {
+            value = value.trim();
+        }
+        if (value !== '') {
+            return value;
+        }
+
+        return me.getSkipEmptyText() ? null : value;
+    },
+
+    setAllowBlank: function (allowBlank) {
+        this.allowBlank = allowBlank;
+        this.validate();
+    },
+});
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 14+ messages in thread

* [PATCH proxmox-widget-toolkit 09/13] utils: introduce helper function getFieldDefFromPropertySchema
  2026-06-23 14:33 [PATCH common/manager/proxmox-widget-toolkit/storage 00/13] GUI Support for Custom Storage Plugins Max R. Carrara
                   ` (7 preceding siblings ...)
  2026-06-23 14:33 ` [PATCH proxmox-widget-toolkit 08/13] form: introduce new 'proxmoxtextarea' field Max R. Carrara
@ 2026-06-23 14:33 ` Max R. Carrara
  2026-06-23 14:33 ` [PATCH proxmox-widget-toolkit 10/13] acme: use helper to construct ExtJS fields from property schemas Max R. Carrara
                   ` (3 subsequent siblings)
  12 siblings, 0 replies; 14+ messages in thread
From: Max R. Carrara @ 2026-06-23 14:33 UTC (permalink / raw)
  To: pve-devel

This helper takes the JSON schema of a single property and transforms
it into an object representing an Ext JS field.

Currently, four types for properties are supported: `string`,
`integer`, `number` and `boolean`. Many JSON schema validation
keywords are also supported and converted to Ext JS analogues.

Types --> Ext JS field types:
- boolean --> proxmoxcheckbox
- integer --> proxmoxintegerfield
- number  --> numberfield
- string  --> proxmoxtextfield

Keywords --> Ext JS properties / behavior:
- minLength   --> minLength
- maxLength   --> maxLength
- minimum     --> minValue
- maximum     --> maxValue
- title       --> fieldLabel (using the field's name if no title)
- optional    --> allowBlank
- description --> shown on hover
- enum        --> use proxmoxKVComboBox as field instead
- default     --> set as `value` of the field and use as `emptyText`

Furthermore, add support for the `multiline` JSON schema `format`.
This means that any property with the `multiline` format becomes a
`proxmoxtextarea` field.

Additionally, the helper also takes "extra attributes" and a "context"
which allows for more fine-grained control over how the ExtJS field is
actually constructed and also makes it possible to express different
UI-specific behaviors that cannot be modeled via JSONSchema.

The only variable in the context is `isCreate`, which is used to hint
whether the field is used when creating a record or not.

The following extra attributes are added:

- sensitive

  Marks a field as "sensitive", which is useful for password fields
  for example. This causes most fields to become `proxmoxtextfield`s
  instead, with their inputs obfuscated.

  Fields with the `multiline` format are kept as `proxmoxtextarea` to
  allow copy-pasting private keys into the field or similar.

- readonly

  Causes a field to become a `displayfield` instead. Boolean fields
  are rendered as "Yes / No" (locale-aware).

Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
 src/Utils.js | 150 +++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 150 insertions(+)

diff --git a/src/Utils.js b/src/Utils.js
index 14a90bf..f41080f 100644
--- a/src/Utils.js
+++ b/src/Utils.js
@@ -1600,6 +1600,156 @@ Ext.define('Proxmox.Utils', {
             }
             return degrees;
         },
+
+        /**
+         * Takes a JSONSchema of a single property and transforms it into an
+         * object representing an ExtJS field.
+         *
+         * @param {string} fieldName - The value for the `name` property of the resulting object.
+         * @param {Object} propSchema - The JSON schema of a property.
+         * @param {Object} extraAttributes - Additional attributes describing the
+         * field which do not exist as keywords in JSON schema.
+         * Note that none of these attributes represent any specific ExtJS property.
+         * @param {Boolean} extraAttributes.readonly - Whether the field shall be read-only.
+         * "Read-only" here means that the field's `xtype` will be set to
+         * `'displayfield'`, regardless of what data type the field will
+         * display. Additionally, checkboxes (booleans) will be displayed as
+         * "Yes" or "No" depending on their value (locale-aware).
+         * @param {Boolean} extraAttributes.sensitive - Whether the field
+         * contains sensitive data, such as a password or similar.
+         * @param {Object} context - The context within which the field is being used.
+         * @param {Boolean} context.isCreate - Whether the field is used when creating a record.
+         */
+        getFieldDefFromPropertySchema: function (fieldName, propSchema, extraAttributes, context) {
+            const fieldPropsByType = {
+                string: {
+                    xtype: 'proxmoxtextfield',
+                    minLength: propSchema.minLength,
+                    maxLength: propSchema.maxLength,
+                },
+                integer: {
+                    xtype: 'proxmoxintegerfield',
+                    minValue: propSchema.minimum,
+                    maxValue: propSchema.maximum,
+                },
+                number: {
+                    xtype: 'numberfield',
+                    minValue: propSchema.minimum,
+                    maxValue: propSchema.maximum,
+                },
+                boolean: {
+                    xtype: 'proxmoxcheckbox',
+                    uncheckedValue: 0,
+                },
+            };
+
+            const commonFieldProps = {
+                name: fieldName,
+                fieldLabel: Ext.htmlEncode(fieldName),
+                emptyText: propSchema.default || '',
+                allowBlank: propSchema.optional,
+            };
+
+            const fieldPropsByFormat = {
+                multiline: {
+                    xtype: 'proxmoxtextarea',
+                },
+            };
+
+            const anyToBool = (value) => {
+                if (typeof value === 'string' || value instanceof String) {
+                    value = value.trim().toLowerCase();
+                    return ['y', 'yes', 'true'].includes(value);
+                }
+
+                return Boolean(value);
+            };
+
+            let fieldProps = fieldPropsByType[propSchema.type];
+            if (fieldProps === undefined) {
+                console.warn(`Unhandled property type '${propSchema.type}'`);
+                fieldProps = fieldPropsByType.string;
+            }
+
+            let formatFieldProps = fieldPropsByFormat[propSchema.format] ?? {};
+
+            let field = {
+                ...fieldProps,
+                ...commonFieldProps,
+                ...formatFieldProps,
+            };
+
+            if (propSchema.title) {
+                field.fieldLabel = Ext.htmlEncode(propSchema.title);
+            }
+
+            if (propSchema.description) {
+                field.autoEl = {
+                    tag: 'div',
+                    'data-qtip': Ext.htmlEncode(Ext.htmlEncode(propSchema.description)),
+                };
+            }
+
+            if (propSchema.enum) {
+                const comboBoxProps = {
+                    xtype: 'proxmoxKVComboBox',
+                    comboItems: propSchema.enum.map((item) => [item, Ext.htmlEncode(item)]),
+                    deleteEmpty: !context.isCreate,
+                    editable: true,
+                };
+
+                Object.assign(field, comboBoxProps);
+            }
+
+            if (context.isCreate && propSchema.default !== undefined) {
+                if (propSchema.type === 'boolean') {
+                    field.checked = anyToBool(propSchema.default);
+                }
+
+                if (field.xtype === 'proxmoxKVComboBox') {
+                    let defaultText =
+                        Proxmox.Utils.defaultText + ' (' + Ext.htmlEncode(propSchema.default) + ')';
+
+                    field.comboItems.unshift(['__default__', defaultText]);
+                    field.value = '__default__';
+                } else {
+                    field.value = propSchema.default;
+                }
+            }
+
+            if (extraAttributes.sensitive) {
+                // textarea fields for multiline strings stay as-is, because
+                // Ext JS will turn them into <input> elements otherwise, which
+                // in turn causes newlines to be replaced with spaces.
+                // 'inputType' unfortunately has no effect for textarea fields.
+                if (field.xtype !== 'proxmoxtextarea') {
+                    field.xtype = 'proxmoxtextfield';
+                    field.inputType = 'password';
+                }
+
+                if (context.isCreate) {
+                    field.value = '';
+                    field.emptyText = Proxmox.Utils.NoneText;
+                } else {
+                    field.emptyText = gettext('Unchanged');
+                }
+            }
+
+            if (extraAttributes.readonly) {
+                field.xtype = 'displayfield';
+
+                if (propSchema.type === 'boolean') {
+                    field.renderer = Proxmox.Utils.format_boolean;
+                }
+
+                if (extraAttributes.sensitive) {
+                    field.value = '*********';
+                    field.emptyText = '';
+                }
+            }
+
+            return field;
+        },
     },
 
     singleton: true,
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 14+ messages in thread

* [PATCH proxmox-widget-toolkit 10/13] acme: use helper to construct ExtJS fields from property schemas
  2026-06-23 14:33 [PATCH common/manager/proxmox-widget-toolkit/storage 00/13] GUI Support for Custom Storage Plugins Max R. Carrara
                   ` (8 preceding siblings ...)
  2026-06-23 14:33 ` [PATCH proxmox-widget-toolkit 09/13] utils: introduce helper function getFieldDefFromPropertySchema Max R. Carrara
@ 2026-06-23 14:33 ` Max R. Carrara
  2026-06-23 14:33 ` [PATCH pve-manager 11/13] api: add API routes 'plugins' and 'plugins/storage' Max R. Carrara
                   ` (2 subsequent siblings)
  12 siblings, 0 replies; 14+ messages in thread
From: Max R. Carrara @ 2026-06-23 14:33 UTC (permalink / raw)
  To: pve-devel

Use the new `getFieldDefFromPropertySchema()` helper function to
simplify some of the logic in `ACMEPluginEdit.js`.

This results in no noticeable difference when setting up ACME challenge
plugins, except for the fact that fields not marked as optional are
now in fact not optional in the UI.

Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
 src/window/ACMEPluginEdit.js | 42 +++++++++++-------------------------
 1 file changed, 13 insertions(+), 29 deletions(-)

diff --git a/src/window/ACMEPluginEdit.js b/src/window/ACMEPluginEdit.js
index 900923b..d8591ae 100644
--- a/src/window/ACMEPluginEdit.js
+++ b/src/window/ACMEPluginEdit.js
@@ -69,45 +69,29 @@ Ext.define('Proxmox.window.ACMEPluginEdit', {
                 for (const [name, definition] of Object.entries(schema.fields).sort((a, b) =>
                     a[0].localeCompare(b[0]),
                 )) {
-                    let xtype;
-                    switch (definition.type) {
-                        case 'string':
-                            xtype = 'proxmoxtextfield';
-                            break;
-                        case 'integer':
-                            xtype = 'proxmoxintegerfield';
-                            break;
-                        case 'number':
-                            xtype = 'numberfield';
-                            break;
-                        default:
-                            console.warn(`unknown type '${definition.type}'`);
-                            xtype = 'proxmoxtextfield';
-                            break;
-                    }
+                    let fieldName = `custom_${name}`;
+                    let context = { isCreate: me.isCreate };
+
+                    let fieldDef = Proxmox.Utils.getFieldDefFromPropertySchema(
+                        fieldName,
+                        definition,
+                        {},
+                        context,
+                    );
 
                     let label = name;
                     if (typeof definition.name === 'string') {
                         label = definition.name;
                     }
 
-                    let field = Ext.create({
-                        xtype,
-                        name: `custom_${name}`,
+                    let extraProps = {
                         fieldLabel: Ext.htmlEncode(label),
                         width: '100%',
                         labelWidth: 150,
                         labelSeparator: '=',
-                        emptyText: definition.default || '',
-                        autoEl: definition.description
-                            ? {
-                                  tag: 'div',
-                                  'data-qtip': Ext.htmlEncode(
-                                      Ext.htmlEncode(definition.description),
-                                  ),
-                              }
-                            : undefined,
-                    });
+                    };
+
+                    let field = Ext.create({ ...fieldDef, ...extraProps });
 
                     me.createdFields[name] = field;
                     container.add(field);
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 14+ messages in thread

* [PATCH pve-manager 11/13] api: add API routes 'plugins' and 'plugins/storage'
  2026-06-23 14:33 [PATCH common/manager/proxmox-widget-toolkit/storage 00/13] GUI Support for Custom Storage Plugins Max R. Carrara
                   ` (9 preceding siblings ...)
  2026-06-23 14:33 ` [PATCH proxmox-widget-toolkit 10/13] acme: use helper to construct ExtJS fields from property schemas Max R. Carrara
@ 2026-06-23 14:33 ` Max R. Carrara
  2026-06-23 14:33 ` [PATCH pve-manager 12/13] ui: storage view: display error when no editor for storage type exists Max R. Carrara
  2026-06-23 14:33 ` [PATCH pve-manager 13/13] ui: storage: add basic UI integration for custom storage plugins Max R. Carrara
  12 siblings, 0 replies; 14+ messages in thread
From: Max R. Carrara @ 2026-06-23 14:33 UTC (permalink / raw)
  To: pve-devel

Add the `plugins` route to our API and handle it via its own module,
`API2/Plugins.pm`. Implement a GET endpoint for this route, which just
lists its subpaths.

Additionally, add the `plugins/storage` path to the API and let all
corresponding endpoints be handled by the
`PVE::API2::Plugins::Storage` module, which is part of the
`libpve-storage-perl` package.

This means that the following routes are now available:
* plugins
* plugins/storage
* plugins/storage/plugin
* plugins/storage/plugin/{type}

Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
 PVE/API2.pm         |  6 +++++
 PVE/API2/Makefile   |  1 +
 PVE/API2/Plugins.pm | 61 +++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 68 insertions(+)
 create mode 100644 PVE/API2/Plugins.pm

diff --git a/PVE/API2.pm b/PVE/API2.pm
index 0c7e4654..af1cf38b 100644
--- a/PVE/API2.pm
+++ b/PVE/API2.pm
@@ -17,6 +17,7 @@ use PVE::API2::Nodes;
 use PVE::API2::Pool;
 use PVE::API2::AccessControl;
 use PVE::API2::Storage::Config;
+use PVE::API2::Plugins;
 
 __PACKAGE__->register_method({
     subclass => "PVE::API2::Cluster",
@@ -43,6 +44,11 @@ __PACKAGE__->register_method({
     path => 'pools',
 });
 
+__PACKAGE__->register_method({
+    subclass => "PVE::API2::Plugins",
+    path => 'plugins',
+});
+
 __PACKAGE__->register_method({
     name => 'index',
     path => '',
diff --git a/PVE/API2/Makefile b/PVE/API2/Makefile
index 97f1cc20..97910009 100644
--- a/PVE/API2/Makefile
+++ b/PVE/API2/Makefile
@@ -17,6 +17,7 @@ PERLSOURCE = 			\
 	Network.pm		\
 	NodeConfig.pm		\
 	Nodes.pm		\
+	Plugins.pm		\
 	Pool.pm			\
 	Replication.pm		\
 	ReplicationConfig.pm	\
diff --git a/PVE/API2/Plugins.pm b/PVE/API2/Plugins.pm
new file mode 100644
index 00000000..beb606e6
--- /dev/null
+++ b/PVE/API2/Plugins.pm
@@ -0,0 +1,61 @@
+package PVE::API2::Plugins;
+
+use v5.36;
+
+use PVE::RESTHandler;
+use base qw(PVE::RESTHandler);
+
+use PVE::API2::Plugins::Storage;
+
+__PACKAGE__->register_method({
+    subclass => "PVE::API2::Plugins::Storage",
+    path => 'storage',
+});
+
+__PACKAGE__->register_method({
+    name => 'index',
+    path => '',
+    method => 'GET',
+    permissions => {
+        user => 'all',
+    },
+    description => "Directory index.",
+    parameters => {
+        additionalProperties => 0,
+        properties => {},
+    },
+    returns => {
+        type => 'array',
+        items => {
+            type => 'object',
+            properties => {
+                subdir => {
+                    type => 'string',
+                },
+            },
+        },
+        links => [
+            {
+                rel => 'child',
+                href => "{subdir}",
+            },
+        ],
+    },
+    code => sub($param) {
+        my $result = [];
+
+        my $attrs = PVE::API2::Plugins->method_attributes();
+
+        for my $info ($attrs->@*) {
+            next if !$info->{subclass};
+
+            my $subpath = $info->{match_re}->[0];
+
+            push $result->@*, { subdir => $subpath };
+        }
+
+        return $result;
+    },
+});
+
+1;
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 14+ messages in thread

* [PATCH pve-manager 12/13] ui: storage view: display error when no editor for storage type exists
  2026-06-23 14:33 [PATCH common/manager/proxmox-widget-toolkit/storage 00/13] GUI Support for Custom Storage Plugins Max R. Carrara
                   ` (10 preceding siblings ...)
  2026-06-23 14:33 ` [PATCH pve-manager 11/13] api: add API routes 'plugins' and 'plugins/storage' Max R. Carrara
@ 2026-06-23 14:33 ` Max R. Carrara
  2026-06-23 14:33 ` [PATCH pve-manager 13/13] ui: storage: add basic UI integration for custom storage plugins Max R. Carrara
  12 siblings, 0 replies; 14+ messages in thread
From: Max R. Carrara @ 2026-06-23 14:33 UTC (permalink / raw)
  To: pve-devel

... instead of `throw`ing an exception which gets swallowed by ExtJS
and never displayed to the user.

Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
 www/manager6/dc/StorageView.js | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/www/manager6/dc/StorageView.js b/www/manager6/dc/StorageView.js
index e4c6f07d..bcc02ed5 100644
--- a/www/manager6/dc/StorageView.js
+++ b/www/manager6/dc/StorageView.js
@@ -13,7 +13,8 @@ Ext.define(
         createStorageEditWindow: function (type, sid) {
             let schema = PVE.Utils.storageSchema[type];
             if (!schema || !schema.ipanel) {
-                throw 'no editor registered for storage type: ' + type;
+                Ext.Msg.alert(gettext('Error'), `No editor registered for storage type '${type}'`);
+                return;
             }
 
             Ext.create('PVE.storage.BaseEdit', {
-- 
2.47.3





^ permalink raw reply related	[flat|nested] 14+ messages in thread

* [PATCH pve-manager 13/13] ui: storage: add basic UI integration for custom storage plugins
  2026-06-23 14:33 [PATCH common/manager/proxmox-widget-toolkit/storage 00/13] GUI Support for Custom Storage Plugins Max R. Carrara
                   ` (11 preceding siblings ...)
  2026-06-23 14:33 ` [PATCH pve-manager 12/13] ui: storage view: display error when no editor for storage type exists Max R. Carrara
@ 2026-06-23 14:33 ` Max R. Carrara
  12 siblings, 0 replies; 14+ messages in thread
From: Max R. Carrara @ 2026-06-23 14:33 UTC (permalink / raw)
  To: pve-devel

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





^ permalink raw reply related	[flat|nested] 14+ messages in thread

end of thread, other threads:[~2026-06-23 14:36 UTC | newest]

Thread overview: 14+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-06-23 14:33 [PATCH common/manager/proxmox-widget-toolkit/storage 00/13] GUI Support for Custom Storage Plugins Max R. Carrara
2026-06-23 14:33 ` [PATCH pve-common 01/13] json schema: add multiline string format Max R. Carrara
2026-06-23 14:33 ` [PATCH pve-storage 02/13] api: plugins/storage: add initial routes and endpoints Max R. Carrara
2026-06-23 14:33 ` [PATCH pve-storage 03/13] api: plugins/storage/plugin: include schema in plugin metadata Max R. Carrara
2026-06-23 14:33 ` [PATCH pve-storage 04/13] api: plugins/storage/plugin: mark sensitive properties in schema Max R. Carrara
2026-06-23 14:33 ` [PATCH pve-storage 05/13] api: plugins/storage/plugin: factor plugin metadata code into helper Max R. Carrara
2026-06-23 14:33 ` [PATCH pve-storage 06/13] api: plugins/storage/plugin: add plugins' 'content' to their metadata Max R. Carrara
2026-06-23 14:33 ` [PATCH pve-storage 07/13] all plugins: add 'title' to properties, adapt 'description's Max R. Carrara
2026-06-23 14:33 ` [PATCH proxmox-widget-toolkit 08/13] form: introduce new 'proxmoxtextarea' field Max R. Carrara
2026-06-23 14:33 ` [PATCH proxmox-widget-toolkit 09/13] utils: introduce helper function getFieldDefFromPropertySchema Max R. Carrara
2026-06-23 14:33 ` [PATCH proxmox-widget-toolkit 10/13] acme: use helper to construct ExtJS fields from property schemas Max R. Carrara
2026-06-23 14:33 ` [PATCH pve-manager 11/13] api: add API routes 'plugins' and 'plugins/storage' Max R. Carrara
2026-06-23 14:33 ` [PATCH pve-manager 12/13] ui: storage view: display error when no editor for storage type exists Max R. Carrara
2026-06-23 14:33 ` [PATCH pve-manager 13/13] ui: storage: add basic UI integration for custom storage plugins Max R. Carrara

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