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; 14+ 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] 14+ 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; 14+ 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] 14+ 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; 14+ 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] 14+ 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; 14+ 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] 14+ 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; 14+ 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] 14+ 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; 14+ 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] 14+ 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; 14+ 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] 14+ 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; 14+ 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] 14+ 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; 14+ 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] 14+ 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; 14+ 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] 14+ 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; 14+ 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] 14+ 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; 14+ 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] 14+ 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; 14+ 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] 14+ 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
  12 siblings, 0 replies; 14+ 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] 14+ messages in thread

end of thread, other threads:[~2025-09-08 19:24 UTC | newest]

Thread overview: 14+ 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

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