public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
* [pve-devel] [RFC pve-storage, pve-manager master v1 00/12] GUI Support for Custom Storage Plugins
@ 2025-09-08 18:00 Max R. Carrara
  2025-09-08 18:00 ` [pve-devel] [RFC pve-storage master v1 01/12] plugin: meta: add package PVE::Storage::Plugin::Meta Max R. Carrara
                   ` (12 more replies)
  0 siblings, 13 replies; 15+ messages in thread
From: Max R. Carrara @ 2025-09-08 18:00 UTC (permalink / raw)
  To: pve-devel

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

tl;dr:

Add an API method to PVE::Storage::Plugin that returns the definition
for the form view of custom storage plugins. This definition is used by
the frontend to build the form view for creating / editing the storage
config entry of the plugin. The ultimate goal here is that custom
storage plugin devs don't have to (and also *must not*) ever touch
JavaScript to make their plugins show up in the GUI.

Overview
--------

This RFC implements GUI support for custom storage plugins.

To achieve this, four new paths are added to the API:

  - plugins/storage

      Returns the metadata of all plugins.

      Plugin metadata includes supported content types and formats (as well
      as their defaults), the plugin's short name, the views a plugin
      defines, and more.

  - plugins/storage/{plugin}

      Returns the metadata of a single plugin.

  - plugins/storage/{plugin}/views

      Returns a list of all view declarations of a plugin. If the plugin
      defines no views, the list is empty.

  - plugins/storage/{plugin}/views/form

      Returns the form view definition of a plugin.

How Custom Views Work
---------------------

A view is what defines how data should be displayed to users. Views are
specified via a nested hash in Perl and converted to JSON via the API.
The view definition inside the JSON object is then taken by the frontend
and built into a view for the user.

In particular, this RFC adds support for custom form views for storage
plugin config entries (Datacenter > Storage). A plugin may define a form
view by implementing the new `get_form_view()` plugin API method and
specifying in `plugindata()` that it has declared such a view.

Additionally, the JSON schema for form views is versioned to make
forward- and backward-compat easier.

The form view currently only allows customization of the "General" tab.
However, if a config entry has the "backup" content type selected, the
"Backup Retention" tab becomes unmasked, just like with inbuilt plugins.

The JSON schema for the form view mainly consists of the columns of the
"General" tab and the fields those columns may include.

The supported columns reflect those that are currently used in our
frontend:
  - A list of "regular" columns
  - A "bottom" column (the wide column below the regular ones)
  - Columns in the advanced subsection
  - A "bottom" column in the advanced subsection

Every column may contain a list of fields. Fields are typed and
correspond to a SectionConfig property of the storage plugin returning
the view. This means that defining the form view is enough, no
additional API methods need to be implemented otherwise. Ext.JS will
then use the property names in the field definitions for the regular
storage API calls.

This means that custom storage plugin authors don't have to write a
single line of JavaScript when implementing GUI support for their
plugin.

The currently supported field types in the form view schema are:
  - boolean
  - integer
  - number
  - string
  - selection

Fields have common attributes as well as attributes specific to its
particular type. For example, the 'string' field may have the additional
'display-mode' attribute, with which the field may be displayed as
regular text field (the default), as text area, or as a password field.

How these field definitions are interpreted depends on the frontend.
In this RFC, the properties of the corresponding Ext.JS field are
determined and stitched together dynamically.

The four fields for the storage ID, nodes, content types and enable /
disable checkbox are always added by default and cannot be declared in
the form view.

Example Implementation
----------------------

To show the custom form view in action, the whole thing described here
is implemented for the ZFS pool plugin. You should notice only minor
differences from the original form view.

Current Limitations
-------------------

- The "default text" is currently not set. Didn't want to give in to the
  ever-lingering feature creep surrounding this RFC.
  - The same probably goes for other minor particularities that Ext.JS
    supports. If the reader has any additional ideas, please send them
    my way.

- There is no support for cluster setups yet.
  - This is only *really* an issue for node-local storages. The example
    implementation for the ZFS pool storage in this RFC works for single
    node setups, but there's no node selector or anything of the sort
    for custom selections. Would highly appreciate any ideas in that
    regard, as we might have to deviate from the "standard look" that
    our storage config forms currently have when it comes to that.

- For some reason the checkbox for the advanced section doesn't show up
  even if fields exist inside its columns.
  - The fields still show up as expected, it's just that they can't be
    hidden with the "Advanced [ ]" checkbox.
  - No idea why that happens. Would appreciate any Ext.JS lore / help in
    that regard.

- Docstrings for the new stuff will be added once this becomes a proper
  series.

Further Ideas
-------------

- While this only aims to implement GUI support for custom storage
  plugins, there's nothing that's really stopping us from using this for
  our own plugins once all the rough edges have been smoothed out.
  - The only thing that might hinder us from *fully* switching over to
    declaring our inbuilt plugins' form views according to this series
    is the fact that some plugins define custom dialogues and such.
  - E.g. the PBS plugin has a custom "Encryption" tab with whole
    dialogues for auto-generating / uploading encryption keys.

- As of right now, the already existing field types in Ext.JS
  (meaning 'xtypes' here) are used.
  - What we could do is add custom field types in Ext.JS that correspond
    to the five types that the form view schema allows, in order to
    provide a more uniform way of building the fields and columns in
    Ext.JS. Right now the Ext.JS fields are just made up on the spot,
    which is a bit convoluted.
  - Not sure if this is strictly necessary though, but might be nice to
    have.

- This whole concept in the RFC can theoretically be generalized so that
  it may be used throughout other places in PVE as well.
  - The JSON schemas for columns and fields in particular technically
    aren't really limited to storage plugin stuff.
  - It might therefore be beneficial overall to pull the smaller pieces
    out and define them in a separate module (debian package) so that
    the rest of the backend can also benefit from this.
  - I don't know of any other use cases as of right now though, which
    is why I confined the schemas to PVE::Storage::Plugin::Views at the
    moment.
  - If we do want to generalize field / column / row / etc. schemas 
    eventually, we can just add a new schema version for the storage
    plugin form view that uses the altered schemas when that happens.
    :^)

Closing Thoughts
----------------

If you read this far, thanks a lot for your attention. 🙏 I hope I
haven't missed anything. I'd appreciate any feedback.

Also, thanks a lot to Aaron L. for brainstorming this through with me in
the beginning!

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

pve-storage:

Max R. Carrara (8):
  plugin: meta: add package PVE::Storage::Plugin::Meta
  api: Add 'plugins/storage' and 'plugins/storage/{plugin}' paths
  plugin: meta: introduce 'short-name'
  plugin: views: add package PVE::Storage::Plugin::Views
  plugin: add new plugin API method `get_form_view()`
  plugin: meta: add metadata regarding views in API
  api: views: add paths regarding storage plugin views
  plugin: zfspool: add 'short-name' and form view for ZFS pool plugin

 src/PVE/API2/Makefile                  |   1 +
 src/PVE/API2/Plugins/Makefile          |  18 ++
 src/PVE/API2/Plugins/Storage/Config.pm | 188 +++++++++++++++++++
 src/PVE/API2/Plugins/Storage/Makefile  |  18 ++
 src/PVE/API2/Plugins/Storage/Views.pm  | 172 ++++++++++++++++++
 src/PVE/Storage/Makefile               |   1 +
 src/PVE/Storage/Plugin.pm              |   8 +
 src/PVE/Storage/Plugin/Makefile        |  11 ++
 src/PVE/Storage/Plugin/Meta.pm         | 211 +++++++++++++++++++++
 src/PVE/Storage/Plugin/Views.pm        | 242 +++++++++++++++++++++++++
 src/PVE/Storage/ZFSPoolPlugin.pm       |  67 +++++++
 11 files changed, 937 insertions(+)
 create mode 100644 src/PVE/API2/Plugins/Makefile
 create mode 100644 src/PVE/API2/Plugins/Storage/Config.pm
 create mode 100644 src/PVE/API2/Plugins/Storage/Makefile
 create mode 100644 src/PVE/API2/Plugins/Storage/Views.pm
 create mode 100644 src/PVE/Storage/Plugin/Makefile
 create mode 100644 src/PVE/Storage/Plugin/Meta.pm
 create mode 100644 src/PVE/Storage/Plugin/Views.pm

pve-manager:

Max R. Carrara (4):
  api: handle path 'plugins/storage' through its package
  ui: storage: add CustomBase.js
  ui: storage: support custom storage plugins in Datacenter > Storage
  ui: storage: use `Ext.Msg.alert()` instead of throwing an exception

 PVE/API2.pm                        |   6 +
 www/manager6/Makefile              |   1 +
 www/manager6/dc/StorageView.js     | 137 ++++++++--
 www/manager6/storage/CustomBase.js | 402 +++++++++++++++++++++++++++++
 4 files changed, 524 insertions(+), 22 deletions(-)
 create mode 100644 www/manager6/storage/CustomBase.js

-- 
2.47.2



_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel

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

* [pve-devel] [RFC pve-storage master v1 01/12] plugin: meta: add package PVE::Storage::Plugin::Meta
  2025-09-08 18:00 [pve-devel] [RFC pve-storage, pve-manager master v1 00/12] GUI Support for Custom Storage Plugins Max R. Carrara
@ 2025-09-08 18:00 ` Max R. Carrara
  2025-09-08 18:00 ` [pve-devel] [RFC pve-storage master v1 02/12] api: Add 'plugins/storage' and 'plugins/storage/{plugin}' paths Max R. Carrara
                   ` (11 subsequent siblings)
  12 siblings, 0 replies; 15+ messages in thread
From: Max R. Carrara @ 2025-09-08 18:00 UTC (permalink / raw)
  To: pve-devel

This package is used to retrieve general metadata about plugins.

Add this package in order to keep code concerning the retrieval of
storage plugin metadata in one place instead of mixing the code into
`PVE::Storage` and `PVE::Storage::Plugin`.

At the moment, plugin metadata includes the plugin's kind (inbuilt or
custom), its supported content types and formats, and what properties
it declares as sensitive.

Since plugin metadata (such as the returned hash by the `plugindata()`
method, for example) is static, cache the metadata of all plugins
after the first call to either `get_plugin_metadata()` or
`get_plugin_metadata_all()`.

The public subroutines (deep-)copy their returned data to prevent any
accidental modification, as hashrefs aren't supported by the
'use constant' Perl core pragma. This isn't the most optimal way to
do this; as a potential alternative `Readonly` [0] could be used
instead, but I didn't want to pull in another dependency at the
moment.

[0]: https://metacpan.org/pod/Readonly

Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
 src/PVE/Storage/Makefile        |   1 +
 src/PVE/Storage/Plugin/Makefile |  10 ++
 src/PVE/Storage/Plugin/Meta.pm  | 168 ++++++++++++++++++++++++++++++++
 3 files changed, 179 insertions(+)
 create mode 100644 src/PVE/Storage/Plugin/Makefile
 create mode 100644 src/PVE/Storage/Plugin/Meta.pm

diff --git a/src/PVE/Storage/Makefile b/src/PVE/Storage/Makefile
index a67dc25..ca687b6 100644
--- a/src/PVE/Storage/Makefile
+++ b/src/PVE/Storage/Makefile
@@ -19,5 +19,6 @@ SOURCES= \
 .PHONY: install
 install:
 	make -C Common install
+	make -C Plugin install
 	for i in ${SOURCES}; do install -D -m 0644 $$i ${DESTDIR}${PERLDIR}/PVE/Storage/$$i; done
 	make -C LunCmd install
diff --git a/src/PVE/Storage/Plugin/Makefile b/src/PVE/Storage/Plugin/Makefile
new file mode 100644
index 0000000..ca82517
--- /dev/null
+++ b/src/PVE/Storage/Plugin/Makefile
@@ -0,0 +1,10 @@
+SOURCES = Meta.pm		\
+
+
+INSTALL_PATH = ${DESTDIR}${PERLDIR}/PVE/Storage/Plugin
+
+.PHONY: install
+install:
+	set -e && for SOURCE in ${SOURCES}; \
+		do install -D -m 0644 $$SOURCE ${INSTALL_PATH}/$$SOURCE; \
+	done
diff --git a/src/PVE/Storage/Plugin/Meta.pm b/src/PVE/Storage/Plugin/Meta.pm
new file mode 100644
index 0000000..6d0cb51
--- /dev/null
+++ b/src/PVE/Storage/Plugin/Meta.pm
@@ -0,0 +1,168 @@
+package PVE::Storage::Plugin::Meta;
+
+use v5.36;
+
+use Carp qw(croak confess);
+use Storable qw(dclone);
+
+use PVE::Storage;
+use PVE::Storage::Plugin;
+
+use Exporter qw(import);
+
+our @EXPORT_OK = qw(
+    plugin_kinds
+    plugin_content_types
+    plugin_formats
+    get_plugin_metadata
+    get_plugin_metadata_all
+);
+
+=head1 NAME
+
+PVE::Storage::Plugin::Meta - Retrieving Storage Plugin Metadata
+
+=head1 DESCRIPTION
+
+=for comment
+TODO
+
+=cut
+
+my $PLUGIN_KINDS = [
+    'builtin', 'custom',
+];
+
+# Note: 'none' isn't included here since it's an internal marker content type.
+my $PLUGIN_CONTENT_TYPES = [
+    'images', 'rootdir', 'vztmpl', 'iso', 'backup', 'snippets', 'import',
+];
+
+my $PLUGIN_FORMATS = [
+    'raw', 'qcow2', 'vmdk', 'subvol',
+];
+
+my $DEFAULT_PLUGIN_FORMAT = 'raw';
+
+sub plugin_kinds() {
+    return [$PLUGIN_KINDS->@*];
+}
+
+sub plugin_content_types() {
+    return [$PLUGIN_CONTENT_TYPES->@*];
+}
+
+sub plugin_formats() {
+    return [$PLUGIN_FORMATS->@*];
+}
+
+my $plugin_metadata = undef;
+
+my sub assemble_plugin_metadata_content($plugin) {
+    confess '$plugin is undef' if !defined($plugin);
+
+    my $content_metadata = {
+        supported => [],
+        default => [],
+    };
+
+    my $plugindata = $plugin->plugindata();
+
+    return $content_metadata if !defined($plugindata->{content});
+
+    my $supported = $plugindata->{content}->[0];
+    my $default = $plugindata->{content}->[1];
+
+    for my $content_type ($PLUGIN_CONTENT_TYPES->@*) {
+        if (defined($supported->{$content_type})) {
+            push($content_metadata->{supported}->@*, $content_type);
+        }
+
+        if (defined($default->{$content_type})) {
+            push($content_metadata->{default}->@*, $content_type);
+        }
+    }
+
+    return $content_metadata;
+}
+
+my sub assemble_plugin_metadata_format($plugin) {
+    confess '$plugin is undef' if !defined($plugin);
+
+    my $plugindata = $plugin->plugindata();
+
+    if (!defined($plugindata->{format})) {
+        return {
+            supported => [$DEFAULT_PLUGIN_FORMAT],
+            default => $DEFAULT_PLUGIN_FORMAT,
+        };
+    }
+
+    my $format_metadata = {
+        supported => [],
+        default => $plugindata->{format}->[1],
+    };
+
+    my $supported = $plugindata->{format}->[0];
+
+    for my $format ($PLUGIN_FORMATS->@*) {
+        if (defined($supported->{$format})) {
+            push($format_metadata->{supported}->@*, $format);
+        }
+    }
+
+    return $format_metadata;
+}
+
+my sub assemble_plugin_metadata() {
+    return if defined($plugin_metadata);
+
+    $plugin_metadata = {};
+    my $all_types = PVE::Storage::Plugin->lookup_types();
+
+    for my $type ($all_types->@*) {
+        my $plugin = PVE::Storage::Plugin->lookup($type);
+
+        $plugin = "$plugin";
+
+        my $kind = 'builtin';
+        $kind = 'custom' if $plugin =~ m/^PVE::Storage::Custom::/;
+
+        my $metadata = {
+            type => $type,
+            module => $plugin,
+            kind => $kind,
+        };
+
+        $metadata->{content} = assemble_plugin_metadata_content($plugin);
+        $metadata->{format} = assemble_plugin_metadata_format($plugin);
+
+        my $sensitive_properties = $plugin->plugindata()->{'sensitive-properties'} // {};
+
+        $metadata->{'sensitive-properties'} =
+            [grep { $sensitive_properties->{$_} } sort keys $sensitive_properties->%*];
+
+        $plugin_metadata->{$type} = $metadata;
+    }
+
+    return;
+}
+
+sub get_plugin_metadata {
+    my ($plugin_type) = @_;
+
+    croak "\$plugin_type is undef" if !defined($plugin_type);
+
+    assemble_plugin_metadata() if !defined($plugin_metadata);
+
+    return dclone($plugin_metadata->{$plugin_type}) if exists($plugin_metadata->{$plugin_type});
+    return undef;
+}
+
+sub get_plugin_metadata_all {
+    assemble_plugin_metadata() if !defined($plugin_metadata);
+
+    return dclone($plugin_metadata);
+}
+
+1;
-- 
2.47.2



_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel


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

* [pve-devel] [RFC pve-storage master v1 02/12] api: Add 'plugins/storage' and 'plugins/storage/{plugin}' paths
  2025-09-08 18:00 [pve-devel] [RFC pve-storage, pve-manager master v1 00/12] GUI Support for Custom Storage Plugins Max R. Carrara
  2025-09-08 18:00 ` [pve-devel] [RFC pve-storage master v1 01/12] plugin: meta: add package PVE::Storage::Plugin::Meta Max R. Carrara
@ 2025-09-08 18:00 ` Max R. Carrara
  2025-09-08 18:00 ` [pve-devel] [RFC pve-storage master v1 03/12] plugin: meta: introduce 'short-name' Max R. Carrara
                   ` (10 subsequent siblings)
  12 siblings, 0 replies; 15+ messages in thread
From: Max R. Carrara @ 2025-09-08 18:00 UTC (permalink / raw)
  To: pve-devel

Add these paths in order to expose plugin metadata via the API.

Both paths use a common JSON schema for plugin metadata;
'plugins/storage' lists the metadata for all plugins, whereas
'plugins/storage/{plugin}' returns the metadata for a single plugin.

The queried metadata is validated against the JSON schema for each API
call. This is rather suboptimal and should be done via tests instead,
but is kept in regardless to keep this RFC short.

What permissions the two paths should use has not yet been decided.

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/Config.pm | 168 +++++++++++++++++++++++++
 src/PVE/API2/Plugins/Storage/Makefile  |  17 +++
 4 files changed, 204 insertions(+)
 create mode 100644 src/PVE/API2/Plugins/Makefile
 create mode 100644 src/PVE/API2/Plugins/Storage/Config.pm
 create mode 100644 src/PVE/API2/Plugins/Storage/Makefile

diff --git a/src/PVE/API2/Makefile b/src/PVE/API2/Makefile
index fe316c5..01b7b28 100644
--- a/src/PVE/API2/Makefile
+++ b/src/PVE/API2/Makefile
@@ -5,3 +5,4 @@ install:
 	install -D -m 0644 Disks.pm ${DESTDIR}${PERLDIR}/PVE/API2/Disks.pm
 	make -C Storage install
 	make -C Disks install
+	make -C Plugins install
diff --git a/src/PVE/API2/Plugins/Makefile b/src/PVE/API2/Plugins/Makefile
new file mode 100644
index 0000000..b235d67
--- /dev/null
+++ b/src/PVE/API2/Plugins/Makefile
@@ -0,0 +1,18 @@
+SOURCES =
+
+
+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/Config.pm b/src/PVE/API2/Plugins/Storage/Config.pm
new file mode 100644
index 0000000..064aec9
--- /dev/null
+++ b/src/PVE/API2/Plugins/Storage/Config.pm
@@ -0,0 +1,168 @@
+package PVE::API2::Plugins::Storage::Config;
+
+use v5.36;
+
+use HTTP::Status qw(:constants);
+
+use PVE::Exception qw(raise);
+use PVE::JSONSchema;
+use PVE::Storage;
+use PVE::Storage::Plugin;
+use PVE::Storage::Plugin::Meta qw(
+    plugin_kinds
+    plugin_content_types
+    plugin_formats
+    get_plugin_metadata
+    get_plugin_metadata_all
+);
+use PVE::Tools qw(extract_param);
+
+use PVE::RESTHandler;
+use base qw(PVE::RESTHandler);
+
+my $PLUGIN_METADATA_SCHEMA = {
+    type => 'object',
+    properties => {
+        type => {
+            type => 'string',
+            enum => PVE::Storage::Plugin->lookup_types(),
+            optional => 0,
+        },
+        kind => {
+            type => 'string',
+            enum => plugin_kinds(),
+            optional => 0,
+        },
+        content => {
+            type => 'object',
+            optional => 0,
+            properties => {
+                supported => {
+                    type => 'array',
+                    items => {
+                        type => 'string',
+                        enum => plugin_content_types(),
+                    },
+                },
+                default => {
+                    type => 'array',
+                    items => {
+                        type => 'string',
+                        enum => plugin_content_types(),
+                    },
+                },
+            },
+        },
+        format => {
+            type => 'object',
+            optional => 0,
+            properties => {
+                supported => {
+                    type => 'array',
+                    items => {
+                        type => 'string',
+                        enum => plugin_formats(),
+                    },
+                },
+                default => {
+                    type => 'string',
+                    enum => plugin_formats(),
+                },
+            },
+        },
+        'sensitive-properties' => {
+            type => 'array',
+            optional => 0,
+            items => {
+                type => 'string',
+            },
+        },
+    },
+};
+
+# plugins/storage
+
+__PACKAGE__->register_method({
+    name => 'index',
+    path => '',
+    method => 'GET',
+    description => 'List all available storage plugins and their metadata.',
+    permissions => {
+        # TODO: perms
+        description => "",
+        user => 'all',
+    },
+    parameters => {
+        additionalProperties => 0,
+        properties => {
+            kind => {
+                description => "Only list built-in or custom storage plugins.",
+                type => 'string',
+                enum => plugin_kinds(),
+                optional => 1,
+            },
+        },
+    },
+    returns => {
+        type => 'array',
+        items => $PLUGIN_METADATA_SCHEMA,
+    },
+    code => sub($param) {
+        my $param_kind = extract_param($param, 'kind');
+
+        my $result = [];
+
+        my $plugin_metadata = get_plugin_metadata_all();
+
+        for my $type (sort keys $plugin_metadata->%*) {
+            my $type_info = $plugin_metadata->{$type};
+
+            # TODO: run in tests instead?
+            PVE::JSONSchema::validate($type_info, $PLUGIN_METADATA_SCHEMA);
+
+            my $kind = $type_info->{kind};
+
+            next if defined($param_kind) && $kind ne $param_kind;
+
+            push($result->@*, $type_info);
+        }
+
+        return $result;
+    },
+});
+
+# plugins/storage/{plugin}
+
+__PACKAGE__->register_method({
+    name => 'info',
+    path => '{plugin}',
+    method => 'GET',
+    description => "Show general information and metadata of a storage plugin.",
+    permissions => {
+        # TODO: perms
+        description => "",
+        user => 'all',
+    },
+    parameters => {
+        additionalProperties => 0,
+        properties => {
+            plugin => {
+                type => 'string',
+            },
+        },
+    },
+    returns => $PLUGIN_METADATA_SCHEMA,
+    code => sub($param) {
+        my $param_type = extract_param($param, 'plugin');
+
+        my $plugin_metadata = get_plugin_metadata($param_type)
+            or raise("Plugin '$param_type' not found", code => HTTP_NOT_FOUND);
+
+        # TODO: run in tests for each plugin instead?
+        PVE::JSONSchema::validate($plugin_metadata, $PLUGIN_METADATA_SCHEMA);
+
+        return $plugin_metadata;
+    },
+});
+
+1;
diff --git a/src/PVE/API2/Plugins/Storage/Makefile b/src/PVE/API2/Plugins/Storage/Makefile
new file mode 100644
index 0000000..73875cf
--- /dev/null
+++ b/src/PVE/API2/Plugins/Storage/Makefile
@@ -0,0 +1,17 @@
+SOURCES = Config.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
+
-- 
2.47.2



_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel


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

* [pve-devel] [RFC pve-storage master v1 03/12] plugin: meta: introduce 'short-name'
  2025-09-08 18:00 [pve-devel] [RFC pve-storage, pve-manager master v1 00/12] GUI Support for Custom Storage Plugins Max R. Carrara
  2025-09-08 18:00 ` [pve-devel] [RFC pve-storage master v1 01/12] plugin: meta: add package PVE::Storage::Plugin::Meta Max R. Carrara
  2025-09-08 18:00 ` [pve-devel] [RFC pve-storage master v1 02/12] api: Add 'plugins/storage' and 'plugins/storage/{plugin}' paths Max R. Carrara
@ 2025-09-08 18:00 ` Max R. Carrara
  2025-09-08 18:00 ` [pve-devel] [RFC pve-storage master v1 04/12] plugin: views: add package PVE::Storage::Plugin::Views Max R. Carrara
                   ` (9 subsequent siblings)
  12 siblings, 0 replies; 15+ messages in thread
From: Max R. Carrara @ 2025-09-08 18:00 UTC (permalink / raw)
  To: pve-devel

'short-name' is a new key that can be defined in a plugin's
`plugindata()`, containing the plugin's "colloquially used" or
abbreviated name.

For example:
- ZFS pool plugin / 'zfspool'      --> "ZFS"
- Directory plugin / 'dir'         --> "Directory"
- LVM thin pool plugin / 'lvmthin' --> "LVM-Thin"
- ... and so on.

This key is added so that custom storage plugins can define how they
are named in user interfaces and whatnot, instead of just using their
`type()`.

Optionally return 'short-name' as part of the 'plugins/storage' and
'plugins/storage/{plugin}' endpoints.

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

diff --git a/src/PVE/API2/Plugins/Storage/Config.pm b/src/PVE/API2/Plugins/Storage/Config.pm
index 064aec9..2160e4e 100644
--- a/src/PVE/API2/Plugins/Storage/Config.pm
+++ b/src/PVE/API2/Plugins/Storage/Config.pm
@@ -33,6 +33,10 @@ my $PLUGIN_METADATA_SCHEMA = {
             enum => plugin_kinds(),
             optional => 0,
         },
+        'short-name' => {
+            type => 'string',
+            optional => 1,
+        },
         content => {
             type => 'object',
             optional => 0,
diff --git a/src/PVE/Storage/Plugin/Meta.pm b/src/PVE/Storage/Plugin/Meta.pm
index 6d0cb51..561c01b 100644
--- a/src/PVE/Storage/Plugin/Meta.pm
+++ b/src/PVE/Storage/Plugin/Meta.pm
@@ -134,6 +134,9 @@ my sub assemble_plugin_metadata() {
             kind => $kind,
         };
 
+        $metadata->{'short-name'} = $plugin->plugindata()->{'short-name'}
+            if defined($plugin->plugindata()->{'short-name'});
+
         $metadata->{content} = assemble_plugin_metadata_content($plugin);
         $metadata->{format} = assemble_plugin_metadata_format($plugin);
 
-- 
2.47.2



_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel


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

* [pve-devel] [RFC pve-storage master v1 04/12] plugin: views: add package PVE::Storage::Plugin::Views
  2025-09-08 18:00 [pve-devel] [RFC pve-storage, pve-manager master v1 00/12] GUI Support for Custom Storage Plugins Max R. Carrara
                   ` (2 preceding siblings ...)
  2025-09-08 18:00 ` [pve-devel] [RFC pve-storage master v1 03/12] plugin: meta: introduce 'short-name' Max R. Carrara
@ 2025-09-08 18:00 ` Max R. Carrara
  2025-09-08 18:00 ` [pve-devel] [RFC pve-storage master v1 05/12] plugin: add new plugin API method `get_form_view()` Max R. Carrara
                   ` (8 subsequent siblings)
  12 siblings, 0 replies; 15+ messages in thread
From: Max R. Carrara @ 2025-09-08 18:00 UTC (permalink / raw)
  To: pve-devel

This package defines schemas and utils for storage plugin views.

A view in this context is what defines how data should be displayed to
users. Views are specified via a nested hash in Perl, which is then
serialized into JSON.

Right now, the only such view that can be defined is a "form" view.
A form view is simply the representation of a single record; in the
context of storage plugins here, this would be the form that opens
when you create or edit a single storage configuration entry in the
UI.

This commit adds a versioned JSON schema for storage plugin form
views. The first version of this form view supports customizing the
"General" tab in the following ways:

- Adding various types of columns:
  - Regular columns
  - A "bottom" column (the wide column below the regular ones)
  - Columns in the advanced subsection
  - A "bottom" column in the advanced subsection

- Adding fields to those columns
  - A field always corresponds to a SectionConfig property
  - Fields are typed and share common attributes
    (readonly, required, default)
  - Specific field types have specialized attributes unique to them,
    e.g. 'string' supports setting a 'display-mode', which can be
    'text', 'textarea' or 'password' ('text' by default)

Because each field corresponds to a SectionConfig property, the
existing API calls for creating & editing storage config entries can
simply be reused.

The ultimate goal here is to allow custom storage plugin authors to
allow integrating their plugin into our GUI with minimal effort and
without ever having to write JavaScript code. In fact, not being able
to write JS is a hard requirement for this feature.

The form view schema will be used in further commits after this one.

Some additional context:

Most of this approach is inspired by my past experience wrangling ERP
systems [0]. The ERP system I was developing modules for in particular
defined *a lot* of data models which could all be represented via
several generalized view types (such as form, list, gantt chart,
kanban, etc.). This was possible because all of those data models
shared a common base model and consequently a common database
representation as well. While all the applications within the system
were different, the way they were built was the same.

Furthermore, the idea expressed in this commit here is a
simplification of the somewhat commonly used MVVM architectural
pattern [1], in case that helps with understanding.

[0]: https://en.wikipedia.org/wiki/Enterprise_resource_planning
[1]: https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93viewmodel

Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
 src/PVE/Storage/Plugin/Makefile |   1 +
 src/PVE/Storage/Plugin/Views.pm | 242 ++++++++++++++++++++++++++++++++
 2 files changed, 243 insertions(+)
 create mode 100644 src/PVE/Storage/Plugin/Views.pm

diff --git a/src/PVE/Storage/Plugin/Makefile b/src/PVE/Storage/Plugin/Makefile
index ca82517..2e9b538 100644
--- a/src/PVE/Storage/Plugin/Makefile
+++ b/src/PVE/Storage/Plugin/Makefile
@@ -1,4 +1,5 @@
 SOURCES = Meta.pm		\
+	  Views.pm		\
 
 
 INSTALL_PATH = ${DESTDIR}${PERLDIR}/PVE/Storage/Plugin
diff --git a/src/PVE/Storage/Plugin/Views.pm b/src/PVE/Storage/Plugin/Views.pm
new file mode 100644
index 0000000..597c657
--- /dev/null
+++ b/src/PVE/Storage/Plugin/Views.pm
@@ -0,0 +1,242 @@
+package PVE::Storage::Plugin::Views;
+
+use v5.36;
+
+use Storable qw(dclone);
+
+use PVE::JSONSchema;
+
+use Exporter qw(import);
+
+our @EXPORT_OK = qw(
+    get_form_view_schema
+);
+
+=head1 NAME
+
+PVE::Storage::Plugin::Views - Schemas and Utils for Storage Plugin Views
+
+=head1 DESCRIPTION
+
+=for comment
+TODO
+
+=cut
+
+package PVE::Storage::Plugin::Views::v1 {
+    use v5.36;
+
+    use Storable qw(dclone);
+
+    use PVE::JSONSchema;
+
+    my $FIELD_TYPES = [
+        'boolean', 'integer', 'number', 'string', 'selection',
+    ];
+
+    my $ATTRIBUTES_COMMON = {
+        required => {
+            type => 'boolean',
+            optional => 1,
+        },
+        readonly => {
+            type => 'boolean',
+            optional => 1,
+        },
+        # NOTE: Overridden per field type; specified here to make clear that
+        # this is a common attribute
+        default => {
+            type => 'any',
+            optional => 1,
+        },
+    };
+
+    my $ATTRIBUTES_BOOLEAN = {
+        'instance-types' => ['boolean'],
+        $ATTRIBUTES_COMMON->%*,
+        default => {
+            type => 'boolean',
+            optional => 1,
+        },
+    };
+
+    my $ATTRIBUTES_INTEGER = {
+        'instance-types' => ['integer'],
+        $ATTRIBUTES_COMMON->%*,
+        default => {
+            type => 'integer',
+            optional => 1,
+        },
+    };
+
+    my $ATTRIBUTES_NUMBER = {
+        'instance-types' => ['number'],
+        $ATTRIBUTES_COMMON->%*,
+        default => {
+            type => 'number',
+            optional => 1,
+        },
+    };
+
+    my $ATTRIBUTES_STRING = {
+        'instance-types' => ['string'],
+        $ATTRIBUTES_COMMON->%*,
+        default => {
+            type => 'string',
+            optional => 1,
+        },
+        'display-mode' => {
+            type => 'string',
+            enum => ['text', 'textarea', 'password'],
+            optional => 1,
+            default => 'text',
+        },
+    };
+
+    my $ATTRIBUTES_SELECTION = {
+        'instance-types' => ['selection'],
+        $ATTRIBUTES_COMMON->%*,
+        'selection-mode' => {
+            type => 'string',
+            enum => ['single', 'multi'],
+            optional => 1,
+            default => 'single',
+        },
+        # List of "tuples" where the first element is the selection value,
+        # and the second element is how the selection value should be displayed to the user.
+        # For example:
+        # selection_values => [
+        #     ['gzip', "Compress using GZIP"],
+        #     ['zstd', "Compress using ZSTD"],
+        #     ['none', "No Compression"],
+        # ];
+        'selection-values' => {
+            type => 'array',
+            optional => 0,
+            items => {
+                type => 'array',
+                items => {
+                    type => 'string',
+                },
+            },
+        },
+        # The values selected by default on creation.
+        # Must exist in selection_values.
+        # If selection-mode is 'single', then only the first element is considered.
+        default => {
+            type => 'array',
+            items => {
+                type => 'string',
+            },
+            optional => 1,
+        },
+    };
+
+    my $FIELD_ATTRIBUTES_VARIANTS = [
+        $ATTRIBUTES_BOOLEAN,
+        $ATTRIBUTES_INTEGER,
+        $ATTRIBUTES_NUMBER,
+        $ATTRIBUTES_STRING,
+        $ATTRIBUTES_SELECTION,
+    ];
+
+    my $FIELD_SCHEMA = {
+        type => 'object',
+        properties => {
+            property => {
+                type => 'string',
+                optional => 0,
+            },
+            'field-type' => {
+                type => 'string',
+                enum => $FIELD_TYPES,
+                optional => 0,
+            },
+            label => {
+                type => 'string',
+                optional => 0,
+            },
+            attributes => {
+                type => 'object',
+                'type-property' => 'field-type',
+                oneOf => $FIELD_ATTRIBUTES_VARIANTS,
+                optional => 0,
+            },
+        },
+    };
+
+    my $COLUMN_SCHEMA = {
+        type => 'object',
+        properties => {
+            fields => {
+                type => 'array',
+                items => $FIELD_SCHEMA,
+                optional => 1,
+            },
+        },
+    };
+
+    my $FORM_VIEW_SCHEMA = {
+        type => 'object',
+        properties => {
+            general => {
+                type => 'object',
+                optional => 1,
+                properties => {
+                    columns => {
+                        type => 'array',
+                        items => $COLUMN_SCHEMA,
+                        optional => 1,
+                    },
+                    'column-bottom' => {
+                        $COLUMN_SCHEMA->%*, optional => 1,
+                    },
+                    'columns-advanced' => {
+                        type => 'array',
+                        items => $COLUMN_SCHEMA,
+                        optional => 1,
+                    },
+                    'column-advanced-bottom' => {
+                        $COLUMN_SCHEMA->%*, optional => 1,
+                    },
+                },
+            },
+        },
+    };
+
+    PVE::JSONSchema::validate_schema($FORM_VIEW_SCHEMA);
+
+    sub get_form_view_schema() {
+        return dclone($FORM_VIEW_SCHEMA);
+    }
+};
+
+my $API_FORM_VIEW_SCHEMA = {
+    type => 'object',
+    properties => {
+        version => {
+            type => 'integer',
+            enum => [1],
+            optional => 0,
+        },
+        definition => {
+            type => 'object',
+            'type-property' => 'version',
+            optional => 0,
+            oneOf => [
+                {
+                    'instance-types' => [1],
+                    PVE::Storage::Plugin::Views::v1::get_form_view_schema()->%*,
+                },
+            ],
+        },
+    },
+};
+
+PVE::JSONSchema::validate_schema($API_FORM_VIEW_SCHEMA);
+
+sub get_form_view_schema() {
+    return dclone($API_FORM_VIEW_SCHEMA);
+}
+
+1;
-- 
2.47.2



_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel


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

* [pve-devel] [RFC pve-storage master v1 05/12] plugin: add new plugin API method `get_form_view()`
  2025-09-08 18:00 [pve-devel] [RFC pve-storage, pve-manager master v1 00/12] GUI Support for Custom Storage Plugins Max R. Carrara
                   ` (3 preceding siblings ...)
  2025-09-08 18:00 ` [pve-devel] [RFC pve-storage master v1 04/12] plugin: views: add package PVE::Storage::Plugin::Views Max R. Carrara
@ 2025-09-08 18:00 ` Max R. Carrara
  2025-09-08 18:00 ` [pve-devel] [RFC pve-storage master v1 06/12] plugin: meta: add metadata regarding views in API Max R. Carrara
                   ` (7 subsequent siblings)
  12 siblings, 0 replies; 15+ messages in thread
From: Max R. Carrara @ 2025-09-08 18:00 UTC (permalink / raw)
  To: pve-devel

This method returns a nested hashref containing the form view
definition for the given plugin in `$class`. The returned hashref must
correspond to the form view schema in the `::Plugin::Views` package.

Plugins that define a form view must specify so in `plugindata()`:

    sub plugindata {
        return {
            # [...]
            views => {
                form => 1,
            },
        };
    }

Returns undef by default otherwise.

`get_form_view()` takes a single parameter (besides `$class`) named
`$context`, which is a hashref that contains additional context vars
that may or may not be taken account by the method.

Currently, the only such context variable is `mode`, which can be
either 'create' or 'update', corresponding to whether a configuration
entry belonging to the plugin is being created or edited (-> updated).

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

diff --git a/src/PVE/Storage/Plugin.pm b/src/PVE/Storage/Plugin.pm
index 2291d72..0356a25 100644
--- a/src/PVE/Storage/Plugin.pm
+++ b/src/PVE/Storage/Plugin.pm
@@ -684,6 +684,14 @@ sub on_delete_hook {
     return undef;
 }
 
+# TODO: POD docstring
+sub get_form_view {
+    my ($class, $context) = @_;
+
+    # no form view defined by default
+    return undef;
+}
+
 sub cluster_lock_storage {
     my ($class, $storeid, $shared, $timeout, $func, @param) = @_;
 
-- 
2.47.2



_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel


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

* [pve-devel] [RFC pve-storage master v1 06/12] plugin: meta: add metadata regarding views in API
  2025-09-08 18:00 [pve-devel] [RFC pve-storage, pve-manager master v1 00/12] GUI Support for Custom Storage Plugins Max R. Carrara
                   ` (4 preceding siblings ...)
  2025-09-08 18:00 ` [pve-devel] [RFC pve-storage master v1 05/12] plugin: add new plugin API method `get_form_view()` Max R. Carrara
@ 2025-09-08 18:00 ` Max R. Carrara
  2025-09-08 18:00 ` [pve-devel] [RFC pve-storage master v1 07/12] api: views: add paths regarding storage plugin views Max R. Carrara
                   ` (6 subsequent siblings)
  12 siblings, 0 replies; 15+ messages in thread
From: Max R. Carrara @ 2025-09-08 18:00 UTC (permalink / raw)
  To: pve-devel

Define and expose currently possible view types and view modes in
`::Plugin::Meta`.

Add a 'views' array to the objects returned by the 'plugins/storage'
and 'plugins/storage/{plugin}' endpoints, containing all view types
that a plugin currently supports.

Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
 src/PVE/API2/Plugins/Storage/Config.pm |  9 ++++++
 src/PVE/Storage/Plugin/Meta.pm         | 40 ++++++++++++++++++++++++++
 2 files changed, 49 insertions(+)

diff --git a/src/PVE/API2/Plugins/Storage/Config.pm b/src/PVE/API2/Plugins/Storage/Config.pm
index 2160e4e..60c0515 100644
--- a/src/PVE/API2/Plugins/Storage/Config.pm
+++ b/src/PVE/API2/Plugins/Storage/Config.pm
@@ -12,6 +12,7 @@ use PVE::Storage::Plugin::Meta qw(
     plugin_kinds
     plugin_content_types
     plugin_formats
+    plugin_view_types
     get_plugin_metadata
     get_plugin_metadata_all
 );
@@ -81,6 +82,14 @@ my $PLUGIN_METADATA_SCHEMA = {
                 type => 'string',
             },
         },
+        views => {
+            type => 'array',
+            optional => 0,
+            items => {
+                type => 'string',
+                enum => plugin_view_types(),
+            },
+        },
     },
 };
 
diff --git a/src/PVE/Storage/Plugin/Meta.pm b/src/PVE/Storage/Plugin/Meta.pm
index 561c01b..38d6279 100644
--- a/src/PVE/Storage/Plugin/Meta.pm
+++ b/src/PVE/Storage/Plugin/Meta.pm
@@ -14,6 +14,8 @@ our @EXPORT_OK = qw(
     plugin_kinds
     plugin_content_types
     plugin_formats
+    plugin_view_types
+    plugin_view_modes
     get_plugin_metadata
     get_plugin_metadata_all
 );
@@ -44,6 +46,14 @@ my $PLUGIN_FORMATS = [
 
 my $DEFAULT_PLUGIN_FORMAT = 'raw';
 
+my $PLUGIN_VIEW_TYPES = [
+    'form',
+];
+
+my $PLUGIN_VIEW_MODES = [
+    'create', 'update',
+];
+
 sub plugin_kinds() {
     return [$PLUGIN_KINDS->@*];
 }
@@ -56,6 +66,14 @@ sub plugin_formats() {
     return [$PLUGIN_FORMATS->@*];
 }
 
+sub plugin_view_types() {
+    return [$PLUGIN_VIEW_TYPES->@*];
+}
+
+sub plugin_view_modes() {
+    return [$PLUGIN_VIEW_MODES->@*];
+}
+
 my $plugin_metadata = undef;
 
 my sub assemble_plugin_metadata_content($plugin) {
@@ -114,6 +132,26 @@ my sub assemble_plugin_metadata_format($plugin) {
     return $format_metadata;
 }
 
+my sub assemble_plugin_metadata_views($plugin) {
+    confess '$plugin is undef' if !defined($plugin);
+
+    my $plugindata = $plugin->plugindata();
+
+    return [] if !defined($plugindata->{views});
+
+    my $view_metadata = [];
+
+    my $views = $plugindata->{views};
+
+    for my $view ($PLUGIN_VIEW_TYPES->@*) {
+        if (defined($views->{$view})) {
+            push($view_metadata->@*, $view);
+        }
+    }
+
+    return $view_metadata;
+}
+
 my sub assemble_plugin_metadata() {
     return if defined($plugin_metadata);
 
@@ -145,6 +183,8 @@ my sub assemble_plugin_metadata() {
         $metadata->{'sensitive-properties'} =
             [grep { $sensitive_properties->{$_} } sort keys $sensitive_properties->%*];
 
+        $metadata->{views} = assemble_plugin_metadata_views($plugin);
+
         $plugin_metadata->{$type} = $metadata;
     }
 
-- 
2.47.2



_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel


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

* [pve-devel] [RFC pve-storage master v1 07/12] api: views: add paths regarding storage plugin views
  2025-09-08 18:00 [pve-devel] [RFC pve-storage, pve-manager master v1 00/12] GUI Support for Custom Storage Plugins Max R. Carrara
                   ` (5 preceding siblings ...)
  2025-09-08 18:00 ` [pve-devel] [RFC pve-storage master v1 06/12] plugin: meta: add metadata regarding views in API Max R. Carrara
@ 2025-09-08 18:00 ` Max R. Carrara
  2025-09-08 18:00 ` [pve-devel] [RFC pve-storage master v1 08/12] plugin: zfspool: add 'short-name' and form view for ZFS pool plugin Max R. Carrara
                   ` (5 subsequent siblings)
  12 siblings, 0 replies; 15+ messages in thread
From: Max R. Carrara @ 2025-09-08 18:00 UTC (permalink / raw)
  To: pve-devel

This commit adds the following paths:

- 'plugins/storage/{plugin}/views': Returns an array of objects
  describing the views that a plugin currently supports. If no views
  are supported, the array is empty.

- 'plugins/storage/{plugin}/views/form': Returns the form view
  definition of the given plugin.

Like in an earlier commit, the form view is always validated against
its JSON schema after it was fetched. This is rather suboptimal at the
moment and will be done within tests in the future. Right now this is
left in so as to keep the RFC smaller.

Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
 src/PVE/API2/Plugins/Storage/Config.pm |   7 +
 src/PVE/API2/Plugins/Storage/Makefile  |   1 +
 src/PVE/API2/Plugins/Storage/Views.pm  | 172 +++++++++++++++++++++++++
 3 files changed, 180 insertions(+)
 create mode 100644 src/PVE/API2/Plugins/Storage/Views.pm

diff --git a/src/PVE/API2/Plugins/Storage/Config.pm b/src/PVE/API2/Plugins/Storage/Config.pm
index 60c0515..3f57784 100644
--- a/src/PVE/API2/Plugins/Storage/Config.pm
+++ b/src/PVE/API2/Plugins/Storage/Config.pm
@@ -18,9 +18,16 @@ use PVE::Storage::Plugin::Meta qw(
 );
 use PVE::Tools qw(extract_param);
 
+use PVE::API2::Plugins::Storage::Views;
+
 use PVE::RESTHandler;
 use base qw(PVE::RESTHandler);
 
+__PACKAGE__->register_method({
+    subclass => 'PVE::API2::Plugins::Storage::Views',
+    path => '{plugin}/views',
+});
+
 my $PLUGIN_METADATA_SCHEMA = {
     type => 'object',
     properties => {
diff --git a/src/PVE/API2/Plugins/Storage/Makefile b/src/PVE/API2/Plugins/Storage/Makefile
index 73875cf..83fab2e 100644
--- a/src/PVE/API2/Plugins/Storage/Makefile
+++ b/src/PVE/API2/Plugins/Storage/Makefile
@@ -1,4 +1,5 @@
 SOURCES = Config.pm		\
+	  Views.pm		\
 
 
 SUBDIRS =
diff --git a/src/PVE/API2/Plugins/Storage/Views.pm b/src/PVE/API2/Plugins/Storage/Views.pm
new file mode 100644
index 0000000..7419fb5
--- /dev/null
+++ b/src/PVE/API2/Plugins/Storage/Views.pm
@@ -0,0 +1,172 @@
+package PVE::API2::Plugins::Storage::Views;
+
+use v5.36;
+
+use List::Util qw(any);
+
+use HTTP::Status qw(:constants);
+
+use PVE::Exception qw(raise);
+use PVE::Storage;
+use PVE::Storage::Plugin;
+use PVE::Storage::Plugin::Meta qw(
+    plugin_view_types
+    plugin_view_modes
+    get_plugin_metadata
+);
+use PVE::Storage::Plugin::Views qw(
+    get_form_view_schema
+);
+use PVE::Tools qw(extract_param);
+
+use PVE::RESTHandler;
+
+use base qw(PVE::RESTHandler);
+
+# plugins/storage/{plugin}/views
+
+__PACKAGE__->register_method({
+    name => 'index',
+    path => '',
+    method => 'GET',
+    description => "Return available views for a plugin.",
+    permissions => {
+        # TODO: perms
+        description => "",
+        user => 'all',
+    },
+    parameters => {
+        additionalProperties => 0,
+    },
+    # NOTE: Intentionally returning an array of objects here for forward compat
+    returns => {
+        type => 'array',
+        items => {
+            type => 'object',
+            properties => {
+                'view-type' => {
+                    type => 'string',
+                    enum => plugin_view_types(),
+                    optional => 0,
+                },
+            },
+        },
+    },
+    code => sub($param) {
+        my $param_type = extract_param($param, 'plugin');
+
+        my $metadata = get_plugin_metadata($param_type)
+            or raise("Plugin '$param_type' not found", code => HTTP_NOT_FOUND);
+
+        my $result = [];
+
+        for my $view_type ($metadata->{views}->@*) {
+            my $view_spec = {
+                'view-type' => $view_type,
+            };
+
+            push($result->@*, $view_spec);
+        }
+
+        return $result;
+    },
+});
+
+# plugins/storage/{plugin}/views/form
+
+__PACKAGE__->register_method({
+    name => 'form',
+    path => 'form',
+    method => 'GET',
+    description => "Return a plugin's form view.",
+    permissions => {
+        # TODO: perms
+        description => "",
+        user => 'all',
+    },
+    parameters => {
+        additionalProperties => 0,
+        properties => {
+            plugin => {
+                type => 'string',
+                optional => 0,
+            },
+            mode => {
+                description => "The mode for which to return the view."
+                    . " Can be either 'create' or 'update', depending on whether"
+                    . " the storage is being created or updated (edited).",
+                type => 'string',
+                enum => plugin_view_modes(),
+                optional => 1,
+                default => 'create',
+            },
+        },
+    },
+    returns => get_form_view_schema(),
+    code => sub($param) {
+        my $param_type = extract_param($param, 'plugin');
+        my $param_mode = extract_param($param, 'mode') // 'create';
+
+        my $metadata = get_plugin_metadata($param_type)
+            or raise("Plugin '$param_type' not found", code => HTTP_NOT_FOUND);
+
+        my $views = $metadata->{views} // [];
+        raise("Plugin '$param_type' defines no views", code => HTTP_BAD_REQUEST)
+            if !scalar($views->@*);
+
+        my $has_form_view = any { $_ eq 'form' } $views->@*;
+
+        raise("Plugin '$param_type' has no form view", code => HTTP_BAD_REQUEST)
+            if !$has_form_view;
+
+        my $plugin = PVE::Storage::Plugin->lookup($param_type);
+
+        my $context = {
+            mode => $param_mode,
+        };
+
+        my $view = eval { $plugin->get_form_view($context) };
+        if (my $err = $@) {
+            raise(
+                "Error while fetching form view for plugin '$param_type': $err",
+                code => HTTP_INTERNAL_SERVER_ERROR,
+            );
+        }
+
+        if (!defined($view)) {
+            raise(
+                "Form view for plugin '$param_type' is undefined",
+                code => HTTP_INTERNAL_SERVER_ERROR,
+            );
+        }
+
+        # TODO: run in tests instead?
+        # --> test with different contexts (mode only right now)
+        eval { PVE::JSONSchema::validate($view, get_form_view_schema()); };
+        if (my $err = $@) {
+            # NOTE: left in only for debugging purposes at the moment
+            require Data::Dumper;
+
+            local $Data::Dumper::Terse = 1;
+            local $Data::Dumper::Indent = 1;
+            local $Data::Dumper::Useqq = 1;
+            local $Data::Dumper::Deparse = 1;
+            local $Data::Dumper::Quotekeys = 0;
+            local $Data::Dumper::Sortkeys = 1;
+            local $Data::Dumper::Trailingcomma = 1;
+
+            warn "Failed to validate form view of plugin '$param_type':\n$err\n";
+            warn '$context = ' . Dumper($context) . "\n";
+            warn '$view = ' . Dumper($view) . "\n";
+
+            raise(
+                "Failed to validate form view of plugin '$param_type':\n$err\n",
+                code => HTTP_INTERNAL_SERVER_ERROR,
+            );
+        }
+
+        return $view;
+    },
+});
+
+1;
-- 
2.47.2



_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel


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

* [pve-devel] [RFC pve-storage master v1 08/12] plugin: zfspool: add 'short-name' and form view for ZFS pool plugin
  2025-09-08 18:00 [pve-devel] [RFC pve-storage, pve-manager master v1 00/12] GUI Support for Custom Storage Plugins Max R. Carrara
                   ` (6 preceding siblings ...)
  2025-09-08 18:00 ` [pve-devel] [RFC pve-storage master v1 07/12] api: views: add paths regarding storage plugin views Max R. Carrara
@ 2025-09-08 18:00 ` Max R. Carrara
  2025-09-08 18:00 ` [pve-devel] [RFC pve-manager master v1 09/12] api: handle path 'plugins/storage' through its package Max R. Carrara
                   ` (4 subsequent siblings)
  12 siblings, 0 replies; 15+ messages in thread
From: Max R. Carrara @ 2025-09-08 18:00 UTC (permalink / raw)
  To: pve-devel

This commit demonstrates how the 'short-name' key and the
`get_form_view()` method can be added to a plugin.

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

diff --git a/src/PVE/Storage/ZFSPoolPlugin.pm b/src/PVE/Storage/ZFSPoolPlugin.pm
index d8d8d0f..6de5ee1 100644
--- a/src/PVE/Storage/ZFSPoolPlugin.pm
+++ b/src/PVE/Storage/ZFSPoolPlugin.pm
@@ -20,9 +20,13 @@ sub type {
 
 sub plugindata {
     return {
+        'short-name' => 'ZFS',
         content => [{ images => 1, rootdir => 1 }, { images => 1, rootdir => 1 }],
         format => [{ raw => 1, subvol => 1 }, 'raw'],
         'sensitive-properties' => {},
+        views => {
+            form => 1,
+        },
     };
 }
 
@@ -140,6 +144,69 @@ sub on_add_hook {
     return;
 }
 
+sub get_form_view {
+    my ($class, $context) = @_;
+
+    my $pool_selection_values = [];
+
+    my $cmd = ['zfs', 'list', '-t', 'filesystem', '-Hp', '-o', 'name'];
+    run_command(
+        $cmd,
+        outfunc => sub {
+            my ($line) = @_;
+
+            if ($line =~ m/ ^ (?<pool>\S+) \s* $/xn) {
+                my $pool = $+{pool};
+                push($pool_selection_values->@*, [$pool, $pool]);
+            }
+        },
+    );
+
+    $pool_selection_values = [ sort { $a->[0] cmp $b->[0] } $pool_selection_values->@* ];
+
+    my $view = {
+        version => 1,
+        definition => {
+            general => {
+                columns => [
+                    {
+                        fields => [
+                            {
+                                property => 'pool',
+                                label => "Pool",
+                                'field-type' => 'selection',
+                                attributes => {
+                                    'selection-values' => $pool_selection_values,
+                                    required => 1,
+                                    readonly => $context->{mode} eq 'update',
+                                },
+                            },
+                        ],
+                    },
+                    {
+                        fields => [
+                            {
+                                property => 'sparse',
+                                label => "Thin provision",
+                                'field-type' => 'boolean',
+                                attributes => {},
+                            },
+                            {
+                                property => 'blocksize',
+                                label => "Block Size",
+                                'field-type' => 'string',
+                                attributes => {},
+                            },
+                        ],
+                    },
+                ],
+            },
+        },
+    };
+
+    return $view;
+}
+
 sub path {
     my ($class, $scfg, $volname, $storeid, $snapname) = @_;
 
-- 
2.47.2



_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel


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

* [pve-devel] [RFC pve-manager master v1 09/12] api: handle path 'plugins/storage' through its package
  2025-09-08 18:00 [pve-devel] [RFC pve-storage, pve-manager master v1 00/12] GUI Support for Custom Storage Plugins Max R. Carrara
                   ` (7 preceding siblings ...)
  2025-09-08 18:00 ` [pve-devel] [RFC pve-storage master v1 08/12] plugin: zfspool: add 'short-name' and form view for ZFS pool plugin Max R. Carrara
@ 2025-09-08 18:00 ` Max R. Carrara
  2025-09-08 18:00 ` [pve-devel] [RFC pve-manager master v1 10/12] ui: storage: add CustomBase.js Max R. Carrara
                   ` (3 subsequent siblings)
  12 siblings, 0 replies; 15+ messages in thread
From: Max R. Carrara @ 2025-09-08 18:00 UTC (permalink / raw)
  To: pve-devel

... so that all the previously added API paths can actually be called.

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

diff --git a/PVE/API2.pm b/PVE/API2.pm
index 0c7e4654..daa78f28 100644
--- a/PVE/API2.pm
+++ b/PVE/API2.pm
@@ -17,6 +17,7 @@ use PVE::API2::Nodes;
 use PVE::API2::Pool;
 use PVE::API2::AccessControl;
 use PVE::API2::Storage::Config;
+use PVE::API2::Plugins::Storage::Config;
 
 __PACKAGE__->register_method({
     subclass => "PVE::API2::Cluster",
@@ -43,6 +44,11 @@ __PACKAGE__->register_method({
     path => 'pools',
 });
 
+__PACKAGE__->register_method({
+    subclass => "PVE::API2::Plugins::Storage::Config",
+    path => 'plugins/storage',
+});
+
 __PACKAGE__->register_method({
     name => 'index',
     path => '',
-- 
2.47.2



_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel


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

* [pve-devel] [RFC pve-manager master v1 10/12] ui: storage: add CustomBase.js
  2025-09-08 18:00 [pve-devel] [RFC pve-storage, pve-manager master v1 00/12] GUI Support for Custom Storage Plugins Max R. Carrara
                   ` (8 preceding siblings ...)
  2025-09-08 18:00 ` [pve-devel] [RFC pve-manager master v1 09/12] api: handle path 'plugins/storage' through its package Max R. Carrara
@ 2025-09-08 18:00 ` Max R. Carrara
  2025-09-08 18:00 ` [pve-devel] [RFC pve-manager master v1 11/12] ui: storage: support custom storage plugins in Datacenter > Storage Max R. Carrara
                   ` (2 subsequent siblings)
  12 siblings, 0 replies; 15+ messages in thread
From: Max R. Carrara @ 2025-09-08 18:00 UTC (permalink / raw)
  To: pve-devel

Add CustomBase.js, a copy of Base.js specifically for custom form
views of storage plugin configs.

While there is a large overlap between the files' contents, they are
still kept separate for the purposes of this RFC. This makes it
easier to differ between how custom storage plugins and inbuilt
storage plugins are handled in the GUI at the moment, until this idea
has been fleshed out more.

The main UI building logic is in `PVE.storage.CustomInputPanel`. Right
now, there are no custom fields or anything of the sort; the field's
Ext.JS code is simply stitched together piece by piece depending on
the form view definition provided.

The fields for the 'storage', 'content', 'nodes' and 'disable'
('enable') are always included in every form view and cannot be
disabled at the moment, as they exist in virtually every storage
plugin.

Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
 www/manager6/Makefile              |   1 +
 www/manager6/storage/CustomBase.js | 402 +++++++++++++++++++++++++++++
 2 files changed, 403 insertions(+)
 create mode 100644 www/manager6/storage/CustomBase.js

diff --git a/www/manager6/Makefile b/www/manager6/Makefile
index 85f9268d..a329d36e 100644
--- a/www/manager6/Makefile
+++ b/www/manager6/Makefile
@@ -326,6 +326,7 @@ JSSRC= 							\
 	storage/ContentView.js				\
 	storage/BackupView.js				\
 	storage/Base.js					\
+	storage/CustomBase.js				\
 	storage/Browser.js				\
 	storage/CIFSEdit.js				\
 	storage/CephFSEdit.js				\
diff --git a/www/manager6/storage/CustomBase.js b/www/manager6/storage/CustomBase.js
new file mode 100644
index 00000000..9ee2417c
--- /dev/null
+++ b/www/manager6/storage/CustomBase.js
@@ -0,0 +1,402 @@
+Ext.define('PVE.panel.CustomStorageBase', {
+    extend: 'Proxmox.panel.InputPanel',
+    controller: 'storageEdit',
+
+    type: '',
+
+    onGetValues: function (values) {
+        let me = this;
+
+        if (me.isCreate) {
+            values.type = me.type;
+        } else {
+            delete values.storage;
+        }
+
+        values.disable = values.enable ? 0 : 1;
+        delete values.enable;
+
+        return values;
+    },
+
+    initComponent: function () {
+        let me = this;
+
+        me.column1.unshift(
+            {
+                xtype: me.isCreate ? 'textfield' : 'displayfield',
+                name: 'storage',
+                value: me.storageId || '',
+                fieldLabel: 'ID',
+                vtype: 'StorageId',
+                allowBlank: false,
+            },
+            {
+                xtype: 'pveContentTypeSelector',
+                cts: me.metadataForPlugin.content.supported,
+                fieldLabel: gettext('Content'),
+                name: 'content',
+                value: me.metadataForPlugin.content.default,
+                multiSelect: true,
+                allowBlank: false,
+            },
+        );
+
+        if (!me.column2) {
+            me.column2 = [];
+        }
+
+        me.column2.unshift(
+            {
+                xtype: 'pveNodeSelector',
+                name: 'nodes',
+                reference: 'storageNodeRestriction',
+                disabled: me.storageId === 'local',
+                fieldLabel: gettext('Nodes'),
+                emptyText: gettext('All') + ' (' + gettext('No restrictions') + ')',
+                multiSelect: true,
+                autoSelect: false,
+            },
+            {
+                xtype: 'proxmoxcheckbox',
+                name: 'enable',
+                checked: true,
+                uncheckedValue: 0,
+                fieldLabel: gettext('Enable'),
+            },
+        );
+
+        me.callParent();
+    },
+});
+
+Ext.define('PVE.storage.CustomBaseEdit', {
+    extend: 'Proxmox.window.Edit',
+
+    apiCallDone: function (success, response, options) {
+        let me = this;
+        if (typeof me.ipanel.apiCallDone === 'function') {
+            me.ipanel.apiCallDone(success, response, options);
+        }
+    },
+
+    initComponent: function () {
+        let me = this;
+
+        me.isCreate = !me.storageId;
+
+        if (me.isCreate) {
+            me.url = '/api2/extjs/storage';
+            me.method = 'POST';
+        } else {
+            me.url = '/api2/extjs/storage/' + me.storageId;
+            me.method = 'PUT';
+        }
+
+        me.ipanel = Ext.create(me.paneltype, {
+            title: gettext('General'),
+            type: me.type,
+            isCreate: me.isCreate,
+            storageId: me.storageId,
+            formView: me.formView,
+            metadataForPlugin: me.metadataForPlugin,
+        });
+
+        let subject = me.metadataForPlugin['short-name'] || PVE.Utils.format_storage_type(me.type);
+
+        Ext.apply(me, {
+            subject: subject,
+            isAdd: true,
+            bodyPadding: 0,
+            items: {
+                xtype: 'tabpanel',
+                region: 'center',
+                layout: 'fit',
+                bodyPadding: 10,
+                items: [
+                    me.ipanel,
+                    {
+                        xtype: 'pveBackupJobPrunePanel',
+                        title: gettext('Backup Retention'),
+                        hasMaxProtected: true,
+                        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",
+                        ),
+                    },
+                ],
+            },
+        });
+
+        if (me.ipanel.extraTabs) {
+            me.ipanel.extraTabs.forEach((panel) => {
+                panel.isCreate = me.isCreate;
+                me.items.items.push(panel);
+            });
+        }
+
+        me.callParent();
+
+        if (!me.canDoBackups) {
+            // cannot mask now, not fully rendered until activated
+            me.down('pmxPruneInputPanel').needMask = true;
+        }
+
+        if (!me.isCreate) {
+            me.load({
+                success: function (response, options) {
+                    let values = response.result.data;
+                    let ctypes = values.content || '';
+
+                    values.content = ctypes.split(',');
+
+                    if (values.nodes) {
+                        values.nodes = values.nodes.split(',');
+                    }
+                    values.enable = values.disable ? 0 : 1;
+                    if (values['prune-backups']) {
+                        let retention = PVE.Parser.parsePropertyString(values['prune-backups']);
+                        delete values['prune-backups'];
+                        Object.assign(values, retention);
+                    }
+
+                    me.query('inputpanel').forEach((panel) => {
+                        panel.setValues(values);
+                    });
+                },
+            });
+        }
+    },
+});
+
+Ext.define('PVE.storage.CustomInputPanel', {
+    extend: 'PVE.panel.CustomStorageBase',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    onlineHelp: '',
+
+    buildFieldFromDefinition: function (me, fieldDef) {
+        let { property, label, attributes } = fieldDef;
+
+        if (property in me.visitedStorageProperties) {
+            throw (
+                `duplicate property '${property}' in form view` +
+                ` for custom storage plugin '${me.type}'`
+            );
+        }
+
+        me.visitedStorageProperties[property] = 1;
+
+        let field = {
+            name: property,
+            fieldLabel: label,
+            cbind: {},
+        };
+
+        switch (fieldDef['field-type']) {
+            case 'boolean':
+                field.xtype = 'proxmoxcheckbox';
+                field.uncheckedValue = 0;
+                break;
+
+            case 'integer':
+                field.xtype = 'proxmoxintegerfield';
+                break;
+
+            case 'number':
+                field.xtype = 'numberfield';
+                break;
+
+            case 'string':
+                switch (attributes['display-mode']) {
+                    case 'text':
+                        field.xtype = 'textfield';
+                        break;
+                    case 'textarea':
+                        field.xtype = 'textarea';
+                        break;
+                    case 'password':
+                        field.xtype = 'proxmoxtextfield';
+                        field.inputType = 'password';
+                        break;
+                    default:
+                        field.xtype = 'textfield';
+                }
+
+                break;
+
+            case 'selection':
+                field.xtype = 'proxmoxKVComboBox';
+                field.comboItems = attributes['selection-values'] || [];
+                field.autoSelect = true;
+
+                if (me.isCreate) {
+                    let firstPair = attributes['selection-values'][0];
+                    if (firstPair) {
+                        field.value = firstPair[0];
+                    }
+                }
+
+                switch (attributes['selection-mode']) {
+                    case 'single':
+                        field.multiSelect = false;
+                        break;
+                    case 'multi':
+                        field.multiSelect = true;
+                        break;
+                    case 'default':
+                        field.multiSelect = false;
+                }
+
+                break;
+
+            default:
+                field.xtype = 'displayfield';
+                break;
+        }
+
+        // **Common Attributes**
+        // required
+        if (attributes.required) {
+            field.allowBlank = false;
+        }
+
+        // readonly
+        if (attributes.readonly) {
+            switch (fieldDef['field-type']) {
+                case 'boolean':
+                    field.disabled = true;
+                    break;
+                case 'integer':
+                    field.xtype = 'displayfield';
+                    break;
+                case 'number':
+                    field.xtype = 'displayfield';
+                    break;
+                case 'string':
+                    field.xtype = 'displayfield';
+                    break;
+                case 'selection':
+                    field.xtype = 'displayfield';
+                    break;
+            }
+        }
+
+        // default
+        if (attributes.default && me.isCreate) {
+            switch (fieldDef['field-type']) {
+                case 'boolean':
+                    field.value = Boolean(attributes.default);
+                    field.checked = Boolean(attributes.default);
+                    break;
+
+                case 'integer':
+                    field.value = Number(attributes.default);
+                    break;
+
+                case 'number':
+                    field.value = Number(attributes.default);
+                    break;
+
+                case 'string':
+                    field.value = attributes.default;
+                    break;
+
+                case 'selection':
+                    switch (attributes['selection-mode']) {
+                        case 'single':
+                            field.value = attributes.default[0];
+                            break;
+
+                        case 'multi':
+                            field.value = attributes.default;
+                            break;
+
+                        default:
+                            field.value = attributes.default[0];
+                    }
+                    break;
+            }
+        }
+
+        return field;
+    },
+
+    buildColumnFromDefinition: function (me, columnDef) {
+        return columnDef.fields.map((fieldDef) => me.buildFieldFromDefinition(me, fieldDef));
+    },
+
+    initComponent: function () {
+        let me = this;
+
+        // TODO: take schema version into account
+
+        me.visitedStorageProperties = {
+            storage: 1,
+            content: 1,
+            notes: 1,
+            disable: 1,
+            enable: 1,
+        };
+
+        const viewDef = me.formView.definition.general;
+        const maxColumns = 2;
+        const maxAdvancedColumns = 2;
+
+        let columns = viewDef.columns ?? [];
+        let columnBottom = viewDef['column-bottom'];
+        let advancedColumns = viewDef['columns-advanced'] ?? [];
+        let advancedColumnBottom = viewDef['column-advanced-bottom'];
+
+        let columnCount = Math.min(columns.length, maxColumns);
+
+        let advancedColumnCount = Math.min(advancedColumns.length, maxAdvancedColumns);
+
+        try {
+            columns.slice(0, columnCount).map((columnDef, index) => {
+                let colName = 'column' + (index + 1);
+
+                if (!me[colName]) {
+                    me[colName] = [];
+                }
+
+                me[colName] = me[colName].concat(me.buildColumnFromDefinition(me, columnDef));
+            });
+
+            if (columnBottom) {
+                if (!me.columnB) {
+                    me.columnB = [];
+                }
+
+                me.columnB = me.columnB.concat(me.buildColumnFromDefinition(me, columnBottom));
+            }
+
+            advancedColumns.slice(0, advancedColumnCount).map((columnDef, index) => {
+                let colName = 'advancedColumn' + (index + 1);
+
+                if (!me[colName]) {
+                    me[colName] = [];
+                }
+
+                me[colName] = me[colName].concat(me.buildColumnFromDefinition(me, columnDef));
+            });
+
+            if (advancedColumnBottom) {
+                if (!me.advancedColumnB) {
+                    me.advancedColumnB = [];
+                }
+
+                me.advancedColumnB = me.advancedColumnB.concat(
+                    me.buildColumnFromDefinition(me, advancedColumnBottom),
+                );
+            }
+        } catch (error) {
+            Ext.Msg.alert(gettext('Error'), error);
+            return;
+        }
+
+        me.callParent();
+    },
+});
-- 
2.47.2



_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel


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

* [pve-devel] [RFC pve-manager master v1 11/12] ui: storage: support custom storage plugins in Datacenter > Storage
  2025-09-08 18:00 [pve-devel] [RFC pve-storage, pve-manager master v1 00/12] GUI Support for Custom Storage Plugins Max R. Carrara
                   ` (9 preceding siblings ...)
  2025-09-08 18:00 ` [pve-devel] [RFC pve-manager master v1 10/12] ui: storage: add CustomBase.js Max R. Carrara
@ 2025-09-08 18:00 ` Max R. Carrara
  2025-09-08 18:00 ` [pve-devel] [RFC pve-manager master v1 12/12] ui: storage: use `Ext.Msg.alert()` instead of throwing an exception Max R. Carrara
  2025-09-08 19:23 ` [pve-devel] [RFC pve-storage, pve-manager master v1 00/12] GUI Support for Custom Storage Plugins Thomas Lamprecht
  12 siblings, 0 replies; 15+ messages in thread
From: Max R. Carrara @ 2025-09-08 18:00 UTC (permalink / raw)
  To: pve-devel

This commit implements support for custom form views for custom
storage plugins. This is achieved by adapting the
`createStorageEditWindow()` function to request the plugin's form view
if it's a custom plugin. Whether a plugin is custom or not is first
determined by fetching the metadata of all plugins.

Furthermore, custom storage plugins now show up in the menu of the
"Add" button. If a custom plugin defines a form view, the user can
create a new storage config entry using the GUI.

The 'short-name' of custom plugins is now also displayed in the
storage config list as well as the dropdown selection list of the
"Add" button, if present. Otherwise, we fall back to just using the
plugin's type, as we were already doing.

The items in the menu of the "Add" button are now added dynamically.
This is because making requests is asynchronous. Anything else has led
to various exceptions being thrown while testing this, due to race
conditions.

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

diff --git a/www/manager6/dc/StorageView.js b/www/manager6/dc/StorageView.js
index e4c6f07d..f4515c94 100644
--- a/www/manager6/dc/StorageView.js
+++ b/www/manager6/dc/StorageView.js
@@ -11,6 +11,47 @@ Ext.define(
         stateId: 'grid-dc-storage',
 
         createStorageEditWindow: function (type, sid) {
+            let me = this;
+
+            let metadataForPlugin = me.pluginMetadata[type];
+
+            if (!metadataForPlugin) {
+                Ext.Msg.alert(gettext('Error'), `Plugin '${type}' has no metadata`);
+                return;
+            }
+
+            // NOTE: zfspool is only hardcoded for demonstration purposes
+            if (metadataForPlugin.kind === 'custom' || type === 'zfspool') {
+                Proxmox.Utils.API2Request({
+                    url: `/api2/extjs/plugins/storage/${type}/views/form`,
+                    method: 'GET',
+                    params: {
+                        mode: sid ? 'update' : 'create',
+                    },
+                    failure: function ({ htmlStatus }) {
+                        Ext.Msg.alert(gettext('Error'), htmlStatus);
+                    },
+                    success: function ({ result: { data } }) {
+                        let formView = data;
+
+                        Ext.create('PVE.storage.CustomBaseEdit', {
+                            paneltype: 'PVE.storage.CustomInputPanel',
+                            type: type,
+                            storageId: sid,
+                            canDoBackups: metadataForPlugin.content.supported.includes('backup'),
+                            formView: formView,
+                            metadataForPlugin: metadataForPlugin,
+                            autoShow: true,
+                            listeners: {
+                                destroy: me.reloadStore,
+                            },
+                        });
+                    },
+                });
+
+                return;
+            }
+
             let schema = PVE.Utils.storageSchema[type];
             if (!schema || !schema.ipanel) {
                 throw 'no editor registered for storage type: ' + type;
@@ -23,7 +64,7 @@ Ext.define(
                 canDoBackups: schema.backups,
                 autoShow: true,
                 listeners: {
-                    destroy: this.reloadStore,
+                    destroy: me.reloadStore,
                 },
             });
         },
@@ -45,6 +86,63 @@ Ext.define(
 
             let sm = Ext.create('Ext.selection.RowModel', {});
 
+            me.pluginMetadata = {};
+
+            let menuButtonAdd = new Ext.menu.Menu({
+                items: [],
+            });
+
+            let pushBuiltinPluginsToMenu = function () {
+                for (const [type, storage] of Object.entries(PVE.Utils.storageSchema)) {
+                    console.log(`Adding builtin plugin '${type}' to Add Button`);
+                    if (storage.hideAdd) {
+                        continue;
+                    }
+
+                    menuButtonAdd.add({
+                        text: PVE.Utils.format_storage_type(type),
+                        iconCls: 'fa fa-fw fa-' + storage.faIcon,
+                        handler: () => me.createStorageEditWindow(type),
+                    });
+                }
+            };
+
+            let pushCustomPluginsToMenu = function () {
+                for (const type in me.pluginMetadata) {
+                    if (!Object.hasOwn(me.pluginMetadata, type)) {
+                        continue;
+                    }
+
+                    const metadata = me.pluginMetadata[type];
+
+                    if (metadata.kind !== 'custom') {
+                        continue;
+                    }
+
+                    menuButtonAdd.add({
+                        text: metadata['short-name'] || PVE.Utils.format_storage_type(type),
+                        iconCls: 'fa fa-fw fa-folder',
+                        handler: () => me.createStorageEditWindow(type),
+                    });
+                }
+            };
+
+            Proxmox.Utils.API2Request({
+                url: `/api2/extjs/plugins/storage`,
+                method: 'GET',
+                success: function ({ result: { data } }) {
+                    data.forEach((metadata) => {
+                        me.pluginMetadata[metadata.type] = metadata;
+                    });
+
+                    pushBuiltinPluginsToMenu();
+                    pushCustomPluginsToMenu();
+                },
+                failure: function ({ htmlStatus }) {
+                    Ext.Msg.alert('Error', htmlStatus);
+                },
+            });
+
             let run_editor = function () {
                 let rec = sm.getSelection()[0];
                 if (!rec) {
@@ -66,23 +164,19 @@ Ext.define(
                 callback: () => store.load(),
             });
 
-            // else we cannot dynamically generate the add menu handlers
-            let addHandleGenerator = function (type) {
-                return function () {
-                    me.createStorageEditWindow(type);
-                };
-            };
-            let addMenuItems = [];
-            for (const [type, storage] of Object.entries(PVE.Utils.storageSchema)) {
-                if (storage.hideAdd) {
-                    continue;
+            // value is plugin type here
+            let columnRendererType = function (value, meta, record) {
+                let metadataForPlugin = me.pluginMetadata[value];
+
+                if (metadataForPlugin && metadataForPlugin.kind === 'custom') {
+                    return (
+                        metadataForPlugin['short-name'] ||
+                        PVE.Utils.format_storage_type(value, meta, record)
+                    );
                 }
-                addMenuItems.push({
-                    text: PVE.Utils.format_storage_type(type),
-                    iconCls: 'fa fa-fw fa-' + storage.faIcon,
-                    handler: addHandleGenerator(type),
-                });
-            }
+
+                return PVE.Utils.format_storage_type(value, meta, record);
+            };
 
             Ext.apply(me, {
                 store: store,
@@ -94,9 +188,7 @@ Ext.define(
                 tbar: [
                     {
                         text: gettext('Add'),
-                        menu: new Ext.menu.Menu({
-                            items: addMenuItems,
-                        }),
+                        menu: menuButtonAdd,
                     },
                     remove_btn,
                     edit_btn,
@@ -113,7 +205,7 @@ Ext.define(
                         flex: 1,
                         sortable: true,
                         dataIndex: 'type',
-                        renderer: PVE.Utils.format_storage_type,
+                        renderer: columnRendererType,
                     },
                     {
                         header: gettext('Content'),
-- 
2.47.2



_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel


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

* [pve-devel] [RFC pve-manager master v1 12/12] ui: storage: use `Ext.Msg.alert()` instead of throwing an exception
  2025-09-08 18:00 [pve-devel] [RFC pve-storage, pve-manager master v1 00/12] GUI Support for Custom Storage Plugins Max R. Carrara
                   ` (10 preceding siblings ...)
  2025-09-08 18:00 ` [pve-devel] [RFC pve-manager master v1 11/12] ui: storage: support custom storage plugins in Datacenter > Storage Max R. Carrara
@ 2025-09-08 18:00 ` Max R. Carrara
  2025-09-08 19:23 ` [pve-devel] [RFC pve-storage, pve-manager master v1 00/12] GUI Support for Custom Storage Plugins Thomas Lamprecht
  12 siblings, 0 replies; 15+ messages in thread
From: Max R. Carrara @ 2025-09-08 18:00 UTC (permalink / raw)
  To: pve-devel

... if no editor is registered for a storage plugin.

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

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



_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel


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

* Re: [pve-devel] [RFC pve-storage, pve-manager master v1 00/12] GUI Support for Custom Storage Plugins
  2025-09-08 18:00 [pve-devel] [RFC pve-storage, pve-manager master v1 00/12] GUI Support for Custom Storage Plugins Max R. Carrara
                   ` (11 preceding siblings ...)
  2025-09-08 18:00 ` [pve-devel] [RFC pve-manager master v1 12/12] ui: storage: use `Ext.Msg.alert()` instead of throwing an exception Max R. Carrara
@ 2025-09-08 19:23 ` Thomas Lamprecht
  2025-09-09 14:21   ` Max R. Carrara
  12 siblings, 1 reply; 15+ messages in thread
From: Thomas Lamprecht @ 2025-09-08 19:23 UTC (permalink / raw)
  To: Proxmox VE development discussion, Max R. Carrara

Am 08.09.25 um 20:01 schrieb Max R. Carrara:
> GUI Support for Custom Storage Plugins
> ======================================
> 
> tl;dr:
> 
> Add an API method to PVE::Storage::Plugin that returns the definition
> for the form view of custom storage plugins. This definition is used by
> the frontend to build the form view for creating / editing the storage
> config entry of the plugin. The ultimate goal here is that custom
> storage plugin devs don't have to (and also *must not*) ever touch
> JavaScript to make their plugins show up in the GUI.

Did not check the changes in full, but I see no mentioning of the ACME
DNS plugins, which already provide such a mechanism, albeit a bit lower
level with no real control how the fields get layouted. Did you check
that out and maybe already re-used the underlying parts of the
implementation but just not referenced it, or is this something
completely new/custom?

Further, FWICT this seems to add some parallel infra and need of
definitions, might we get away with "just" annotating the existing
schemas a bit better, i.e. add the flags for signaling secrets and
potentially some layout hints, as the core importance is to be able to
input required data to configure a storage, not that it needs to look
"perfect", at least not in the initial version of this implementation,
as adding more features and complexer layouting later on, especially
once we got feedback from integrators and see how they use it.
As it's much easier to see what's really required and what might be
rather a (not much used) headache to maintain support for in ExtJS
and a future Yew based UI. I.e., I'd be totally fine if the initial
version would basically look like what the ACME DNS plugin UI does,
focusing purely on required parameters, data types and validation
over layout.

Btw. this is partially related to [0] and I thought there was
another issue tracking this, but couldn't find one from a quick search.

[0]: https://bugzilla.proxmox.com/show_bug.cgi?id=3420


_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel


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

* Re: [pve-devel] [RFC pve-storage, pve-manager master v1 00/12] GUI Support for Custom Storage Plugins
  2025-09-08 19:23 ` [pve-devel] [RFC pve-storage, pve-manager master v1 00/12] GUI Support for Custom Storage Plugins Thomas Lamprecht
@ 2025-09-09 14:21   ` Max R. Carrara
  0 siblings, 0 replies; 15+ messages in thread
From: Max R. Carrara @ 2025-09-09 14:21 UTC (permalink / raw)
  To: Thomas Lamprecht, Proxmox VE development discussion

On Mon Sep 8, 2025 at 9:23 PM CEST, Thomas Lamprecht wrote:
> Am 08.09.25 um 20:01 schrieb Max R. Carrara:
> > GUI Support for Custom Storage Plugins
> > ======================================
> > 
> > tl;dr:
> > 
> > Add an API method to PVE::Storage::Plugin that returns the definition
> > for the form view of custom storage plugins. This definition is used by
> > the frontend to build the form view for creating / editing the storage
> > config entry of the plugin. The ultimate goal here is that custom
> > storage plugin devs don't have to (and also *must not*) ever touch
> > JavaScript to make their plugins show up in the GUI.
>
> Did not check the changes in full, but I see no mentioning of the ACME
> DNS plugins, which already provide such a mechanism, albeit a bit lower
> level with no real control how the fields get layouted. Did you check
> that out and maybe already re-used the underlying parts of the
> implementation but just not referenced it, or is this something
> completely new/custom?

This is something completely new, as the code regarding the ACME DNS
plugins isn't really adaptable / applicable in this case here.

When Aaron and I brainstormed the whole thing, he did mention that we
already do a somewhat similar thing with the ACME DNS plugins and
suggested that I might want to check that out first. Dominik then also
mentioned it, but suggested that I probably shouldn't implement it the
way we did for the ACME DNS plugins, so I decided to not look at that
code at all.

However, Dominik and I just went through the ACME GUI stuff for good
measure and found two main reasons why the functionality can't really be
reused:

1. The ACME DNS plugin [schema] is too simple and doesn't really
translate to storage plugins.

In particular, the [schema] is static, with all fields except one being
of type "string". Things like checkboxes and dynamic dropdown selection
boxes aren't really accounted for, since they're not needed.

2. The ACME DNS API [edit window] is designed for editing on the fly.

The entire [schema] is fetched at once from the backend. The window
remembers which DNS API the user selected and stores the KV-pairs in the
background. So when you switch back and forth between e.g. "Cloudflare
Managed DNS" and "Alibaba Cloud DNS", the values you entered are
retained.

If you then switch to an API without any fields in the schema, the
existing KV-pairs of other plugins are inserted into the fallback text
area. If you select an API with fields again, the content of the text
area is parsed back into the KV-pairs.

So, also not something that's really applicable / reusable for storage
plugins.

>
> Further, FWICT this seems to add some parallel infra and need of
> definitions, might we get away with "just" annotating the existing
> schemas a bit better, i.e. add the flags for signaling secrets and
> potentially some layout hints, as the core importance is to be able to
> input required data to configure a storage, not that it needs to look
> "perfect", at least not in the initial version of this implementation,
> as adding more features and complexer layouting later on, especially
> once we got feedback from integrators and see how they use it.

Maybe I should clarify that the core part of this RFC isn't really the
layouting: The form view schema allows devs to specify which columns are
used, and a column is just an object with an array of fields. The fields
are displayed in the order in which they appear in the column. That's
about as much layouting as one can do at the moment.

What's important are the definitions of the fields themselves. Each
field corresponds to a SectionConfig property of the storage plugin the
form is defined for.

To provide some additional context before I continue, my initial idea
was to *just* define what SectionConfig properties are shown in the UI
and have the rest be "inferred" in some way from the global set of
SectionConfig properties.

That quickly turned out to be a huge can of worms, raising a lot of
questions:

- Single Selection:

  * How would "string" type properties whose value is
    from a set of possible selection values be shown in the UI?

      Would need a hint that it's a "single-selection" field or
      something.

  * How would one tell the UI which possible selection values there are?

      Hard to do with just a hint / marker, as those selection values
      can be dynamic, e.g. the possible selections for the "pool" prop of
      the ZFSPoolPlugin are determined by listing which pools are
      available via the ZFS CLI.

- Multiple Selections:

  * Same as above, but with multiple possible selection values (usually
    properties that have a format whose name ends with "-list").

- Sensitive Properties:

  * How should sensitive properties be shown in the UI?

      We currently do not do that uniformly, e.g. the "Password" field
      of the ESXi importer always has `inputType: 'password'` whether
      you're creating or editing it, whereas the "Keyring" field of the
      RBD storage is displayed during creation but isn't shown nor
      editable when editing it (amongst other things).

The list goes on; I think you get the idea. :P

So, instead of making possibly restrictive design decisions for use
cases I'm not even aware of, I instead chose this "manual" way of
defining the form view, sidestepping all those questions.

*For now* this also seems to be the simpler, and more importantly, more
maintainable option, since things related to the UI aren't coupled to
the SectionConfig definitions (besides specifying what property a field
corresponds to). I think that this approach makes it overall easier for
third-party plugin devs to integrate their plugin into the GUI, and it's
something we could eventually also adopt for most of our own plugins.

> As it's much easier to see what's really required and what might be
> rather a (not much used) headache to maintain support for in ExtJS
> and a future Yew based UI. I.e., I'd be totally fine if the initial
> version would basically look like what the ACME DNS plugin UI does,
> focusing purely on required parameters, data types and validation
> over layout.

I totally get your point here, and the focus is on exactly
that—whether or not the columns are in the schema doesn't really make
any difference IMHO.

>
> Btw. this is partially related to [0] and I thought there was
> another issue tracking this, but couldn't find one from a quick search.

Ah, thanks for pointing me towards this; I'll assign it to myself if you
don't mind.

>
> [0]: https://bugzilla.proxmox.com/show_bug.cgi?id=3420

[schema]: /usr/share/proxmox-acme/dns-challenge-schema.json
[edit window]: https://git.proxmox.com/?p=proxmox-widget-toolkit.git;a=blob;f=src/window/ACMEPluginEdit.js;h=900923b98788a8d1e6fa3f1bc2004495ac297969;hb=refs/heads/master


_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel

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

end of thread, other threads:[~2025-09-09 14:22 UTC | newest]

Thread overview: 15+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2025-09-08 18:00 [pve-devel] [RFC pve-storage, pve-manager master v1 00/12] GUI Support for Custom Storage Plugins Max R. Carrara
2025-09-08 18:00 ` [pve-devel] [RFC pve-storage master v1 01/12] plugin: meta: add package PVE::Storage::Plugin::Meta Max R. Carrara
2025-09-08 18:00 ` [pve-devel] [RFC pve-storage master v1 02/12] api: Add 'plugins/storage' and 'plugins/storage/{plugin}' paths Max R. Carrara
2025-09-08 18:00 ` [pve-devel] [RFC pve-storage master v1 03/12] plugin: meta: introduce 'short-name' Max R. Carrara
2025-09-08 18:00 ` [pve-devel] [RFC pve-storage master v1 04/12] plugin: views: add package PVE::Storage::Plugin::Views Max R. Carrara
2025-09-08 18:00 ` [pve-devel] [RFC pve-storage master v1 05/12] plugin: add new plugin API method `get_form_view()` Max R. Carrara
2025-09-08 18:00 ` [pve-devel] [RFC pve-storage master v1 06/12] plugin: meta: add metadata regarding views in API Max R. Carrara
2025-09-08 18:00 ` [pve-devel] [RFC pve-storage master v1 07/12] api: views: add paths regarding storage plugin views Max R. Carrara
2025-09-08 18:00 ` [pve-devel] [RFC pve-storage master v1 08/12] plugin: zfspool: add 'short-name' and form view for ZFS pool plugin Max R. Carrara
2025-09-08 18:00 ` [pve-devel] [RFC pve-manager master v1 09/12] api: handle path 'plugins/storage' through its package Max R. Carrara
2025-09-08 18:00 ` [pve-devel] [RFC pve-manager master v1 10/12] ui: storage: add CustomBase.js Max R. Carrara
2025-09-08 18:00 ` [pve-devel] [RFC pve-manager master v1 11/12] ui: storage: support custom storage plugins in Datacenter > Storage Max R. Carrara
2025-09-08 18:00 ` [pve-devel] [RFC pve-manager master v1 12/12] ui: storage: use `Ext.Msg.alert()` instead of throwing an exception Max R. Carrara
2025-09-08 19:23 ` [pve-devel] [RFC pve-storage, pve-manager master v1 00/12] GUI Support for Custom Storage Plugins Thomas Lamprecht
2025-09-09 14:21   ` Max R. Carrara

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal