* [PATCH common/manager/proxmox-widget-toolkit/storage/storage-plugin-examples v4 00/17] GUI Support for Custom Storage Plugins
@ 2026-11-06 15:45 Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-common v4 01/17] json schema: add multiline string format Max R. Carrara
` (16 more replies)
0 siblings, 17 replies; 18+ messages in thread
From: Max R. Carrara @ 2026-11-06 15:45 UTC (permalink / raw)
To: pve-devel; +Cc: Alfonso Kuen, Thomas Ellmenreich, Jakob Klocker
GUI Support for Custom Storage Plugins - v4
===========================================
This refresh should now *hopefully* cover the remaining issues that were
found during reviewing, and also some other things that were found
off-list.
Huge thanks to the reviewers & testers @Thomas E., @Jakob K., and
@Alfonso K. -- I appreciate your work a lot!
Since a lot of little and not-so-little things changed here and there, I
have not included any previous Reviewed-by / Tested-by git trailers.
I have smoke-tested the series myself using the SSHFS plugin, but any
additional testing would be much appreciated.
Maintainer Notice
-----------------
- pve-storage needs a version bump on its libpve-common-perl dependency,
since third-party storage plugins might now start using the new
`multiline` format and have pve-common as a transitive dependency
- pve-manager needs version bumps on its libpve-storage-perl and
proxmox-widget-toolkit dependencies, due to the API changes and the
new helper in Utils.js
- libpve-storage-plugin-sshfs-perl needs a version bump on
libpve-storage-perl, since it now uses the `multiline` string format
for one of its properties, but does not explicitly depend on
libpve-common-perl
Building
--------
Note that building pve-manager directly depends on the changes made to
pve-storage, so you'll have to install the storage package in your build
env first before building pve-manager. There shouldn't be any other
issues otherwise, if I'm not mistaken.
Notable Changes Since v3
------------------------
- Rebase on latest master.
- pve-common: Keep the `multiline` format as it is.
There was one last remaining design decision to make, namely whether
to support the `multiline` format "natively" inside SectionConfig,
perhaps encoding the multiline string in base64 or something similar,
since SectionConfig file format syntax has no concept of multiline
strings.
Since multiline strings are rarely used wherever we use SectionConfig,
I decided to keep the format as it is, and not support it in SC. Sites
that use this format are instead expected to handle it themselves.
The main reason why this format is added is to be able to let users
upload secrets like private keys or other kinds of credentials, and
those things should be stored in `/etc/pve/priv` anyway, not in
world-readable configuration files.
- pve-storage: Ensure that the `optional` key for a property is *always*
set to what's in the plugin's `options()` method. This avoids
mismatches between what the UI allows and what the backend allows.
- pve-storage: Likewise, the `x-sensitive`, `x-advanced`, and `x-hidden`
keys are *always* set for each property, even if their resp. hashes
were not defined in `plugindata()`.
- pve-storage: Use `PVE::Storage::Plugin::sensitive_properties()` to
determine which properties should be marked with the `x-sensitive`
key, since that helper also takes the old, hard-coded sensitive
properties into account.
- pve-storage: The `content-dirs` property is no longer marked as
`x-advanced`, since it is `x-hidden` anyway.
- proxmox-widget-toolkit: Handle default values for booleans
(checkboxes) and enums (comboboxes) correctly. Checkboxes in
particular always set a value, even if left empty; that behavior is
now accounted for.
- proxmox-widget-toolkit: For the ACME challenge plugins, always mark
all properties as optional in order to restore original behavior.
It went a bit under my radar that some providers support
alternative credentials as well. In previous series, a lot of fields
became required, since the ACME schema was used as is. This meant that
the UI wouldn't accept certain configurations anymore.
Therefore, all ACME fields are marked as optional now (again), which
restores the previous behavior.
- pve-manager: Wrap missing translatable strings with `gettext()`.
- pve-manager: Instead of waiting for the request that fetches the
custom storage plugin metadata to receive a response, *append* custom
storage plugins to the "Add" droptown menu once the request succeeds.
Built-in storage types therefore do not depend on this request now,
and can still be edited right away, and also if the request fails for
some reason.
Editing a custom storage type before the request finishes opens the
editor once it does.
- pve-manager: Handle the `none` content type in the UI as well, in the
rare case where a custom plugin might actually use it.
- pve-manager: Ensure that defaults of checkboxes do not end up on the
config if left unchanged.
- pve-storage-plugin-examples: Update to APIVER 16 instead of 15.
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/
v1: https://lore.proxmox.com/pve-devel/20260623143402.772452-1-m.carrara@proxmox.com/
v2: https://lore.proxmox.com/pve-devel/20260717154943.696411-1-m.carrara@proxmox.com/
v3: https://lore.proxmox.com/pve-devel/20260727143445.513900-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 (8):
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
all plugins, api: mark certain properties as advanced in plugindata()
all plugins, api: mark certain properties as hidden in plugindata()
src/PVE/API2/Makefile | 1 +
src/PVE/API2/Plugins/Makefile | 18 +++
src/PVE/API2/Plugins/Storage.pm | 52 +++++++
src/PVE/API2/Plugins/Storage/Makefile | 17 +++
src/PVE/API2/Plugins/Storage/Plugin.pm | 188 +++++++++++++++++++++++++
src/PVE/Storage/BTRFSPlugin.pm | 9 ++
src/PVE/Storage/CIFSPlugin.pm | 21 ++-
src/PVE/Storage/CephFSPlugin.pm | 14 +-
src/PVE/Storage/DirPlugin.pm | 22 ++-
src/PVE/Storage/ESXiPlugin.pm | 3 +
src/PVE/Storage/ISCSIDirectPlugin.pm | 4 +
src/PVE/Storage/ISCSIPlugin.pm | 10 +-
src/PVE/Storage/LVMPlugin.pm | 17 ++-
src/PVE/Storage/LvmThinPlugin.pm | 7 +-
src/PVE/Storage/NFSPlugin.pm | 17 ++-
src/PVE/Storage/PBSPlugin.pm | 7 +-
src/PVE/Storage/Plugin.pm | 46 ++++--
src/PVE/Storage/RBDPlugin.pm | 20 ++-
src/PVE/Storage/ZFSPlugin.pm | 20 ++-
src/PVE/Storage/ZFSPoolPlugin.pm | 13 +-
20 files changed, 469 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 | 202 +++++++++++++++++++++++++++++++++++
src/form/TextAreaField.js | 47 ++++++++
src/window/ACMEPluginEdit.js | 47 ++++----
3 files changed, 267 insertions(+), 29 deletions(-)
pve-manager:
Max R. Carrara (4):
api: sort modules and API method registrations
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 | 26 +--
PVE/API2/Makefile | 1 +
PVE/API2/Plugins.pm | 61 +++++++
www/manager6/Makefile | 1 +
www/manager6/dc/StorageView.js | 102 +++++++++++-
www/manager6/storage/Base.js | 7 +-
www/manager6/storage/CustomEdit.js | 254 +++++++++++++++++++++++++++++
7 files changed, 433 insertions(+), 19 deletions(-)
create mode 100644 PVE/API2/Plugins.pm
create mode 100644 www/manager6/storage/CustomEdit.js
pve-storage-plugin-examples:
Max R. Carrara (1):
sshfs: Update SSHFS plugin
.../src/PVE/Storage/Custom/SSHFSPlugin.pm | 26 ++++++++++++++-----
1 file changed, 20 insertions(+), 6 deletions(-)
Summary over all repositories:
32 files changed, 1194 insertions(+), 91 deletions(-)
--
Generated by murpp 0.12.0
^ permalink raw reply [flat|nested] 18+ messages in thread
* [PATCH pve-common v4 01/17] json schema: add multiline string format
2026-11-06 15:45 [PATCH common/manager/proxmox-widget-toolkit/storage/storage-plugin-examples v4 00/17] GUI Support for Custom Storage Plugins Max R. Carrara
@ 2026-11-06 15:45 ` Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-storage v4 02/17] api: plugins/storage: add initial routes and endpoints Max R. Carrara
` (15 subsequent siblings)
16 siblings, 0 replies; 18+ messages in thread
From: Max R. Carrara @ 2026-11-06 15:45 UTC (permalink / raw)
To: pve-devel; +Cc: Alfonso Kuen, Thomas Ellmenreich, Jakob Klocker
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 34f2949..5790a32 100644
--- a/src/PVE/JSONSchema.pm
+++ b/src/PVE/JSONSchema.pm
@@ -236,10 +236,15 @@ sub get_renderer {
# register some common type for pve
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 {
my ($text, $noerr) = @_;
if ($text !~ /^[-%a-zA-Z0-9_.!~*'()]*\z/) {
--
2.47.3
^ permalink raw reply related [flat|nested] 18+ messages in thread
* [PATCH pve-storage v4 02/17] api: plugins/storage: add initial routes and endpoints
2026-11-06 15:45 [PATCH common/manager/proxmox-widget-toolkit/storage/storage-plugin-examples v4 00/17] GUI Support for Custom Storage Plugins Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-common v4 01/17] json schema: add multiline string format Max R. Carrara
@ 2026-11-06 15:45 ` Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-storage v4 03/17] api: plugins/storage/plugin: include schema in plugin metadata Max R. Carrara
` (14 subsequent siblings)
16 siblings, 0 replies; 18+ messages in thread
From: Max R. Carrara @ 2026-11-06 15:45 UTC (permalink / raw)
To: pve-devel; +Cc: Alfonso Kuen, Thomas Ellmenreich, Jakob Klocker
Add the following routes / endpoints:
* `GET plugins/storage`
Directory index, listing 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 | 52 ++++++++++++
src/PVE/API2/Plugins/Storage/Makefile | 17 ++++
src/PVE/API2/Plugins/Storage/Plugin.pm | 106 +++++++++++++++++++++++++
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
@@ -3,5 +3,6 @@
.PHONY: install
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..988e84b2
--- /dev/null
+++ b/src/PVE/API2/Plugins/Storage.pm
@@ -0,0 +1,52 @@
+package PVE::API2::Plugins::Storage;
+
+use v5.36;
+
+use PVE::RESTHandler;
+
+use PVE::API2::Plugins::Storage::Plugin;
+
+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..e5d6b04f
--- /dev/null
+++ b/src/PVE/API2/Plugins/Storage/Plugin.pm
@@ -0,0 +1,106 @@
+package PVE::API2::Plugins::Storage::Plugin;
+
+use v5.36;
+
+use HTTP::Status qw(:constants);
+
+use PVE::Exception qw(raise);
+use PVE::RESTHandler;
+use PVE::Storage;
+use PVE::Storage::Plugin;
+use PVE::Tools qw(extract_param);
+
+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 => {
+ description => 'The type of the storage plugin.',
+ 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] 18+ messages in thread
* [PATCH pve-storage v4 03/17] api: plugins/storage/plugin: include schema in plugin metadata
2026-11-06 15:45 [PATCH common/manager/proxmox-widget-toolkit/storage/storage-plugin-examples v4 00/17] GUI Support for Custom Storage Plugins Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-common v4 01/17] json schema: add multiline string format Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-storage v4 02/17] api: plugins/storage: add initial routes and endpoints Max R. Carrara
@ 2026-11-06 15:45 ` Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-storage v4 04/17] api: plugins/storage/plugin: mark sensitive properties in schema Max R. Carrara
` (13 subsequent siblings)
16 siblings, 0 replies; 18+ messages in thread
From: Max R. Carrara @ 2026-11-06 15:45 UTC (permalink / raw)
To: pve-devel; +Cc: Alfonso Kuen, Thomas Ellmenreich, Jakob Klocker
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, that is, 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 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.
Whether a property is optional for a plugin is taken from the plugin's
`options()` only, which is also what is enforced when the config is
checked. The property schemas themselves cannot be used for that, as
the global property list marks every plugin property as optional.
Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
src/PVE/API2/Plugins/Storage/Plugin.pm | 52 ++++++++++++++++++++++++++
1 file changed, 52 insertions(+)
diff --git a/src/PVE/API2/Plugins/Storage/Plugin.pm b/src/PVE/API2/Plugins/Storage/Plugin.pm
index e5d6b04f..07909c40 100644
--- a/src/PVE/API2/Plugins/Storage/Plugin.pm
+++ b/src/PVE/API2/Plugins/Storage/Plugin.pm
@@ -20,18 +20,68 @@ my $PLUGIN_METADATA_SCHEMA = {
properties => {
module => {
type => 'string',
optional => 0,
},
+ schema => {
+ type => 'object',
+ optional => 0,
+ },
type => {
type => 'string',
optional => 0,
enum => $ALL_PLUGIN_TYPES,
},
},
};
+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};
+ }
+
+ # NOTE: The global property list in SectionConfig marks every plugin
+ # property as optional. This means that we cannot rely on the
+ # `optional` key that the `get_property_schema` above returns for
+ # validation in the front end; it would mark required values as
+ # optional.
+ #
+ # Note that we also cannot rely on `createSchema()` / `updateSchema()`
+ # here for the same reason.
+ #
+ # Therefore, we override this key below with what the plugin's
+ # `options()` set directly. This makes it so that the front end
+ # validation matches the back end's.
+ #
+ # See the docstring for PVE::SectionConfig::properties() for more
+ # information.
+ my $is_optional = $options->{$option}->{optional};
+ $property->{optional} = $is_optional ? 1 : 0;
+ }
+
+ return $schema;
+}
+
# plugins/storage/plugin
__PACKAGE__->register_method({
name => 'index',
path => '',
@@ -54,10 +104,11 @@ __PACKAGE__->register_method({
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,
};
push($result->@*, $item);
}
@@ -94,10 +145,11 @@ __PACKAGE__->register_method({
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;
},
--
2.47.3
^ permalink raw reply related [flat|nested] 18+ messages in thread
* [PATCH pve-storage v4 04/17] api: plugins/storage/plugin: mark sensitive properties in schema
2026-11-06 15:45 [PATCH common/manager/proxmox-widget-toolkit/storage/storage-plugin-examples v4 00/17] GUI Support for Custom Storage Plugins Max R. Carrara
` (2 preceding siblings ...)
2026-11-06 15:45 ` [PATCH pve-storage v4 03/17] api: plugins/storage/plugin: include schema in plugin metadata Max R. Carrara
@ 2026-11-06 15:45 ` Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-storage v4 05/17] api: plugins/storage/plugin: factor plugin metadata code into helper Max R. Carrara
` (12 subsequent siblings)
16 siblings, 0 replies; 18+ messages in thread
From: Max R. Carrara @ 2026-11-06 15:45 UTC (permalink / raw)
To: pve-devel; +Cc: Alfonso Kuen, Thomas Ellmenreich, Jakob Klocker
Return a new 'x-sensitive' key as part of a plugin property's schema.
Take the information for this key from the same helper that the
storage config API uses to decide which parameters are sensitive. For
plugins without a 'sensitive-properties' hash in their `plugindata()`,
this helper falls back to a hardcoded list of properties. Otherwise, a
custom plugin reusing e.g. the 'password' property would get a plain
text field in the UI, although the API treats it as sensitive.
Always set the key to either 0 or 1, so that the returned schema has
the same shape for every property.
Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
src/PVE/API2/Plugins/Storage/Plugin.pm | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/src/PVE/API2/Plugins/Storage/Plugin.pm b/src/PVE/API2/Plugins/Storage/Plugin.pm
index 07909c40..12da9a12 100644
--- a/src/PVE/API2/Plugins/Storage/Plugin.pm
+++ b/src/PVE/API2/Plugins/Storage/Plugin.pm
@@ -40,20 +40,24 @@ 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();
+ # also covers the fallback for plugins without 'sensitive-properties' in their plugindata()
+ my $sensitive = { map { $_ => 1 } PVE::Storage::Plugin::sensitive_properties($type)->@* };
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->%* };
+ $property->{'x-sensitive'} = $sensitive->{$option} ? 1 : 0;
+
$schema->{$option} = $property;
for my $opt_key (keys $options->{$option}->%*) {
$property->{$opt_key} = $options->{$option}->{$opt_key};
}
--
2.47.3
^ permalink raw reply related [flat|nested] 18+ messages in thread
* [PATCH pve-storage v4 05/17] api: plugins/storage/plugin: factor plugin metadata code into helper
2026-11-06 15:45 [PATCH common/manager/proxmox-widget-toolkit/storage/storage-plugin-examples v4 00/17] GUI Support for Custom Storage Plugins Max R. Carrara
` (3 preceding siblings ...)
2026-11-06 15:45 ` [PATCH pve-storage v4 04/17] api: plugins/storage/plugin: mark sensitive properties in schema Max R. Carrara
@ 2026-11-06 15:45 ` Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-storage v4 06/17] api: plugins/storage/plugin: add plugins' 'content' to their metadata Max R. Carrara
` (11 subsequent siblings)
16 siblings, 0 replies; 18+ messages in thread
From: Max R. Carrara @ 2026-11-06 15:45 UTC (permalink / raw)
To: pve-devel; +Cc: Alfonso Kuen, Thomas Ellmenreich, Jakob Klocker
Factor the code that builds a plugin's metadata into a helper, 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 12da9a12..dbede811 100644
--- a/src/PVE/API2/Plugins/Storage/Plugin.pm
+++ b/src/PVE/API2/Plugins/Storage/Plugin.pm
@@ -82,10 +82,23 @@ 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({
name => 'index',
path => '',
@@ -104,18 +117,11 @@ __PACKAGE__->register_method({
},
code => sub($param) {
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);
}
return $result;
},
@@ -142,21 +148,10 @@ __PACKAGE__->register_method({
},
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,
- 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] 18+ messages in thread
* [PATCH pve-storage v4 06/17] api: plugins/storage/plugin: add plugins' 'content' to their metadata
2026-11-06 15:45 [PATCH common/manager/proxmox-widget-toolkit/storage/storage-plugin-examples v4 00/17] GUI Support for Custom Storage Plugins Max R. Carrara
` (4 preceding siblings ...)
2026-11-06 15:45 ` [PATCH pve-storage v4 05/17] api: plugins/storage/plugin: factor plugin metadata code into helper Max R. Carrara
@ 2026-11-06 15:45 ` Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-storage v4 07/17] all plugins: add 'title' to properties, adapt 'description's Max R. Carrara
` (10 subsequent siblings)
16 siblings, 0 replies; 18+ messages in thread
From: Max R. Carrara @ 2026-11-06 15:45 UTC (permalink / raw)
To: pve-devel; +Cc: Alfonso Kuen, Thomas Ellmenreich, Jakob Klocker
Return the content types that a plugin supports, as well as the ones it
enables by default, from the 'content' entry of its `plugindata()`.
The UI needs these to offer only the supported content types when
adding or editing a storage of a custom plugin, and to preselect the
default ones when adding it. This information is not part of the
property schema, as the 'content' property is shared by all plugins.
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 dbede811..906724ba 100644
--- a/src/PVE/API2/Plugins/Storage/Plugin.pm
+++ b/src/PVE/API2/Plugins/Storage/Plugin.pm
@@ -16,10 +16,31 @@ my $ALL_PLUGIN_TYPES = PVE::Storage::Plugin->lookup_types();
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,
},
schema => {
@@ -88,11 +109,17 @@ 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);
}
+ 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] 18+ messages in thread
* [PATCH pve-storage v4 07/17] all plugins: add 'title' to properties, adapt 'description's
2026-11-06 15:45 [PATCH common/manager/proxmox-widget-toolkit/storage/storage-plugin-examples v4 00/17] GUI Support for Custom Storage Plugins Max R. Carrara
` (5 preceding siblings ...)
2026-11-06 15:45 ` [PATCH pve-storage v4 06/17] api: plugins/storage/plugin: add plugins' 'content' to their metadata Max R. Carrara
@ 2026-11-06 15:45 ` Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-storage v4 08/17] all plugins, api: mark certain properties as advanced in plugindata() Max R. Carrara
` (9 subsequent siblings)
16 siblings, 0 replies; 18+ messages in thread
From: Max R. Carrara @ 2026-11-06 15:45 UTC (permalink / raw)
To: pve-devel; +Cc: Alfonso Kuen, Thomas Ellmenreich, Jakob Klocker
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 | 11 ++++++--
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, 110 insertions(+), 37 deletions(-)
diff --git a/src/PVE/Storage/BTRFSPlugin.pm b/src/PVE/Storage/BTRFSPlugin.pm
index fa28592e..166bf815 100644
--- a/src/PVE/Storage/BTRFSPlugin.pm
+++ b/src/PVE/Storage/BTRFSPlugin.pm
@@ -50,10 +50,11 @@ 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"
. " safe than on a single ext4 formatted disk with no underlying raid system.",
type => 'boolean',
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
@@ -130,25 +130,29 @@ 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.",
type => 'string',
default => 'default',
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
@@ -123,15 +123,17 @@ 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
@@ -43,39 +43,44 @@ 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',
},
mkdir => {
description =>
"Create the directory if it doesn't exist and populate it with default sub-dirs."
. " NOTE: Deprecated, use the 'create-base-path' and 'create-subdirs' options instead.",
type => 'boolean',
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" }),
};
}
sub options {
return {
diff --git a/src/PVE/Storage/ESXiPlugin.pm b/src/PVE/Storage/ESXiPlugin.pm
index bc2e4816..e0ea2837 100644
--- a/src/PVE/Storage/ESXiPlugin.pm
+++ b/src/PVE/Storage/ESXiPlugin.pm
@@ -37,10 +37,11 @@ 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',
default => 'false',
},
diff --git a/src/PVE/Storage/ISCSIPlugin.pm b/src/PVE/Storage/ISCSIPlugin.pm
index 3bba27a6..aa187d85 100644
--- a/src/PVE/Storage/ISCSIPlugin.pm
+++ b/src/PVE/Storage/ISCSIPlugin.pm
@@ -355,15 +355,17 @@ 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 a6a5eb70..ced87234 100644
--- a/src/PVE/Storage/LVMPlugin.pm
+++ b/src/PVE/Storage/LVMPlugin.pm
@@ -750,43 +750,50 @@ PVE::JSONSchema::register_format(
);
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 Volume",
+ description => "Volume ID 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',
},
'on-volume-remove' => {
+ title => "Volume Removal Actions",
description => "Optional actions when removing LVs.",
type => 'string',
format => 'on-volume-remove',
verbose_description => "Configure actions performed before removing an LV."
. " Use 'discard=1' to issue discard (TRIM) requests before removal.",
},
'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,
enum => [qw(1 2 4 8 16 32)],
type => 'integer',
},
saferemove_throughput => {
+ title => "Wipe Throughput",
description => "Wipe throughput in bytes per second (optional suffix: k, m, or g).",
type => 'string',
pattern => '-?\d+[kmgKMG]?',
},
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 6b953693..6cb0500d 100644
--- a/src/PVE/Storage/LvmThinPlugin.pm
+++ b/src/PVE/Storage/LvmThinPlugin.pm
@@ -41,11 +41,12 @@ 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
@@ -71,16 +71,18 @@ 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 6adafa34..d1c94653 100644
--- a/src/PVE/Storage/PBSPlugin.pm
+++ b/src/PVE/Storage/PBSPlugin.pm
@@ -40,21 +40,24 @@ 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 1d09b634..4a85bbcd 100644
--- a/src/PVE/Storage/Plugin.pm
+++ b/src/PVE/Storage/Plugin.pm
@@ -93,40 +93,48 @@ 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);
sub validate_prune_backups {
@@ -145,112 +153,132 @@ sub validate_prune_backups {
return $res;
}
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 "
. "are covered by this option. The next option does not take care "
. "of already covered backups and only considers older backups.",
optional => 1,
type => 'string',
format => 'prune-backups',
},
);
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',
format => 'pve-storage-content-list',
optional => 1,
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',
minimum => -1,
optional => 1,
default =>
"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 "
. "local storage automatically accessible to other nodes, it just marks an already "
. "shared storage as such!",
type => 'boolean',
optional => 1,
},
subdir => {
+ title => "Subdirectory",
description => "Subdir to mount.",
type => 'string',
format => 'pve-storage-path',
optional => 1,
},
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',
enum => ['off', 'metadata', 'falloc', 'full'],
default => 'metadata',
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 nfs' or 'man mount.cifs' 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"
. " configure the port via the mount options.",
type => 'integer',
minimum => 1,
maximum => 65535,
optional => 1,
},
'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,
optional => 1,
},
diff --git a/src/PVE/Storage/RBDPlugin.pm b/src/PVE/Storage/RBDPlugin.pm
index 9f52677a..ce88dc2c 100644
--- a/src/PVE/Storage/RBDPlugin.pm
+++ b/src/PVE/Storage/RBDPlugin.pm
@@ -421,40 +421,48 @@ 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 => {
description => "Authsupported.",
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 ffa64609..a722c773 100644
--- a/src/PVE/Storage/ZFSPlugin.pm
+++ b/src/PVE/Storage/ZFSPlugin.pm
@@ -199,33 +199,39 @@ 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',
format => 'pve-storage-path',
},
diff --git a/src/PVE/Storage/ZFSPoolPlugin.pm b/src/PVE/Storage/ZFSPoolPlugin.pm
index d7557ba3..8b6fa0ae 100644
--- a/src/PVE/Storage/ZFSPoolPlugin.pm
+++ b/src/PVE/Storage/ZFSPoolPlugin.pm
@@ -69,21 +69,24 @@ 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] 18+ messages in thread
* [PATCH pve-storage v4 08/17] all plugins, api: mark certain properties as advanced in plugindata()
2026-11-06 15:45 [PATCH common/manager/proxmox-widget-toolkit/storage/storage-plugin-examples v4 00/17] GUI Support for Custom Storage Plugins Max R. Carrara
` (6 preceding siblings ...)
2026-11-06 15:45 ` [PATCH pve-storage v4 07/17] all plugins: add 'title' to properties, adapt 'description's Max R. Carrara
@ 2026-11-06 15:45 ` Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-storage v4 09/17] all plugins, api: mark certain properties as hidden " Max R. Carrara
` (8 subsequent siblings)
16 siblings, 0 replies; 18+ messages in thread
From: Max R. Carrara @ 2026-11-06 15:45 UTC (permalink / raw)
To: pve-devel; +Cc: Alfonso Kuen, Thomas Ellmenreich, Jakob Klocker
Add a new 'advanced-properties' hash in `plugindata()` of all plugins.
For each plugin, mark every property that is used in the "Advanced"
section of the create / edit panel in the UI in that hash.
This results in the following properties being marked as advanced if
they do appear in a given plugin's `properties()` or `options()`:
- preallocation
- snapshot-as-volume-chain
Additionally, expose whether a property is advanced or not through a
new 'x-advanced' key that is added to each plugin property schema that
is returned by the `plugins/storage/plugin` endpoint.
The key is always set to either 0 or 1, also for plugins without the
hash.
Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
src/PVE/API2/Plugins/Storage/Plugin.pm | 2 ++
src/PVE/Storage/BTRFSPlugin.pm | 3 +++
src/PVE/Storage/CIFSPlugin.pm | 4 ++++
src/PVE/Storage/CephFSPlugin.pm | 1 +
src/PVE/Storage/DirPlugin.pm | 4 ++++
src/PVE/Storage/ESXiPlugin.pm | 1 +
src/PVE/Storage/ISCSIDirectPlugin.pm | 1 +
src/PVE/Storage/ISCSIPlugin.pm | 1 +
src/PVE/Storage/LVMPlugin.pm | 3 +++
src/PVE/Storage/LvmThinPlugin.pm | 1 +
src/PVE/Storage/NFSPlugin.pm | 4 ++++
src/PVE/Storage/PBSPlugin.pm | 1 +
src/PVE/Storage/RBDPlugin.pm | 1 +
src/PVE/Storage/ZFSPlugin.pm | 1 +
src/PVE/Storage/ZFSPoolPlugin.pm | 1 +
15 files changed, 29 insertions(+)
diff --git a/src/PVE/API2/Plugins/Storage/Plugin.pm b/src/PVE/API2/Plugins/Storage/Plugin.pm
index 906724ba..33efc138 100644
--- a/src/PVE/API2/Plugins/Storage/Plugin.pm
+++ b/src/PVE/API2/Plugins/Storage/Plugin.pm
@@ -63,20 +63,22 @@ my sub get_schema_for_plugin : prototype($) ($plugin) {
return $PLUGIN_SCHEMAS->{$type} if defined($PLUGIN_SCHEMAS->{$type});
my $options = $plugin->options();
# also covers the fallback for plugins without 'sensitive-properties' in their plugindata()
my $sensitive = { map { $_ => 1 } PVE::Storage::Plugin::sensitive_properties($type)->@* };
+ my $advanced = $plugin->plugindata()->{'advanced-properties'} // {};
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->%* };
+ $property->{'x-advanced'} = $advanced->{$option} ? 1 : 0;
$property->{'x-sensitive'} = $sensitive->{$option} ? 1 : 0;
$schema->{$option} = $property;
for my $opt_key (keys $options->{$option}->%*) {
diff --git a/src/PVE/Storage/BTRFSPlugin.pm b/src/PVE/Storage/BTRFSPlugin.pm
index 166bf815..0108e03d 100644
--- a/src/PVE/Storage/BTRFSPlugin.pm
+++ b/src/PVE/Storage/BTRFSPlugin.pm
@@ -43,10 +43,13 @@ sub plugindata {
import => 1,
},
{ images => 1, rootdir => 1 },
],
format => [{ raw => 1, subvol => 1 }, 'raw'],
+ 'advanced-properties' => {
+ preallocation => 1,
+ },
'sensitive-properties' => {},
};
}
sub properties {
diff --git a/src/PVE/Storage/CIFSPlugin.pm b/src/PVE/Storage/CIFSPlugin.pm
index 23b505cd..5a04154a 100644
--- a/src/PVE/Storage/CIFSPlugin.pm
+++ b/src/PVE/Storage/CIFSPlugin.pm
@@ -123,10 +123,14 @@ sub plugindata {
import => 1,
},
{ images => 1 },
],
format => [{ raw => 1, qcow2 => 1, vmdk => 1 }, 'raw'],
+ 'advanced-properties' => {
+ preallocation => 1,
+ 'snapshot-as-volume-chain' => 1,
+ },
'sensitive-properties' => { password => 1 },
};
}
sub properties {
diff --git a/src/PVE/Storage/CephFSPlugin.pm b/src/PVE/Storage/CephFSPlugin.pm
index fcb8a9e6..59cb57c3 100644
--- a/src/PVE/Storage/CephFSPlugin.pm
+++ b/src/PVE/Storage/CephFSPlugin.pm
@@ -116,10 +116,11 @@ sub type {
sub plugindata {
return {
content =>
[{ vztmpl => 1, iso => 1, backup => 1, snippets => 1, import => 1 }, { backup => 1 }],
+ 'advanced-properties' => {},
'sensitive-properties' => { keyring => 1 },
};
}
sub properties {
diff --git a/src/PVE/Storage/DirPlugin.pm b/src/PVE/Storage/DirPlugin.pm
index cc52feae..8e923077 100644
--- a/src/PVE/Storage/DirPlugin.pm
+++ b/src/PVE/Storage/DirPlugin.pm
@@ -36,10 +36,14 @@ sub plugindata {
import => 1,
},
{ images => 1, rootdir => 1 },
],
format => [{ raw => 1, qcow2 => 1, vmdk => 1, subvol => 1 }, 'raw'],
+ 'advanced-properties' => {
+ preallocation => 1,
+ 'snapshot-as-volume-chain' => 1,
+ },
'sensitive-properties' => {},
};
}
sub properties {
diff --git a/src/PVE/Storage/ESXiPlugin.pm b/src/PVE/Storage/ESXiPlugin.pm
index e0ea2837..82f64650 100644
--- a/src/PVE/Storage/ESXiPlugin.pm
+++ b/src/PVE/Storage/ESXiPlugin.pm
@@ -30,10 +30,11 @@ sub type {
sub plugindata {
return {
content => [{ import => 1 }, { import => 1 }],
format => [{ raw => 1, qcow2 => 1, vmdk => 1 }, 'raw'],
+ 'advanced-properties' => {},
'sensitive-properties' => { password => 1 },
};
}
sub properties {
diff --git a/src/PVE/Storage/ISCSIDirectPlugin.pm b/src/PVE/Storage/ISCSIDirectPlugin.pm
index fb732850..72bad793 100644
--- a/src/PVE/Storage/ISCSIDirectPlugin.pm
+++ b/src/PVE/Storage/ISCSIDirectPlugin.pm
@@ -64,10 +64,11 @@ sub type {
sub plugindata {
return {
content => [{ images => 1, none => 1 }, { images => 1 }],
select_existing => 1,
+ 'advanced-properties' => {},
'sensitive-properties' => {},
};
}
sub options {
diff --git a/src/PVE/Storage/ISCSIPlugin.pm b/src/PVE/Storage/ISCSIPlugin.pm
index aa187d85..2f148935 100644
--- a/src/PVE/Storage/ISCSIPlugin.pm
+++ b/src/PVE/Storage/ISCSIPlugin.pm
@@ -348,10 +348,11 @@ sub type {
sub plugindata {
return {
content => [{ images => 1, none => 1 }, { images => 1 }],
select_existing => 1,
+ 'advanced-properties' => {},
'sensitive-properties' => {},
};
}
sub properties {
diff --git a/src/PVE/Storage/LVMPlugin.pm b/src/PVE/Storage/LVMPlugin.pm
index ced87234..2619a7b9 100644
--- a/src/PVE/Storage/LVMPlugin.pm
+++ b/src/PVE/Storage/LVMPlugin.pm
@@ -713,10 +713,13 @@ sub type {
sub plugindata {
return {
content => [{ images => 1, rootdir => 1 }, { images => 1 }],
format => [{ raw => 1, qcow2 => 1 }, 'raw'],
+ 'advanced-properties' => {
+ 'snapshot-as-volume-chain' => 1,
+ },
'sensitive-properties' => {},
};
}
my $on_volume_remove_format = {
diff --git a/src/PVE/Storage/LvmThinPlugin.pm b/src/PVE/Storage/LvmThinPlugin.pm
index 6cb0500d..7fed8af8 100644
--- a/src/PVE/Storage/LvmThinPlugin.pm
+++ b/src/PVE/Storage/LvmThinPlugin.pm
@@ -34,10 +34,11 @@ sub type {
}
sub plugindata {
return {
content => [{ images => 1, rootdir => 1 }, { images => 1, rootdir => 1 }],
+ 'advanced-properties' => {},
'sensitive-properties' => {},
};
}
sub properties {
diff --git a/src/PVE/Storage/NFSPlugin.pm b/src/PVE/Storage/NFSPlugin.pm
index e54c4a4a..85497df6 100644
--- a/src/PVE/Storage/NFSPlugin.pm
+++ b/src/PVE/Storage/NFSPlugin.pm
@@ -64,10 +64,14 @@ sub plugindata {
import => 1,
},
{ images => 1 },
],
format => [{ raw => 1, qcow2 => 1, vmdk => 1 }, 'raw'],
+ 'advanced-properties' => {
+ preallocation => 1,
+ 'snapshot-as-volume-chain' => 1,
+ },
'sensitive-properties' => {},
};
}
sub properties {
diff --git a/src/PVE/Storage/PBSPlugin.pm b/src/PVE/Storage/PBSPlugin.pm
index d1c94653..83b6f89b 100644
--- a/src/PVE/Storage/PBSPlugin.pm
+++ b/src/PVE/Storage/PBSPlugin.pm
@@ -29,10 +29,11 @@ sub type {
}
sub plugindata {
return {
content => [{ backup => 1, none => 1 }, { backup => 1 }],
+ 'advanced-properties' => {},
'sensitive-properties' => {
'encryption-key' => 1,
'master-pubkey' => 1,
password => 1,
},
diff --git a/src/PVE/Storage/RBDPlugin.pm b/src/PVE/Storage/RBDPlugin.pm
index ce88dc2c..970358ca 100644
--- a/src/PVE/Storage/RBDPlugin.pm
+++ b/src/PVE/Storage/RBDPlugin.pm
@@ -414,10 +414,11 @@ sub type {
}
sub plugindata {
return {
content => [{ images => 1, rootdir => 1 }, { images => 1 }],
+ 'advanced-properties' => {},
'sensitive-properties' => { keyring => 1 },
};
}
sub properties {
diff --git a/src/PVE/Storage/ZFSPlugin.pm b/src/PVE/Storage/ZFSPlugin.pm
index a722c773..81923494 100644
--- a/src/PVE/Storage/ZFSPlugin.pm
+++ b/src/PVE/Storage/ZFSPlugin.pm
@@ -192,10 +192,11 @@ sub type {
}
sub plugindata {
return {
content => [{ images => 1 }, { images => 1 }],
+ 'advanced-properties' => {},
'sensitive-properties' => {},
};
}
sub properties {
diff --git a/src/PVE/Storage/ZFSPoolPlugin.pm b/src/PVE/Storage/ZFSPoolPlugin.pm
index 8b6fa0ae..522fee8b 100644
--- a/src/PVE/Storage/ZFSPoolPlugin.pm
+++ b/src/PVE/Storage/ZFSPoolPlugin.pm
@@ -62,10 +62,11 @@ sub type {
sub plugindata {
return {
content => [{ images => 1, rootdir => 1 }, { images => 1, rootdir => 1 }],
format => [{ raw => 1, subvol => 1 }, 'raw'],
+ 'advanced-properties' => {},
'sensitive-properties' => {},
};
}
sub properties {
--
2.47.3
^ permalink raw reply related [flat|nested] 18+ messages in thread
* [PATCH pve-storage v4 09/17] all plugins, api: mark certain properties as hidden in plugindata()
2026-11-06 15:45 [PATCH common/manager/proxmox-widget-toolkit/storage/storage-plugin-examples v4 00/17] GUI Support for Custom Storage Plugins Max R. Carrara
` (7 preceding siblings ...)
2026-11-06 15:45 ` [PATCH pve-storage v4 08/17] all plugins, api: mark certain properties as advanced in plugindata() Max R. Carrara
@ 2026-11-06 15:45 ` Max R. Carrara
2026-11-06 15:45 ` [PATCH proxmox-widget-toolkit v4 10/17] form: introduce new 'proxmoxtextarea' field Max R. Carrara
` (7 subsequent siblings)
16 siblings, 0 replies; 18+ messages in thread
From: Max R. Carrara @ 2026-11-06 15:45 UTC (permalink / raw)
To: pve-devel; +Cc: Alfonso Kuen, Thomas Ellmenreich, Jakob Klocker
As done with 'advanced-properties', add a new 'hidden-properties' hash
in `plugindata()` of all plugins. For each plugin, mark the properties
that are *not* visible / used anywhere in the create / edit panel in
the UI in that hash.
This results in the following properties being marked as hidden if
they do appear in a given plugin's `properties()` or `options()`:
- bwlimit
- create-base-path
- create-subdirs
- content-dirs
- format
Properties that the UI of a built-in plugin simply does not offer yet
are not marked.
Additionally, expose whether a property is hidden or not through a
new 'x-hidden' key that is added to each plugin property schema that
is returned by the `plugins/storage/plugin` endpoint.
The key is always set to either 0 or 1, also for plugins without the
hash.
Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
src/PVE/API2/Plugins/Storage/Plugin.pm | 2 ++
src/PVE/Storage/BTRFSPlugin.pm | 5 +++++
src/PVE/Storage/CIFSPlugin.pm | 7 +++++++
src/PVE/Storage/CephFSPlugin.pm | 7 +++++++
src/PVE/Storage/DirPlugin.pm | 7 +++++++
src/PVE/Storage/ESXiPlugin.pm | 1 +
src/PVE/Storage/ISCSIDirectPlugin.pm | 3 +++
src/PVE/Storage/ISCSIPlugin.pm | 3 +++
src/PVE/Storage/LVMPlugin.pm | 3 +++
src/PVE/Storage/LvmThinPlugin.pm | 3 +++
src/PVE/Storage/NFSPlugin.pm | 7 +++++++
src/PVE/Storage/PBSPlugin.pm | 1 +
src/PVE/Storage/RBDPlugin.pm | 3 +++
src/PVE/Storage/ZFSPlugin.pm | 3 +++
src/PVE/Storage/ZFSPoolPlugin.pm | 3 +++
15 files changed, 58 insertions(+)
diff --git a/src/PVE/API2/Plugins/Storage/Plugin.pm b/src/PVE/API2/Plugins/Storage/Plugin.pm
index 33efc138..72b176ff 100644
--- a/src/PVE/API2/Plugins/Storage/Plugin.pm
+++ b/src/PVE/API2/Plugins/Storage/Plugin.pm
@@ -64,21 +64,23 @@ my sub get_schema_for_plugin : prototype($) ($plugin) {
my $options = $plugin->options();
# also covers the fallback for plugins without 'sensitive-properties' in their plugindata()
my $sensitive = { map { $_ => 1 } PVE::Storage::Plugin::sensitive_properties($type)->@* };
my $advanced = $plugin->plugindata()->{'advanced-properties'} // {};
+ my $hidden = $plugin->plugindata()->{'hidden-properties'} // {};
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->%* };
$property->{'x-advanced'} = $advanced->{$option} ? 1 : 0;
+ $property->{'x-hidden'} = $hidden->{$option} ? 1 : 0;
$property->{'x-sensitive'} = $sensitive->{$option} ? 1 : 0;
$schema->{$option} = $property;
for my $opt_key (keys $options->{$option}->%*) {
diff --git a/src/PVE/Storage/BTRFSPlugin.pm b/src/PVE/Storage/BTRFSPlugin.pm
index 0108e03d..bb3b29bb 100644
--- a/src/PVE/Storage/BTRFSPlugin.pm
+++ b/src/PVE/Storage/BTRFSPlugin.pm
@@ -46,10 +46,15 @@ sub plugindata {
],
format => [{ raw => 1, subvol => 1 }, 'raw'],
'advanced-properties' => {
preallocation => 1,
},
+ 'hidden-properties' => {
+ 'create-base-path' => 1,
+ 'create-subdirs' => 1,
+ format => 1,
+ },
'sensitive-properties' => {},
};
}
sub properties {
diff --git a/src/PVE/Storage/CIFSPlugin.pm b/src/PVE/Storage/CIFSPlugin.pm
index 5a04154a..6a428248 100644
--- a/src/PVE/Storage/CIFSPlugin.pm
+++ b/src/PVE/Storage/CIFSPlugin.pm
@@ -127,10 +127,17 @@ sub plugindata {
format => [{ raw => 1, qcow2 => 1, vmdk => 1 }, 'raw'],
'advanced-properties' => {
preallocation => 1,
'snapshot-as-volume-chain' => 1,
},
+ 'hidden-properties' => {
+ bwlimit => 1,
+ 'create-base-path' => 1,
+ 'create-subdirs' => 1,
+ 'content-dirs' => 1,
+ format => 1,
+ },
'sensitive-properties' => { password => 1 },
};
}
sub properties {
diff --git a/src/PVE/Storage/CephFSPlugin.pm b/src/PVE/Storage/CephFSPlugin.pm
index 59cb57c3..01a73732 100644
--- a/src/PVE/Storage/CephFSPlugin.pm
+++ b/src/PVE/Storage/CephFSPlugin.pm
@@ -117,10 +117,17 @@ sub type {
sub plugindata {
return {
content =>
[{ vztmpl => 1, iso => 1, backup => 1, snippets => 1, import => 1 }, { backup => 1 }],
'advanced-properties' => {},
+ 'hidden-properties' => {
+ bwlimit => 1,
+ 'create-base-path' => 1,
+ 'create-subdirs' => 1,
+ 'content-dirs' => 1,
+ format => 1,
+ },
'sensitive-properties' => { keyring => 1 },
};
}
sub properties {
diff --git a/src/PVE/Storage/DirPlugin.pm b/src/PVE/Storage/DirPlugin.pm
index 8e923077..a9240398 100644
--- a/src/PVE/Storage/DirPlugin.pm
+++ b/src/PVE/Storage/DirPlugin.pm
@@ -40,10 +40,17 @@ sub plugindata {
format => [{ raw => 1, qcow2 => 1, vmdk => 1, subvol => 1 }, 'raw'],
'advanced-properties' => {
preallocation => 1,
'snapshot-as-volume-chain' => 1,
},
+ 'hidden-properties' => {
+ bwlimit => 1,
+ 'create-base-path' => 1,
+ 'create-subdirs' => 1,
+ 'content-dirs' => 1,
+ format => 1,
+ },
'sensitive-properties' => {},
};
}
sub properties {
diff --git a/src/PVE/Storage/ESXiPlugin.pm b/src/PVE/Storage/ESXiPlugin.pm
index 82f64650..d418fbd2 100644
--- a/src/PVE/Storage/ESXiPlugin.pm
+++ b/src/PVE/Storage/ESXiPlugin.pm
@@ -31,10 +31,11 @@ sub type {
sub plugindata {
return {
content => [{ import => 1 }, { import => 1 }],
format => [{ raw => 1, qcow2 => 1, vmdk => 1 }, 'raw'],
'advanced-properties' => {},
+ 'hidden-properties' => {},
'sensitive-properties' => { password => 1 },
};
}
sub properties {
diff --git a/src/PVE/Storage/ISCSIDirectPlugin.pm b/src/PVE/Storage/ISCSIDirectPlugin.pm
index 72bad793..65ae0ab8 100644
--- a/src/PVE/Storage/ISCSIDirectPlugin.pm
+++ b/src/PVE/Storage/ISCSIDirectPlugin.pm
@@ -65,10 +65,13 @@ sub type {
sub plugindata {
return {
content => [{ images => 1, none => 1 }, { images => 1 }],
select_existing => 1,
'advanced-properties' => {},
+ 'hidden-properties' => {
+ bwlimit => 1,
+ },
'sensitive-properties' => {},
};
}
sub options {
diff --git a/src/PVE/Storage/ISCSIPlugin.pm b/src/PVE/Storage/ISCSIPlugin.pm
index 2f148935..4a632e09 100644
--- a/src/PVE/Storage/ISCSIPlugin.pm
+++ b/src/PVE/Storage/ISCSIPlugin.pm
@@ -349,10 +349,13 @@ sub type {
sub plugindata {
return {
content => [{ images => 1, none => 1 }, { images => 1 }],
select_existing => 1,
'advanced-properties' => {},
+ 'hidden-properties' => {
+ bwlimit => 1,
+ },
'sensitive-properties' => {},
};
}
sub properties {
diff --git a/src/PVE/Storage/LVMPlugin.pm b/src/PVE/Storage/LVMPlugin.pm
index 2619a7b9..e0204217 100644
--- a/src/PVE/Storage/LVMPlugin.pm
+++ b/src/PVE/Storage/LVMPlugin.pm
@@ -716,10 +716,13 @@ sub plugindata {
content => [{ images => 1, rootdir => 1 }, { images => 1 }],
format => [{ raw => 1, qcow2 => 1 }, 'raw'],
'advanced-properties' => {
'snapshot-as-volume-chain' => 1,
},
+ 'hidden-properties' => {
+ bwlimit => 1,
+ },
'sensitive-properties' => {},
};
}
my $on_volume_remove_format = {
diff --git a/src/PVE/Storage/LvmThinPlugin.pm b/src/PVE/Storage/LvmThinPlugin.pm
index 7fed8af8..cc11dea8 100644
--- a/src/PVE/Storage/LvmThinPlugin.pm
+++ b/src/PVE/Storage/LvmThinPlugin.pm
@@ -35,10 +35,13 @@ sub type {
sub plugindata {
return {
content => [{ images => 1, rootdir => 1 }, { images => 1, rootdir => 1 }],
'advanced-properties' => {},
+ 'hidden-properties' => {
+ bwlimit => 1,
+ },
'sensitive-properties' => {},
};
}
sub properties {
diff --git a/src/PVE/Storage/NFSPlugin.pm b/src/PVE/Storage/NFSPlugin.pm
index 85497df6..d4b086da 100644
--- a/src/PVE/Storage/NFSPlugin.pm
+++ b/src/PVE/Storage/NFSPlugin.pm
@@ -68,10 +68,17 @@ sub plugindata {
format => [{ raw => 1, qcow2 => 1, vmdk => 1 }, 'raw'],
'advanced-properties' => {
preallocation => 1,
'snapshot-as-volume-chain' => 1,
},
+ 'hidden-properties' => {
+ bwlimit => 1,
+ 'create-base-path' => 1,
+ 'create-subdirs' => 1,
+ 'content-dirs' => 1,
+ format => 1,
+ },
'sensitive-properties' => {},
};
}
sub properties {
diff --git a/src/PVE/Storage/PBSPlugin.pm b/src/PVE/Storage/PBSPlugin.pm
index 83b6f89b..66372e54 100644
--- a/src/PVE/Storage/PBSPlugin.pm
+++ b/src/PVE/Storage/PBSPlugin.pm
@@ -30,10 +30,11 @@ sub type {
sub plugindata {
return {
content => [{ backup => 1, none => 1 }, { backup => 1 }],
'advanced-properties' => {},
+ 'hidden-properties' => {},
'sensitive-properties' => {
'encryption-key' => 1,
'master-pubkey' => 1,
password => 1,
},
diff --git a/src/PVE/Storage/RBDPlugin.pm b/src/PVE/Storage/RBDPlugin.pm
index 970358ca..c7aab835 100644
--- a/src/PVE/Storage/RBDPlugin.pm
+++ b/src/PVE/Storage/RBDPlugin.pm
@@ -415,10 +415,13 @@ sub type {
sub plugindata {
return {
content => [{ images => 1, rootdir => 1 }, { images => 1 }],
'advanced-properties' => {},
+ 'hidden-properties' => {
+ bwlimit => 1,
+ },
'sensitive-properties' => { keyring => 1 },
};
}
sub properties {
diff --git a/src/PVE/Storage/ZFSPlugin.pm b/src/PVE/Storage/ZFSPlugin.pm
index 81923494..85137c39 100644
--- a/src/PVE/Storage/ZFSPlugin.pm
+++ b/src/PVE/Storage/ZFSPlugin.pm
@@ -193,10 +193,13 @@ sub type {
sub plugindata {
return {
content => [{ images => 1 }, { images => 1 }],
'advanced-properties' => {},
+ 'hidden-properties' => {
+ bwlimit => 1,
+ },
'sensitive-properties' => {},
};
}
sub properties {
diff --git a/src/PVE/Storage/ZFSPoolPlugin.pm b/src/PVE/Storage/ZFSPoolPlugin.pm
index 522fee8b..7a7903bf 100644
--- a/src/PVE/Storage/ZFSPoolPlugin.pm
+++ b/src/PVE/Storage/ZFSPoolPlugin.pm
@@ -63,10 +63,13 @@ sub type {
sub plugindata {
return {
content => [{ images => 1, rootdir => 1 }, { images => 1, rootdir => 1 }],
format => [{ raw => 1, subvol => 1 }, 'raw'],
'advanced-properties' => {},
+ 'hidden-properties' => {
+ bwlimit => 1,
+ },
'sensitive-properties' => {},
};
}
sub properties {
--
2.47.3
^ permalink raw reply related [flat|nested] 18+ messages in thread
* [PATCH proxmox-widget-toolkit v4 10/17] form: introduce new 'proxmoxtextarea' field
2026-11-06 15:45 [PATCH common/manager/proxmox-widget-toolkit/storage/storage-plugin-examples v4 00/17] GUI Support for Custom Storage Plugins Max R. Carrara
` (8 preceding siblings ...)
2026-11-06 15:45 ` [PATCH pve-storage v4 09/17] all plugins, api: mark certain properties as hidden " Max R. Carrara
@ 2026-11-06 15:45 ` Max R. Carrara
2026-11-06 15:45 ` [PATCH proxmox-widget-toolkit v4 11/17] utils: introduce helper function getFieldDefFromPropertySchema Max R. Carrara
` (6 subsequent siblings)
16 siblings, 0 replies; 18+ messages in thread
From: Max R. Carrara @ 2026-11-06 15:45 UTC (permalink / raw)
To: pve-devel; +Cc: Alfonso Kuen, Thomas Ellmenreich, Jakob Klocker
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
@@ -57,5 +57,52 @@ Ext.define('Proxmox.form.field.Base64TextArea', {
setAllowBlank: function (allowBlank) {
this.allowBlank = allowBlank;
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] 18+ messages in thread
* [PATCH proxmox-widget-toolkit v4 11/17] utils: introduce helper function getFieldDefFromPropertySchema
2026-11-06 15:45 [PATCH common/manager/proxmox-widget-toolkit/storage/storage-plugin-examples v4 00/17] GUI Support for Custom Storage Plugins Max R. Carrara
` (9 preceding siblings ...)
2026-11-06 15:45 ` [PATCH proxmox-widget-toolkit v4 10/17] form: introduce new 'proxmoxtextarea' field Max R. Carrara
@ 2026-11-06 15:45 ` Max R. Carrara
2026-11-06 15:45 ` [PATCH proxmox-widget-toolkit v4 12/17] acme: use helper to construct ExtJS fields from property schemas Max R. Carrara
` (5 subsequent siblings)
16 siblings, 0 replies; 18+ messages in thread
From: Max R. Carrara @ 2026-11-06 15:45 UTC (permalink / raw)
To: pve-devel; +Cc: Alfonso Kuen, Thomas Ellmenreich, Jakob Klocker
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 --> proxmoxintegerfield, with decimals allowed
- string --> proxmoxtextfield
The integer field is used for numbers as well, because Ext JS' own
number field submits an empty string for an empty field, which the API
rejects for numeric parameters.
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, and deleteEmpty when editing
- description --> shown on hover
- enum --> use proxmoxKVComboBox as field instead, with an
additional "Default" entry if optional
- default --> used as `emptyText`, and as initial state of
checkboxes and comboboxes; when editing, a checkbox
of an optional property set to its default deletes
the property
Defaults are deliberately not used as the value of text and number
fields. Otherwise, creating a record would write every default into
the config explicitly, unlike the rest of the UI, which leaves unset
properties unset. Some defaults also describe a behavior instead of
being an actual value (e.g. "Unlimited for users with ..."), which
would make the field invalid. Checkboxes always submit a value, so
callers that do not want defaults written on creation have to drop
those themselves.
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. As the current value is never
sent to the client, leaving the field empty when editing keeps the
value unchanged, so the field is never required when editing.
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). The displayed value is
HTML-encoded, as it comes from the record.
Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
src/Utils.js | 202 +++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 202 insertions(+)
diff --git a/src/Utils.js b/src/Utils.js
index 9d6ac79..835e16e 100644
--- a/src/Utils.js
+++ b/src/Utils.js
@@ -1609,10 +1609,212 @@ Ext.define('Proxmox.Utils', {
if (location.name) {
return `${location.name} (${degrees})`;
}
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 = {},
+ ) {
+ let hasDefault = propSchema.default !== undefined && propSchema.default !== null;
+ let isOptional = Boolean(propSchema.optional);
+
+ const fieldPropsByType = {
+ string: {
+ xtype: 'proxmoxtextfield',
+ minLength: propSchema.minLength,
+ maxLength: propSchema.maxLength,
+ },
+ integer: {
+ xtype: 'proxmoxintegerfield',
+ minValue: propSchema.minimum,
+ maxValue: propSchema.maximum,
+ },
+ // Ext JS' own number field submits empty values as empty string, which the
+ // API rejects for numbers, so reuse the integer field and allow decimals.
+ number: {
+ xtype: 'proxmoxintegerfield',
+ allowDecimals: true,
+ // Ext JS rounds to two decimals by default
+ decimalPrecision: 15,
+ minValue: propSchema.minimum,
+ maxValue: propSchema.maximum,
+ },
+ boolean: {
+ xtype: 'proxmoxcheckbox',
+ uncheckedValue: 0,
+ },
+ };
+
+ const commonFieldProps = {
+ name: fieldName,
+ fieldLabel: Ext.htmlEncode(fieldName),
+ emptyText: hasDefault ? String(propSchema.default) : '',
+ allowBlank: isOptional,
+ };
+
+ const fieldPropsByFormat = {
+ multiline: {
+ xtype: 'proxmoxtextarea',
+ },
+ };
+
+ // same values as accepted by PVE::JSONSchema::parse_boolean()
+ const anyToBool = (value) => {
+ if (typeof value === 'string' || value instanceof String) {
+ return /^(?:1|on|yes|true)$/i.test(value.trim());
+ }
+
+ 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)),
+ };
+ }
+
+ // NOTE: Some context for this branch.
+ //
+ // Text and number fields only show the default as empty text
+ // instead of using it as value. This keeps unset properties unset,
+ // like in the rest of the UI, and also works for defaults that
+ // describe a behavior instead of being an actual value.
+ //
+ // However, checkboxes cannot show an empty text, AND they *always*
+ // submit a value, even if left empty. This means that a property
+ // with `default => 1` (in Perl) that was never explicitly set to
+ // `true` in the UI would then be silently set to `0` on creation.
+ if (propSchema.type === 'boolean') {
+ let defaultValue = hasDefault && anyToBool(propSchema.default);
+ field.checked = defaultValue;
+
+ // Delete an optional property instead of setting it to its
+ // default when editing, so that unset properties stay unset.
+ // The submitted values are '1' and 0.
+ //
+ // When editing (this branch), we delete an optional property
+ // instead of setting it to its default. This makes it so that
+ // unset properties stay unset.
+ if (!context.isCreate && isOptional) {
+ // We set either '1' or 0 here, because that's what
+ // `getSubmitValue()` from Ext JS returns when checked or
+ // unchecked. The check in `getSubmitData()` would fail
+ // otherwise, e.g. `'1' === true` is false.
+ field.defaultValue = defaultValue ? '1' : 0;
+ field.deleteDefaultValue = true;
+ }
+ }
+
+ // NOTE: Like above, checkboxes also cannot show emptyText, so they
+ // too use the default as initial value.
+ if (propSchema.enum) {
+ let comboItems = propSchema.enum.map((item) => [item, Ext.htmlEncode(item)]);
+
+ if (isOptional) {
+ let defaultText = Proxmox.Utils.defaultText;
+ if (hasDefault) {
+ defaultText += ` (${Ext.htmlEncode(propSchema.default)})`;
+ }
+ comboItems.unshift(['__default__', defaultText]);
+ field.value = '__default__';
+ } else if (context.isCreate && hasDefault) {
+ field.value = propSchema.default;
+ }
+
+ Object.assign(field, {
+ xtype: 'proxmoxKVComboBox',
+ comboItems: comboItems,
+ deleteEmpty: !context.isCreate,
+ });
+ } else if (!context.isCreate && isOptional) {
+ // Clearing an optional field must delete the property, otherwise it would keep
+ // its old value. Checkboxes always submit a value and are unaffected.
+ field.deleteEmpty = true;
+ }
+
+ 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';
+ }
+
+ // The current value is never sent to the client, so an empty sensitive field
+ // means "unchanged" when editing.
+ field.value = '';
+ field.deleteEmpty = false;
+ field.allowBlank = isOptional || !context.isCreate;
+ field.emptyText = context.isCreate ? Proxmox.Utils.NoneText : gettext('Unchanged');
+ }
+
+ if (extraAttributes.readonly) {
+ field.xtype = 'displayfield';
+ // the displayed values are defined by the user and must not be rendered as HTML
+ field.htmlEncode = true;
+
+ if (propSchema.type === 'boolean') {
+ field.renderer = Proxmox.Utils.format_boolean;
+ field.value = field.checked ? 1 : 0;
+ } else if (field.value === '__default__') {
+ field.value = hasDefault
+ ? `${Proxmox.Utils.defaultText} (${propSchema.default})`
+ : Proxmox.Utils.defaultText;
+ }
+
+ if (extraAttributes.sensitive) {
+ field.value = '*********';
+ field.emptyText = '';
+ }
+ }
+
+ return field;
+ },
},
singleton: true,
constructor: function () {
let me = this;
--
2.47.3
^ permalink raw reply related [flat|nested] 18+ messages in thread
* [PATCH proxmox-widget-toolkit v4 12/17] acme: use helper to construct ExtJS fields from property schemas
2026-11-06 15:45 [PATCH common/manager/proxmox-widget-toolkit/storage/storage-plugin-examples v4 00/17] GUI Support for Custom Storage Plugins Max R. Carrara
` (10 preceding siblings ...)
2026-11-06 15:45 ` [PATCH proxmox-widget-toolkit v4 11/17] utils: introduce helper function getFieldDefFromPropertySchema Max R. Carrara
@ 2026-11-06 15:45 ` Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-manager v4 13/17] api: sort modules and API method registrations Max R. Carrara
` (4 subsequent siblings)
16 siblings, 0 replies; 18+ messages in thread
From: Max R. Carrara @ 2026-11-06 15:45 UTC (permalink / raw)
To: pve-devel; +Cc: Alfonso Kuen, Thomas Ellmenreich, Jakob Klocker
Use the new `getFieldDefFromPropertySchema()` helper function to
simplify some of the logic in `ACMEPluginEdit.js`.
All fields stay optional, as before. The DNS challenge schema only
marks a few fields as optional, although many plugins accept
alternative sets of credentials, for example either an API token or a
key and email address for Cloudflare. Making the remaining fields
mandatory would prevent such configurations from being saved.
Also never send a 'delete' for emptied fields, as their values are
only submitted as part of the 'data' property.
This results in no noticeable difference when setting up ACME
challenge plugins.
Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
src/window/ACMEPluginEdit.js | 47 ++++++++++++++----------------------
1 file changed, 18 insertions(+), 29 deletions(-)
diff --git a/src/window/ACMEPluginEdit.js b/src/window/ACMEPluginEdit.js
index 900923b..6014e6a 100644
--- a/src/window/ACMEPluginEdit.js
+++ b/src/window/ACMEPluginEdit.js
@@ -67,49 +67,38 @@ Ext.define('Proxmox.window.ACMEPluginEdit', {
// create custom fields according to schema
let gotSchemaField = false;
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.up('pmxACMEPluginEdit').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,
- });
+ // The DNS plugin schema does not reliably mark fields as optional, and
+ // some plugins accept alternative sets of credentials.
+ allowBlank: true,
+ // The values are submitted as part of 'data', not as fields on their own.
+ deleteEmpty: false,
+ };
+
+ let field = Ext.create({ ...fieldDef, ...extraProps });
me.createdFields[name] = field;
container.add(field);
gotSchemaField = true;
}
--
2.47.3
^ permalink raw reply related [flat|nested] 18+ messages in thread
* [PATCH pve-manager v4 13/17] api: sort modules and API method registrations
2026-11-06 15:45 [PATCH common/manager/proxmox-widget-toolkit/storage/storage-plugin-examples v4 00/17] GUI Support for Custom Storage Plugins Max R. Carrara
` (11 preceding siblings ...)
2026-11-06 15:45 ` [PATCH proxmox-widget-toolkit v4 12/17] acme: use helper to construct ExtJS fields from property schemas Max R. Carrara
@ 2026-11-06 15:45 ` Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-manager v4 14/17] api: add API routes 'plugins' and 'plugins/storage' Max R. Carrara
` (3 subsequent siblings)
16 siblings, 0 replies; 18+ messages in thread
From: Max R. Carrara @ 2026-11-06 15:45 UTC (permalink / raw)
To: pve-devel; +Cc: Alfonso Kuen, Thomas Ellmenreich, Jakob Klocker
... so as to better adhere to our style guide [guide].
Note that this also changes the order of the entries in the API's
root directory index ('GET /'), as it lists the registered
subdirectories in registration order.
[guide] https://pve.proxmox.com/wiki/Perl_Style_Guide#Module_Dependencies
Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
PVE/API2.pm | 26 +++++++++++++-------------
1 file changed, 13 insertions(+), 13 deletions(-)
diff --git a/PVE/API2.pm b/PVE/API2.pm
index 0c7e4654..9f1ec961 100644
--- a/PVE/API2.pm
+++ b/PVE/API2.pm
@@ -1,50 +1,50 @@
package PVE::API2;
use strict;
use warnings;
-use PVE::pvecfg;
use PVE::DataCenterConfig;
use PVE::GuestHelpers;
-use PVE::RESTHandler;
use PVE::JSONSchema;
+use PVE::RESTHandler;
+use PVE::pvecfg;
use base qw(PVE::RESTHandler);
# preload classes
+use PVE::API2::AccessControl;
use PVE::API2::Cluster;
use PVE::API2::Nodes;
use PVE::API2::Pool;
-use PVE::API2::AccessControl;
use PVE::API2::Storage::Config;
+__PACKAGE__->register_method({
+ subclass => "PVE::API2::AccessControl",
+ path => 'access',
+});
+
__PACKAGE__->register_method({
subclass => "PVE::API2::Cluster",
path => 'cluster',
});
__PACKAGE__->register_method({
subclass => "PVE::API2::Nodes",
path => 'nodes',
});
-__PACKAGE__->register_method({
- subclass => "PVE::API2::Storage::Config",
- path => 'storage',
-});
-
-__PACKAGE__->register_method({
- subclass => "PVE::API2::AccessControl",
- path => 'access',
-});
-
__PACKAGE__->register_method({
subclass => "PVE::API2::Pool",
path => 'pools',
});
+__PACKAGE__->register_method({
+ subclass => "PVE::API2::Storage::Config",
+ path => 'storage',
+});
+
__PACKAGE__->register_method({
name => 'index',
path => '',
method => 'GET',
permissions => { user => 'all' },
--
2.47.3
^ permalink raw reply related [flat|nested] 18+ messages in thread
* [PATCH pve-manager v4 14/17] api: add API routes 'plugins' and 'plugins/storage'
2026-11-06 15:45 [PATCH common/manager/proxmox-widget-toolkit/storage/storage-plugin-examples v4 00/17] GUI Support for Custom Storage Plugins Max R. Carrara
` (12 preceding siblings ...)
2026-11-06 15:45 ` [PATCH pve-manager v4 13/17] api: sort modules and API method registrations Max R. Carrara
@ 2026-11-06 15:45 ` Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-manager v4 15/17] ui: storage view: display error when no editor for storage type exists Max R. Carrara
` (2 subsequent siblings)
16 siblings, 0 replies; 18+ messages in thread
From: Max R. Carrara @ 2026-11-06 15:45 UTC (permalink / raw)
To: pve-devel; +Cc: Alfonso Kuen, Thomas Ellmenreich, Jakob Klocker
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 9f1ec961..cf158182 100644
--- a/PVE/API2.pm
+++ b/PVE/API2.pm
@@ -13,10 +13,11 @@ use base qw(PVE::RESTHandler);
# preload classes
use PVE::API2::AccessControl;
use PVE::API2::Cluster;
use PVE::API2::Nodes;
+use PVE::API2::Plugins;
use PVE::API2::Pool;
use PVE::API2::Storage::Config;
__PACKAGE__->register_method({
subclass => "PVE::API2::AccessControl",
@@ -31,10 +32,15 @@ __PACKAGE__->register_method({
__PACKAGE__->register_method({
subclass => "PVE::API2::Nodes",
path => 'nodes',
});
+__PACKAGE__->register_method({
+ subclass => "PVE::API2::Plugins",
+ path => 'plugins',
+});
+
__PACKAGE__->register_method({
subclass => "PVE::API2::Pool",
path => 'pools',
});
diff --git a/PVE/API2/Makefile b/PVE/API2/Makefile
index 97f1cc20..97910009 100644
--- a/PVE/API2/Makefile
+++ b/PVE/API2/Makefile
@@ -15,10 +15,11 @@ PERLSOURCE = \
HAConfig.pm \
Hardware.pm \
Network.pm \
NodeConfig.pm \
Nodes.pm \
+ Plugins.pm \
Pool.pm \
Replication.pm \
ReplicationConfig.pm \
Services.pm \
Subscription.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] 18+ messages in thread
* [PATCH pve-manager v4 15/17] ui: storage view: display error when no editor for storage type exists
2026-11-06 15:45 [PATCH common/manager/proxmox-widget-toolkit/storage/storage-plugin-examples v4 00/17] GUI Support for Custom Storage Plugins Max R. Carrara
` (13 preceding siblings ...)
2026-11-06 15:45 ` [PATCH pve-manager v4 14/17] api: add API routes 'plugins' and 'plugins/storage' Max R. Carrara
@ 2026-11-06 15:45 ` Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-manager v4 16/17] ui: storage: add basic UI integration for custom storage plugins Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-storage-plugin-examples v4 17/17] sshfs: Update SSHFS plugin Max R. Carrara
16 siblings, 0 replies; 18+ messages in thread
From: Max R. Carrara @ 2026-11-06 15:45 UTC (permalink / raw)
To: pve-devel; +Cc: Alfonso Kuen, Thomas Ellmenreich, Jakob Klocker
... 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 | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/www/manager6/dc/StorageView.js b/www/manager6/dc/StorageView.js
index e4c6f07d..e779e9c4 100644
--- a/www/manager6/dc/StorageView.js
+++ b/www/manager6/dc/StorageView.js
@@ -11,11 +11,18 @@ Ext.define(
stateId: 'grid-dc-storage',
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'),
+ Ext.String.format(
+ gettext("No editor registered for storage type '{0}'"),
+ Ext.htmlEncode(type),
+ ),
+ );
+ return;
}
Ext.create('PVE.storage.BaseEdit', {
paneltype: 'PVE.storage.' + schema.ipanel,
type: type,
--
2.47.3
^ permalink raw reply related [flat|nested] 18+ messages in thread
* [PATCH pve-manager v4 16/17] ui: storage: add basic UI integration for custom storage plugins
2026-11-06 15:45 [PATCH common/manager/proxmox-widget-toolkit/storage/storage-plugin-examples v4 00/17] GUI Support for Custom Storage Plugins Max R. Carrara
` (14 preceding siblings ...)
2026-11-06 15:45 ` [PATCH pve-manager v4 15/17] ui: storage view: display error when no editor for storage type exists Max R. Carrara
@ 2026-11-06 15:45 ` Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-storage-plugin-examples v4 17/17] sshfs: Update SSHFS plugin Max R. Carrara
16 siblings, 0 replies; 18+ messages in thread
From: Max R. Carrara @ 2026-11-06 15:45 UTC (permalink / raw)
To: pve-devel; +Cc: Alfonso Kuen, Thomas Ellmenreich, Jakob Klocker
This commit adds a basic / rudimentary UI integration for custom
storage plugins.
Do this by issuing a request to the new `GET plugins/storage/plugin`
endpoint and checking whether a plugin is custom or not via its Perl
module path. When a user then adds or edits a storage config entry
belonging to a custom storage plugin, the new
`PVE.storage.CustomInputPanel` opens and builds the form's view from
the schemas of the plugin's properties.
In other words, the UI is built completely from the information
provided by the plugin's SectionConfig schema.
Values of sensitive properties are never filled in, so leaving such a
field empty keeps the stored value.
Checkboxes that are left at their default are not submitted when
adding a storage, so that only explicitly set properties end up in the
config.
Custom storage plugins are appended to the "Add" dropdown menu once
the request to `plugins/storage/plugin` succeeds. Built-in storage
types keep using their own editors and do not depend on the request,
so they can still be added and edited right away, and also if the
request fails. Editing a storage of any other type before the request
finishes opens the editor once it does.
Custom plugins whose type the UI already knows, but deliberately does
not offer in the "Add" menu stay hidden there.
Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
www/manager6/Makefile | 1 +
www/manager6/dc/StorageView.js | 93 ++++++++++-
www/manager6/storage/Base.js | 7 +-
www/manager6/storage/CustomEdit.js | 254 +++++++++++++++++++++++++++++
4 files changed, 347 insertions(+), 8 deletions(-)
create mode 100644 www/manager6/storage/CustomEdit.js
diff --git a/www/manager6/Makefile b/www/manager6/Makefile
index d2ea786b..e8e2bd0b 100644
--- a/www/manager6/Makefile
+++ b/www/manager6/Makefile
@@ -361,10 +361,11 @@ JSSRC= \
storage/BackupView.js \
storage/Base.js \
storage/Browser.js \
storage/CIFSEdit.js \
storage/CephFSEdit.js \
+ storage/CustomEdit.js \
storage/DirEdit.js \
storage/ImageView.js \
storage/IScsiEdit.js \
storage/LVMEdit.js \
storage/LvmThinEdit.js \
diff --git a/www/manager6/dc/StorageView.js b/www/manager6/dc/StorageView.js
index e779e9c4..8f48d552 100644
--- a/www/manager6/dc/StorageView.js
+++ b/www/manager6/dc/StorageView.js
@@ -8,31 +8,58 @@ Ext.define(
onlineHelp: 'chapter_storage',
stateful: true,
stateId: 'grid-dc-storage',
+ isCustomPlugin: function (metadata) {
+ return metadata.module.startsWith('PVE::Storage::Custom::');
+ },
+
createStorageEditWindow: function (type, sid) {
+ let me = this;
+
let schema = PVE.Utils.storageSchema[type];
- if (!schema || !schema.ipanel) {
+
+ let paneltype;
+ let canDoBackups;
+ let metadata;
+
+ // Built-in types have their own editor and must not depend on the plugin metadata,
+ // which might not be loaded (yet).
+ if (schema?.ipanel) {
+ paneltype = 'PVE.storage.' + schema.ipanel;
+ canDoBackups = schema.backups;
+ } else if (me.pluginMetadata[type] && me.isCustomPlugin(me.pluginMetadata[type])) {
+ metadata = me.pluginMetadata[type];
+ paneltype = 'PVE.storage.CustomInputPanel';
+ canDoBackups =
+ metadata.content.supported.includes('backup') &&
+ Object.hasOwn(metadata.schema, 'prune-backups');
+ } else if (!me.pluginMetadataLoaded) {
+ // might be a custom plugin, so open the editor once the metadata is there
+ me.pendingEditor = () => me.createStorageEditWindow(type, sid);
+ return;
+ } else {
Ext.Msg.alert(
gettext('Error'),
Ext.String.format(
gettext("No editor registered for storage type '{0}'"),
Ext.htmlEncode(type),
),
);
return;
}
Ext.create('PVE.storage.BaseEdit', {
- paneltype: 'PVE.storage.' + schema.ipanel,
+ paneltype: paneltype,
type: type,
storageId: sid,
- canDoBackups: schema.backups,
+ canDoBackups: canDoBackups,
+ metadata: metadata,
autoShow: true,
listeners: {
- destroy: this.reloadStore,
+ destroy: me.reloadStore,
},
});
},
initComponent: function () {
@@ -50,10 +77,13 @@ Ext.define(
},
});
let sm = Ext.create('Ext.selection.RowModel', {});
+ me.pluginMetadata = {};
+ me.pluginMetadataLoaded = false;
+
let run_editor = function () {
let rec = sm.getSelection()[0];
if (!rec) {
return;
}
@@ -89,23 +119,72 @@ Ext.define(
iconCls: 'fa fa-fw fa-' + storage.faIcon,
handler: addHandleGenerator(type),
});
}
+ let addMenu = new Ext.menu.Menu({
+ items: addMenuItems,
+ });
+
+ let finishLoading = function (success) {
+ me.pluginMetadataLoaded = true;
+
+ let openEditor = me.pendingEditor;
+ delete me.pendingEditor;
+
+ // on failure, the error is shown instead
+ if (success && openEditor) {
+ openEditor();
+ }
+ };
+
+ Proxmox.Utils.API2Request({
+ url: '/api2/extjs/plugins/storage/plugin',
+ method: 'GET',
+ success: function ({ result: { data } }) {
+ if (me.destroyed) {
+ return;
+ }
+
+ for (const metadata of data) {
+ me.pluginMetadata[metadata.type] = metadata;
+
+ // built-in UI knows the type, but does not offer to add it
+ let hideAdd = PVE.Utils.storageSchema[metadata.type]?.hideAdd;
+
+ if (me.isCustomPlugin(metadata) && !hideAdd) {
+ addMenu.add({
+ text: Ext.htmlEncode(PVE.Utils.format_storage_type(metadata.type)),
+ iconCls: 'fa fa-fw fa-folder',
+ handler: addHandleGenerator(metadata.type),
+ });
+ }
+ }
+
+ finishLoading(true);
+ },
+ failure: function ({ htmlStatus }) {
+ if (me.destroyed) {
+ return;
+ }
+
+ Ext.Msg.alert(gettext('Error'), htmlStatus);
+ finishLoading(false);
+ },
+ });
+
Ext.apply(me, {
store: store,
reloadStore: () => store.load(),
selModel: sm,
viewConfig: {
trackOver: false,
},
tbar: [
{
text: gettext('Add'),
- menu: new Ext.menu.Menu({
- items: addMenuItems,
- }),
+ menu: addMenu,
},
remove_btn,
edit_btn,
],
columns: [
diff --git a/www/manager6/storage/Base.js b/www/manager6/storage/Base.js
index 8961460b..f72b703e 100644
--- a/www/manager6/storage/Base.js
+++ b/www/manager6/storage/Base.js
@@ -97,10 +97,11 @@ Ext.define('PVE.panel.StorageBase', {
name: 'external-snapshot-hint-lvm',
userCls: 'pmx-hint',
value: gettext('Keep Snapshots as Volume-Chain enabled if qcow2 images exist!'),
});
}
+ // Note: This hint is also added in CustomEdit.js.
me.advancedColumnB.unshift({
xtype: 'displayfield',
name: 'external-snapshot-hint',
userCls: 'pmx-hint',
value: gettext('Snapshots as Volume-Chain are a technology preview.'),
@@ -137,10 +138,11 @@ Ext.define('PVE.storage.BaseEdit', {
me.ipanel = Ext.create(me.paneltype, {
title: gettext('General'),
type: me.type,
isCreate: me.isCreate,
storageId: me.storageId,
+ metadata: me.metadata,
});
Ext.apply(me, {
subject: PVE.Utils.format_storage_type(me.type),
isAdd: true,
@@ -153,11 +155,14 @@ Ext.define('PVE.storage.BaseEdit', {
items: [
me.ipanel,
{
xtype: 'pveBackupJobPrunePanel',
title: gettext('Backup Retention'),
- hasMaxProtected: true,
+ // custom plugins do not necessarily support it
+ hasMaxProtected:
+ !me.metadata ||
+ Object.hasOwn(me.metadata.schema, 'max-protected-backups'),
isCreate: me.isCreate,
keepAllDefaultForCreate: true,
showPBSHint: me.ipanel.isPBS,
fallbackHintHtml: gettext(
"Without any keep option, the node's vzdump.conf or `keep-all` is used as fallback for backup jobs",
diff --git a/www/manager6/storage/CustomEdit.js b/www/manager6/storage/CustomEdit.js
new file mode 100644
index 00000000..b6692e7e
--- /dev/null
+++ b/www/manager6/storage/CustomEdit.js
@@ -0,0 +1,254 @@
+Ext.define('PVE.storage.CustomInputPanel', {
+ extend: 'PVE.panel.StorageBase',
+
+ buildFormFieldFromProperty: function (propertyName) {
+ let me = this;
+ let schema = me.metadata.schema;
+
+ let property = schema[propertyName];
+
+ if (!property) {
+ console.warn(
+ `Tried to create field for unknown property '${propertyName}'` +
+ ` for storage type '${me.type}'`,
+ );
+ return;
+ }
+
+ // Fixed properties can only be set on creation. Do not rely on the
+ // field being disabled alone, as the storage edit controller
+ // re-enables the preallocation field.
+ let isFixed = !me.isCreate && Boolean(property.fixed);
+ let fixedProps = { disabled: isFixed, readOnly: isFixed, submitValue: !isFixed };
+
+ const predefinedFields = {
+ content: {
+ xtype: 'pveContentTypeSelector',
+ cts: me.metadata.content.supported,
+ fieldLabel: gettext('Content'),
+ name: 'content',
+ value: me.metadata.content.default,
+ multiSelect: true,
+ allowBlank: false,
+ ...fixedProps,
+ validator: function () {
+ let contentTypes = Ext.Array.from(this.getValue());
+ if (contentTypes.includes('none') && contentTypes.length > 1) {
+ return gettext("'none' cannot be combined with other content types");
+ }
+ return true;
+ },
+ },
+ preallocation: {
+ xtype: 'pvePreallocationSelector',
+ name: 'preallocation',
+ fieldLabel: gettext('Preallocation'),
+ allowBlank: false,
+ deleteEmpty: !me.isCreate && Boolean(property.optional),
+ ...fixedProps,
+ value: '__default__',
+ },
+ 'snapshot-as-volume-chain': {
+ xtype: 'proxmoxcheckbox',
+ name: 'snapshot-as-volume-chain',
+ // TRANSLATORS: As in "a chain of volumes, each referencing the next one".
+ boxLabel: gettext('Allow Snapshots as Volume-Chain'),
+ deleteEmpty: !me.isCreate,
+ ...fixedProps,
+ checked: false,
+ },
+ };
+
+ if (predefinedFields[propertyName]) {
+ return predefinedFields[propertyName];
+ }
+
+ let fieldName = propertyName;
+ let extraAttributes = {
+ readonly: property.fixed && !me.isCreate,
+ sensitive: property['x-sensitive'],
+ };
+ let context = { isCreate: me.isCreate };
+
+ let fieldDef = Proxmox.Utils.getFieldDefFromPropertySchema(
+ fieldName,
+ property,
+ extraAttributes,
+ context,
+ );
+
+ // Fix up any field labels for properties that do not have a title key
+ // by transforming the name of the field like e.g.
+ // "foo_bar-baz" --> "Foo Bar Baz"
+ if (!property.title) {
+ let fieldLabel = fieldName
+ .replace(/([-_]|\s)+/g, ' ')
+ .replace(
+ /\w*/g,
+ (label) => label.charAt(0).toUpperCase() + label.slice(1).toLowerCase(),
+ );
+ fieldDef.fieldLabel = Ext.htmlEncode(fieldLabel);
+ }
+
+ return fieldDef;
+ },
+
+ addWidget: function (widget, isAdvanced) {
+ let me = this;
+
+ let col1, col2, colB;
+
+ if (isAdvanced) {
+ col1 = me.advancedColumn1 = me.advancedColumn1 || [];
+ col2 = me.advancedColumn2 = me.advancedColumn2 || [];
+ colB = me.advancedColumnB = me.advancedColumnB || [];
+ } else {
+ col1 = me.column1 = me.column1 || [];
+ col2 = me.column2 = me.column2 || [];
+ colB = me.columnB = me.columnB || [];
+ }
+
+ const hintClasses = ['pmx-hint'];
+
+ const wideFields = [
+ 'textarea',
+ 'textareafield',
+ 'Ext.form.field.TextArea',
+ 'proxmoxtextarea',
+ 'Proxmox.form.field.TextArea',
+ ];
+
+ if (wideFields.includes(widget.xtype)) {
+ colB.push(widget);
+ return;
+ }
+
+ for (const cls of hintClasses) {
+ if (widget.userCls && widget.userCls.search(cls) !== -1) {
+ colB.push(widget);
+ return;
+ }
+ }
+
+ if (col2.length >= col1.length) {
+ col1.push(widget);
+ } else {
+ col2.push(widget);
+ }
+ },
+
+ onGetValues: function (values) {
+ let me = this;
+ let schema = me.metadata.schema;
+
+ values = me.callParent([values]);
+
+ // Checkboxes always submit a value, but defaults should not end up in
+ // the config.
+ if (me.isCreate) {
+ for (const [name, property] of Object.entries(schema)) {
+ if (
+ property.type !== 'boolean' ||
+ !property.optional ||
+ values[name] === undefined
+ ) {
+ continue;
+ }
+
+ let defaultValue = PVE.Parser.parseBoolean(String(property.default), false);
+ if (PVE.Parser.parseBoolean(String(values[name])) === defaultValue) {
+ delete values[name];
+ }
+ }
+ }
+
+ return values;
+ },
+
+ onSetValues: function (values) {
+ let me = this;
+
+ // Never show and thus resubmit values of sensitive properties, empty means unchanged.
+ for (const [name, property] of Object.entries(me.metadata.schema)) {
+ if (property['x-sensitive']) {
+ delete values[name];
+ }
+ }
+
+ return values;
+ },
+
+ initComponent: function () {
+ let me = this;
+ let schema = me.metadata.schema;
+
+ me.column1 = me.column1 || [];
+ me.column2 = me.column2 || [];
+
+ const reservedFields = new Set([
+ // automatically added in PVE.panel.StorageBase
+ // --> must not be added here
+ 'storage',
+ 'nodes',
+ 'disable',
+
+ // handled by the "Backup Retention" panel
+ // --> must not be added here
+ 'prune-backups',
+ 'max-protected-backups',
+
+ // not an actual property, but used by the UI as an inverse of the
+ // 'disable' property
+ // --> must not be added here
+ 'enable',
+
+ // handled separately for consistency
+ 'content',
+ 'shared',
+ ]);
+
+ // Added first for consistency's sake
+ for (const propertyName of ['content', 'shared']) {
+ let property = schema[propertyName];
+ if (property) {
+ let fieldDef = me.buildFormFieldFromProperty(propertyName);
+ me.column1.push(fieldDef);
+ }
+ }
+
+ // We use regular sort without `localeCompare()`, since we want all properties to be
+ // *always* in the same order, regardless of language.
+ for (const [propertyName, propertySchema] of Object.entries(schema).sort()) {
+ if (reservedFields.has(propertyName)) {
+ continue;
+ }
+
+ // a required property must stay settable on creation
+ if (propertySchema['x-hidden'] && (propertySchema.optional || !me.isCreate)) {
+ continue;
+ }
+
+ let fieldDef = me.buildFormFieldFromProperty(propertyName);
+
+ if (fieldDef === undefined) {
+ continue;
+ }
+
+ me.addWidget(fieldDef, propertySchema['x-advanced']);
+
+ if (propertyName === 'snapshot-as-volume-chain') {
+ me.addWidget(
+ {
+ xtype: 'displayfield',
+ name: 'external-snapshot-hint',
+ userCls: 'pmx-hint',
+ value: gettext('Snapshots as Volume-Chain are a technology preview.'),
+ },
+ propertySchema['x-advanced'],
+ );
+ }
+ }
+
+ me.callParent();
+ },
+});
--
2.47.3
^ permalink raw reply related [flat|nested] 18+ messages in thread
* [PATCH pve-storage-plugin-examples v4 17/17] sshfs: Update SSHFS plugin
2026-11-06 15:45 [PATCH common/manager/proxmox-widget-toolkit/storage/storage-plugin-examples v4 00/17] GUI Support for Custom Storage Plugins Max R. Carrara
` (15 preceding siblings ...)
2026-11-06 15:45 ` [PATCH pve-manager v4 16/17] ui: storage: add basic UI integration for custom storage plugins Max R. Carrara
@ 2026-11-06 15:45 ` Max R. Carrara
16 siblings, 0 replies; 18+ messages in thread
From: Max R. Carrara @ 2026-11-06 15:45 UTC (permalink / raw)
To: pve-devel; +Cc: Alfonso Kuen, Thomas Ellmenreich, Jakob Klocker
Change the API version from 11 to 16.
Support the `import` volume type, since it's already in ::Plugin for
directory-based storage plugins.
Add `title` keys for the custom 'remote-path' and 'sshfs-private-key'
properties. Add the "multiline" `format` to 'sshfs-private-key' as
well.
Mark common advanced and hidden properties in the
'advanced-properties' and 'hidden-properties' hashes in `plugindata()`
respectively.
Use `on_update_hook_full()` instead of `on_update_hook()`.
Adapt the signature of `activate_storage()`, adding `$hints` as new
parameter. Mark the `$cache` and `$hints` parameters as optional, by
assigning `undef` to them by default.
Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
.../src/PVE/Storage/Custom/SSHFSPlugin.pm | 26 ++++++++++++++-----
1 file changed, 20 insertions(+), 6 deletions(-)
diff --git a/plugin-sshfs/src/PVE/Storage/Custom/SSHFSPlugin.pm b/plugin-sshfs/src/PVE/Storage/Custom/SSHFSPlugin.pm
index 2d1612b..0a1ee33 100644
--- a/plugin-sshfs/src/PVE/Storage/Custom/SSHFSPlugin.pm
+++ b/plugin-sshfs/src/PVE/Storage/Custom/SSHFSPlugin.pm
@@ -20,11 +20,11 @@ use PVE::Tools qw(
use base qw(PVE::Storage::Plugin);
# Plugin Definition
sub api {
- return 11;
+ return 16;
}
sub type {
return 'sshfs';
}
@@ -37,10 +37,11 @@ sub plugindata {
rootdir => 1,
vztmpl => 1,
iso => 1,
backup => 1,
snippets => 1,
+ import => 1,
},
{
images => 1,
rootdir => 1,
},
@@ -51,26 +52,39 @@ sub plugindata {
qcow2 => 1,
vmdk => 1,
},
'qcow2',
],
+ 'advanced-properties' => {
+ preallocation => 1,
+ },
+ 'hidden-properties' => {
+ bwlimit => 1,
+ 'content-dirs' => 1,
+ 'create-base-path' => 1,
+ 'create-subdirs' => 1,
+ format => 1,
+ },
'sensitive-properties' => {
'sshfs-private-key' => 1,
},
};
}
sub properties {
return {
'remote-path' => {
description => "Path on the remote filesystem used for SSHFS. Must be absolute.",
type => 'string',
format => 'pve-storage-path',
+ title => 'Remote Path',
},
'sshfs-private-key' => {
description => "The private key to use for SSHFS.",
type => 'string',
+ format => 'multiline',
+ title => 'Private Key',
},
};
}
sub options {
@@ -246,14 +260,14 @@ sub on_add_hook($class, $storeid, $scfg, %sensitive) {
die "error while adding SSHFS storage '${storeid}': $@\n" if $@;
return undef;
}
-sub on_update_hook($class, $storeid, $scfg, %sensitive) {
- if (exists($sensitive{'sshfs-private-key'})) {
- if (defined($sensitive{'sshfs-private-key'})) {
- eval { sshfs_set_private_key($storeid, $sensitive{'sshfs-private-key'}) };
+sub on_update_hook_full($class, $storeid, $scfg, $update, $delete, $sensitive) {
+ if (exists($sensitive->{'sshfs-private-key'})) {
+ if (defined($sensitive->{'sshfs-private-key'})) {
+ eval { sshfs_set_private_key($storeid, $sensitive->{'sshfs-private-key'}) };
die $@ if $@;
} else {
warn "removing private key for SSHFS storage '${storeid}'";
warn "the storage might not be mountable without a private key!";
@@ -303,11 +317,11 @@ sub check_connection($class, $storeid, $scfg) {
}
return 1;
}
-sub activate_storage($class, $storeid, $scfg, $cache) {
+sub activate_storage($class, $storeid, $scfg, $cache = undef, $hints = undef) {
my $mountpoint = $scfg->{path};
if (!sshfs_is_mounted($scfg)) {
if ($scfg->{'create-base-path'} // 1) {
make_path($mountpoint);
--
2.47.3
^ permalink raw reply related [flat|nested] 18+ messages in thread
end of thread, other threads:[~2026-11-06 15:48 UTC | newest]
Thread overview: 18+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-11-06 15:45 [PATCH common/manager/proxmox-widget-toolkit/storage/storage-plugin-examples v4 00/17] GUI Support for Custom Storage Plugins Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-common v4 01/17] json schema: add multiline string format Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-storage v4 02/17] api: plugins/storage: add initial routes and endpoints Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-storage v4 03/17] api: plugins/storage/plugin: include schema in plugin metadata Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-storage v4 04/17] api: plugins/storage/plugin: mark sensitive properties in schema Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-storage v4 05/17] api: plugins/storage/plugin: factor plugin metadata code into helper Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-storage v4 06/17] api: plugins/storage/plugin: add plugins' 'content' to their metadata Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-storage v4 07/17] all plugins: add 'title' to properties, adapt 'description's Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-storage v4 08/17] all plugins, api: mark certain properties as advanced in plugindata() Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-storage v4 09/17] all plugins, api: mark certain properties as hidden " Max R. Carrara
2026-11-06 15:45 ` [PATCH proxmox-widget-toolkit v4 10/17] form: introduce new 'proxmoxtextarea' field Max R. Carrara
2026-11-06 15:45 ` [PATCH proxmox-widget-toolkit v4 11/17] utils: introduce helper function getFieldDefFromPropertySchema Max R. Carrara
2026-11-06 15:45 ` [PATCH proxmox-widget-toolkit v4 12/17] acme: use helper to construct ExtJS fields from property schemas Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-manager v4 13/17] api: sort modules and API method registrations Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-manager v4 14/17] api: add API routes 'plugins' and 'plugins/storage' Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-manager v4 15/17] ui: storage view: display error when no editor for storage type exists Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-manager v4 16/17] ui: storage: add basic UI integration for custom storage plugins Max R. Carrara
2026-11-06 15:45 ` [PATCH pve-storage-plugin-examples v4 17/17] sshfs: Update SSHFS plugin Max R. Carrara
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.