* [pve-devel] [RFC pve-storage master v2 1/10] api: plugins/storage: add initial routes and endpoints
2025-11-21 16:58 [pve-devel] [RFC pve-storage/proxmox-widget-toolkit/pve-manager master v2 00/10] GUI Support for Custom Storage Plugins Max R. Carrara
@ 2025-11-21 16:58 ` Max R. Carrara
2025-11-21 16:58 ` [pve-devel] [RFC pve-storage master v2 2/10] api: plugins/storage/plugin: include schema in plugin metadata Max R. Carrara
` (8 subsequent siblings)
9 siblings, 0 replies; 11+ messages in thread
From: Max R. Carrara @ 2025-11-21 16:58 UTC (permalink / raw)
To: pve-devel
Add the following routes / endpoints:
* `GET plugins/storage`
Subdir index--lists the direct child path components (so, just
'plugin' currently).
* `GET plugins/storage/plugin`
Lists all installed storage plugins and their metadata.
Currently, this is just an array of objects, with each object
containing the plugin's `type()` and Perl module path.
* `GET plugins/storage/plugin/{type}`
Returns the metadata for a single plugin given by `{type}`.
Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
src/PVE/API2/Makefile | 1 +
src/PVE/API2/Plugins/Makefile | 18 +++++
src/PVE/API2/Plugins/Storage.pm | 54 +++++++++++++
src/PVE/API2/Plugins/Storage/Makefile | 17 ++++
src/PVE/API2/Plugins/Storage/Plugin.pm | 104 +++++++++++++++++++++++++
5 files changed, 194 insertions(+)
create mode 100644 src/PVE/API2/Plugins/Makefile
create mode 100644 src/PVE/API2/Plugins/Storage.pm
create mode 100644 src/PVE/API2/Plugins/Storage/Makefile
create mode 100644 src/PVE/API2/Plugins/Storage/Plugin.pm
diff --git a/src/PVE/API2/Makefile b/src/PVE/API2/Makefile
index 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..f59e75b
--- /dev/null
+++ b/src/PVE/API2/Plugins/Makefile
@@ -0,0 +1,18 @@
+SOURCES = Storage.pm \
+
+
+SUBDIRS = Storage \
+
+
+INSTALL_PATH = ${DESTDIR}${PERLDIR}/PVE/API2/Plugins
+
+
+.PHONY: install
+install:
+ set -e && for SOURCE in ${SOURCES}; \
+ do install -D -m 0644 $$SOURCE ${INSTALL_PATH}/$$SOURCE; \
+ done
+ set -e && for SUBDIR in ${SUBDIRS}; \
+ do make -C $$SUBDIR install; \
+ done
+
diff --git a/src/PVE/API2/Plugins/Storage.pm b/src/PVE/API2/Plugins/Storage.pm
new file mode 100644
index 0000000..53b883f
--- /dev/null
+++ b/src/PVE/API2/Plugins/Storage.pm
@@ -0,0 +1,54 @@
+package PVE::API2::Plugins::Storage;
+
+use v5.36;
+
+use PVE::Storage;
+use PVE::Storage::Plugin;
+
+use PVE::API2::Plugins::Storage::Plugin;
+
+use PVE::RESTHandler;
+use base qw(PVE::RESTHandler);
+
+__PACKAGE__->register_method({
+ subclass => 'PVE::API2::Plugins::Storage::Plugin',
+ path => 'plugin',
+});
+
+# plugins/storage
+
+__PACKAGE__->register_method({
+ name => 'index',
+ path => '',
+ method => 'GET',
+ description => 'Directory index.',
+ permissions => {
+ user => 'all',
+ },
+ parameters => {
+ additionalProperties => 0,
+ properties => {},
+ },
+ returns => {
+ type => 'array',
+ items => {
+ type => "object",
+ additionalProperties => 0,
+ properties => {
+ subdir => {
+ type => 'string',
+ },
+ },
+ },
+ links => [{ rel => 'child', href => "{subdir}" }],
+ },
+ code => sub($param) {
+ my $result = [
+ { subdir => 'plugin' },
+ ];
+
+ return $result;
+ },
+});
+
+1;
diff --git a/src/PVE/API2/Plugins/Storage/Makefile b/src/PVE/API2/Plugins/Storage/Makefile
new file mode 100644
index 0000000..06473a2
--- /dev/null
+++ b/src/PVE/API2/Plugins/Storage/Makefile
@@ -0,0 +1,17 @@
+SOURCES = Plugin.pm \
+
+
+SUBDIRS =
+
+
+INSTALL_PATH = ${DESTDIR}${PERLDIR}/PVE/API2/Plugins/Storage
+
+.PHONY: install
+install:
+ set -e && for SOURCE in ${SOURCES}; \
+ do install -D -m 0644 $$SOURCE ${INSTALL_PATH}/$$SOURCE; \
+ done
+ set -e && for SUBDIR in ${SUBDIRS}; \
+ do make -C $$SUBDIR install; \
+ done
+
diff --git a/src/PVE/API2/Plugins/Storage/Plugin.pm b/src/PVE/API2/Plugins/Storage/Plugin.pm
new file mode 100644
index 0000000..fd0f734
--- /dev/null
+++ b/src/PVE/API2/Plugins/Storage/Plugin.pm
@@ -0,0 +1,104 @@
+package PVE::API2::Plugins::Storage::Plugin;
+
+use v5.36;
+
+use HTTP::Status qw(:constants);
+
+use PVE::Exception qw(raise);
+use PVE::Storage;
+use PVE::Storage::Plugin;
+use PVE::Tools qw(extract_param);
+
+use PVE::RESTHandler;
+use base qw(PVE::RESTHandler);
+
+my $ALL_PLUGIN_TYPES = PVE::Storage::Plugin->lookup_types();
+
+my $PLUGIN_METADATA_SCHEMA = {
+ type => 'object',
+ additionalProperties => 0,
+ properties => {
+ module => {
+ type => 'string',
+ optional => 0,
+ },
+ type => {
+ type => 'string',
+ optional => 0,
+ enum => $ALL_PLUGIN_TYPES,
+ },
+ },
+};
+
+# plugins/storage/plugin
+
+__PACKAGE__->register_method({
+ name => 'index',
+ path => '',
+ method => 'GET',
+ description => 'List all available storage plugins and their metadata.',
+ permissions => {
+ user => 'all',
+ },
+ parameters => {
+ additionalProperties => 0,
+ properties => {},
+ },
+ returns => {
+ type => 'array',
+ items => $PLUGIN_METADATA_SCHEMA,
+ },
+ code => sub($param) {
+ my $result = [];
+
+ for my $type ($ALL_PLUGIN_TYPES->@*) {
+ my $plugin = PVE::Storage::Plugin->lookup($type);
+
+ my $item = {
+ module => $plugin,
+ type => $type,
+ };
+
+ push($result->@*, $item);
+ }
+
+ return $result;
+ },
+});
+
+# plugins/storage/plugin/{type}
+
+__PACKAGE__->register_method({
+ name => 'info',
+ path => '{type}',
+ method => 'GET',
+ description => 'Fetch metadata of a storage plugin.',
+ permissions => {
+ user => 'all',
+ },
+ parameters => {
+ additionalProperties => 0,
+ properties => {
+ type => {
+ type => 'string',
+ },
+ },
+ },
+ returns => $PLUGIN_METADATA_SCHEMA,
+ code => sub($param) {
+ my $param_type = extract_param($param, 'type');
+
+ my $plugin = eval { PVE::Storage::Plugin->lookup($param_type) };
+ if ($@) {
+ raise("Plugin '$param_type' not found - $@", code => HTTP_NOT_FOUND);
+ }
+
+ my $result = {
+ module => $plugin,
+ type => $param_type,
+ };
+
+ return $result;
+ },
+});
+1;
--
2.47.3
_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel
^ permalink raw reply [flat|nested] 11+ messages in thread* [pve-devel] [RFC pve-storage master v2 2/10] api: plugins/storage/plugin: include schema in plugin metadata
2025-11-21 16:58 [pve-devel] [RFC pve-storage/proxmox-widget-toolkit/pve-manager master v2 00/10] GUI Support for Custom Storage Plugins Max R. Carrara
2025-11-21 16:58 ` [pve-devel] [RFC pve-storage master v2 1/10] api: plugins/storage: add initial routes and endpoints Max R. Carrara
@ 2025-11-21 16:58 ` Max R. Carrara
2025-11-21 16:58 ` [pve-devel] [RFC pve-storage master v2 3/10] api: plugins/storage/plugin: mark sensitive properties in schema Max R. Carrara
` (7 subsequent siblings)
9 siblings, 0 replies; 11+ messages in thread
From: Max R. Carrara @ 2025-11-21 16:58 UTC (permalink / raw)
To: pve-devel
Return a simple schema describing the plugin as part of its metadata.
This schema is simply a hash consisting of a given plugin's
properties' schemas. Each property schema is obtained by calling the
`get_property_schema()` method of `PVE::SectionConfig` (which
`PVE::Storage::Plugin` inherits) for each property that a given plugin
uses in its `options()`. Additionally, each plugin's `options()` are
also taken into account, and the schemas are memoized.
This means that each returned schema is completely derived from the
given plugin's section config—the (globally) available properties and
the plugin's `options()`. At the same time, this should be adaptable
enough to include extra hints, such as whether a property is sensitive
or not, for example.
Deriving a schema for each plugin like this is more preferable over
exposing `createSchema()` and `updateSchema()` of
`PVE::Storage::Plugin` directly. In particular, these two schemas come
with some drawbacks when it comes to describing an *individual*
plugin's data:
- Neither schema contains any information on which properties are used
by which plugins.
- It is not possible to determine whether a property is actually
optional (or not) for a given plugin. If one plugin declares a
property as optional somewhere, the property will be optional in the
schema in most cases, despite being non-optional for other plugins.
(This is generally true; skipping over a bunch of SectionConfig
implementation details here for the sake of brevity).
- The same is true even more so for fixed properties. While the
`updateSchema()` will oftentimes *not* contain fixed properties,
meaning that you can compare it with `createSchema()` and figure out
which properties *may* be fixed, this only holds if the property is
fixed in *every* plugin's `options()`. As soon as a plugin declares
a usually fixed property as non-fixed, the property is included in
the `updateSchema()`.
- Even when switching to property isolation for
`PVE::Storage::Plugin`, which properties are fixed / optional for
individual plugins is still not consistently determinable for the
previous two reasons.
This means that in addition to exposing `createSchema()` and
`updateSchema()`, we would also have to expose the `options()` of each
plugin, and then stitch all of that information together again just to
obtain the hash that the `get_schema_for_plugin()` helper being added
here returns.
Additionally, `get_property_schema()` takes property isolation into
account. More precisely, if one were to enable property isolation and
copy-paste the formerly global property definitions into the
`properties()` of each plugin where needed, the returned schema would
stay the same.
Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
src/PVE/API2/Plugins/Storage/Plugin.pm | 34 ++++++++++++++++++++++++++
1 file changed, 34 insertions(+)
diff --git a/src/PVE/API2/Plugins/Storage/Plugin.pm b/src/PVE/API2/Plugins/Storage/Plugin.pm
index fd0f734..573d3e4 100644
--- a/src/PVE/API2/Plugins/Storage/Plugin.pm
+++ b/src/PVE/API2/Plugins/Storage/Plugin.pm
@@ -22,6 +22,10 @@ my $PLUGIN_METADATA_SCHEMA = {
type => 'string',
optional => 0,
},
+ schema => {
+ type => 'object',
+ optional => 0,
+ },
type => {
type => 'string',
optional => 0,
@@ -30,6 +34,34 @@ my $PLUGIN_METADATA_SCHEMA = {
},
};
+my $PLUGIN_SCHEMAS = {};
+
+my sub get_schema_for_plugin : prototype($) ($plugin) {
+ my $type = $plugin->type();
+
+ return $PLUGIN_SCHEMAS->{$type} if defined($PLUGIN_SCHEMAS->{$type});
+
+ my $options = $plugin->options();
+
+ my $schema = {};
+ $PLUGIN_SCHEMAS->{$type} = $schema;
+
+ for my $option (keys $options->%*) {
+ my $prop_schema = PVE::RESTHandler::api_dump_remove_refs(
+ PVE::Storage::Plugin->get_property_schema($type, $option));
+
+ # shallow copy
+ my $property = { $prop_schema->%* };
+ $schema->{$option} = $property;
+
+ for my $opt_key (keys $options->{$option}->%*) {
+ $property->{$opt_key} = $options->{$option}->{$opt_key};
+ }
+ }
+
+ return $schema;
+}
+
# plugins/storage/plugin
__PACKAGE__->register_method({
@@ -56,6 +88,7 @@ __PACKAGE__->register_method({
my $item = {
module => $plugin,
+ schema => get_schema_for_plugin($plugin),
type => $type,
};
@@ -95,6 +128,7 @@ __PACKAGE__->register_method({
my $result = {
module => $plugin,
+ schema => get_schema_for_plugin($plugin),
type => $param_type,
};
--
2.47.3
_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel
^ permalink raw reply [flat|nested] 11+ messages in thread* [pve-devel] [RFC pve-storage master v2 3/10] api: plugins/storage/plugin: mark sensitive properties in schema
2025-11-21 16:58 [pve-devel] [RFC pve-storage/proxmox-widget-toolkit/pve-manager master v2 00/10] GUI Support for Custom Storage Plugins Max R. Carrara
2025-11-21 16:58 ` [pve-devel] [RFC pve-storage master v2 1/10] api: plugins/storage: add initial routes and endpoints Max R. Carrara
2025-11-21 16:58 ` [pve-devel] [RFC pve-storage master v2 2/10] api: plugins/storage/plugin: include schema in plugin metadata Max R. Carrara
@ 2025-11-21 16:58 ` Max R. Carrara
2025-11-21 16:58 ` [pve-devel] [RFC pve-storage master v2 4/10] api: plugins/storage/plugin: factor plugin metadata code into helper Max R. Carrara
` (6 subsequent siblings)
9 siblings, 0 replies; 11+ messages in thread
From: Max R. Carrara @ 2025-11-21 16:58 UTC (permalink / raw)
To: pve-devel
Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
src/PVE/API2/Plugins/Storage/Plugin.pm | 3 +++
1 file changed, 3 insertions(+)
diff --git a/src/PVE/API2/Plugins/Storage/Plugin.pm b/src/PVE/API2/Plugins/Storage/Plugin.pm
index 573d3e4..757ae1e 100644
--- a/src/PVE/API2/Plugins/Storage/Plugin.pm
+++ b/src/PVE/API2/Plugins/Storage/Plugin.pm
@@ -42,6 +42,7 @@ my sub get_schema_for_plugin : prototype($) ($plugin) {
return $PLUGIN_SCHEMAS->{$type} if defined($PLUGIN_SCHEMAS->{$type});
my $options = $plugin->options();
+ my $plugindata = $plugin->plugindata();
my $schema = {};
$PLUGIN_SCHEMAS->{$type} = $schema;
@@ -52,6 +53,8 @@ my sub get_schema_for_plugin : prototype($) ($plugin) {
# shallow copy
my $property = { $prop_schema->%* };
+ $property->{sensitive} = defined($plugindata->{'sensitive-properties'}->{$option}) || 0;
+
$schema->{$option} = $property;
for my $opt_key (keys $options->{$option}->%*) {
--
2.47.3
_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel
^ permalink raw reply [flat|nested] 11+ messages in thread* [pve-devel] [RFC pve-storage master v2 4/10] api: plugins/storage/plugin: factor plugin metadata code into helper
2025-11-21 16:58 [pve-devel] [RFC pve-storage/proxmox-widget-toolkit/pve-manager master v2 00/10] GUI Support for Custom Storage Plugins Max R. Carrara
` (2 preceding siblings ...)
2025-11-21 16:58 ` [pve-devel] [RFC pve-storage master v2 3/10] api: plugins/storage/plugin: mark sensitive properties in schema Max R. Carrara
@ 2025-11-21 16:58 ` Max R. Carrara
2025-11-21 16:58 ` [pve-devel] [RFC pve-storage master v2 5/10] api: plugins/storage/plugin: add plugins' 'content' to their metadata Max R. Carrara
` (5 subsequent siblings)
9 siblings, 0 replies; 11+ messages in thread
From: Max R. Carrara @ 2025-11-21 16:58 UTC (permalink / raw)
To: pve-devel
in order to reduce the number of places that need to be touched when
adding more properties to the returned metadata.
Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
src/PVE/API2/Plugins/Storage/Plugin.pm | 35 +++++++++++---------------
1 file changed, 15 insertions(+), 20 deletions(-)
diff --git a/src/PVE/API2/Plugins/Storage/Plugin.pm b/src/PVE/API2/Plugins/Storage/Plugin.pm
index 757ae1e..457c070 100644
--- a/src/PVE/API2/Plugins/Storage/Plugin.pm
+++ b/src/PVE/API2/Plugins/Storage/Plugin.pm
@@ -65,6 +65,19 @@ my sub get_schema_for_plugin : prototype($) ($plugin) {
return $schema;
}
+my sub build_plugin_metadata : prototype($) ($type) {
+ my $plugin = eval { PVE::Storage::Plugin->lookup($type) };
+ if ($@) {
+ raise("Plugin '$type' not found - $@", code => HTTP_NOT_FOUND);
+ }
+
+ return {
+ module => $plugin,
+ schema => get_schema_for_plugin($plugin),
+ type => $type,
+ };
+}
+
# plugins/storage/plugin
__PACKAGE__->register_method({
@@ -87,14 +100,7 @@ __PACKAGE__->register_method({
my $result = [];
for my $type ($ALL_PLUGIN_TYPES->@*) {
- my $plugin = PVE::Storage::Plugin->lookup($type);
-
- my $item = {
- module => $plugin,
- schema => get_schema_for_plugin($plugin),
- type => $type,
- };
-
+ my $item = build_plugin_metadata($type);
push($result->@*, $item);
}
@@ -124,18 +130,7 @@ __PACKAGE__->register_method({
code => sub($param) {
my $param_type = extract_param($param, 'type');
- my $plugin = eval { PVE::Storage::Plugin->lookup($param_type) };
- if ($@) {
- raise("Plugin '$param_type' not found - $@", code => HTTP_NOT_FOUND);
- }
-
- my $result = {
- module => $plugin,
- schema => get_schema_for_plugin($plugin),
- type => $param_type,
- };
-
- return $result;
+ return build_plugin_metadata($param_type);
},
});
1;
--
2.47.3
_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel
^ permalink raw reply [flat|nested] 11+ messages in thread* [pve-devel] [RFC pve-storage master v2 5/10] api: plugins/storage/plugin: add plugins' 'content' to their metadata
2025-11-21 16:58 [pve-devel] [RFC pve-storage/proxmox-widget-toolkit/pve-manager master v2 00/10] GUI Support for Custom Storage Plugins Max R. Carrara
` (3 preceding siblings ...)
2025-11-21 16:58 ` [pve-devel] [RFC pve-storage master v2 4/10] api: plugins/storage/plugin: factor plugin metadata code into helper Max R. Carrara
@ 2025-11-21 16:58 ` Max R. Carrara
2025-11-21 16:58 ` [pve-devel] [RFC proxmox-widget-toolkit master v2 6/10] utils: introduce helper function getFieldDefFromPropertySchema Max R. Carrara
` (4 subsequent siblings)
9 siblings, 0 replies; 11+ messages in thread
From: Max R. Carrara @ 2025-11-21 16:58 UTC (permalink / raw)
To: pve-devel
Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
src/PVE/API2/Plugins/Storage/Plugin.pm | 27 ++++++++++++++++++++++++++
1 file changed, 27 insertions(+)
diff --git a/src/PVE/API2/Plugins/Storage/Plugin.pm b/src/PVE/API2/Plugins/Storage/Plugin.pm
index 457c070..6db9ffc 100644
--- a/src/PVE/API2/Plugins/Storage/Plugin.pm
+++ b/src/PVE/API2/Plugins/Storage/Plugin.pm
@@ -18,6 +18,27 @@ my $PLUGIN_METADATA_SCHEMA = {
type => 'object',
additionalProperties => 0,
properties => {
+ content => {
+ type => 'object',
+ optional => 0,
+ properties => {
+ supported => {
+ type => 'array',
+ optional => 0,
+ items => {
+ type => 'string',
+ },
+ },
+ default => {
+ type => 'array',
+ optional => 0,
+ items => {
+ type => 'string',
+ },
+ },
+ },
+ additionalProperties => 0,
+ },
module => {
type => 'string',
optional => 0,
@@ -71,7 +92,13 @@ my sub build_plugin_metadata : prototype($) ($type) {
raise("Plugin '$type' not found - $@", code => HTTP_NOT_FOUND);
}
+ my $plugindata = $plugin->plugindata();
+
return {
+ content => {
+ supported => [sort keys $plugindata->{content}->[0]->%*],
+ default => [sort keys $plugindata->{content}->[1]->%*],
+ },
module => $plugin,
schema => get_schema_for_plugin($plugin),
type => $type,
--
2.47.3
_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel
^ permalink raw reply [flat|nested] 11+ messages in thread* [pve-devel] [RFC proxmox-widget-toolkit master v2 6/10] utils: introduce helper function getFieldDefFromPropertySchema
2025-11-21 16:58 [pve-devel] [RFC pve-storage/proxmox-widget-toolkit/pve-manager master v2 00/10] GUI Support for Custom Storage Plugins Max R. Carrara
` (4 preceding siblings ...)
2025-11-21 16:58 ` [pve-devel] [RFC pve-storage master v2 5/10] api: plugins/storage/plugin: add plugins' 'content' to their metadata Max R. Carrara
@ 2025-11-21 16:58 ` Max R. Carrara
2025-11-21 16:58 ` [pve-devel] [RFC proxmox-widget-toolkit master v2 7/10] acme: use helper to construct ExtJS fields from property schemas Max R. Carrara
` (3 subsequent siblings)
9 siblings, 0 replies; 11+ messages in thread
From: Max R. Carrara @ 2025-11-21 16:58 UTC (permalink / raw)
To: pve-devel
This helper takes the JSONSchema of a single property and transforms
it into an object representing an ExtJS field.
Currently, four types for properties are supported: string, integer,
number and boolean.
Additionally, the helper also takes "extra attributes" and a "context"
which allows for more fine-grained control over how the ExtJS field is
actually constructed and also makes it possible to express different
UI-specific behaviors that cannot be modeled via JSONSchema (for
example, whether a field's contents are sensitive).
Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
src/Utils.js | 106 +++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 106 insertions(+)
diff --git a/src/Utils.js b/src/Utils.js
index 5457ffa..a9bcb56 100644
--- a/src/Utils.js
+++ b/src/Utils.js
@@ -1571,6 +1571,112 @@ Ext.define('Proxmox.Utils', {
}
hiddenElement.click();
},
+
+ /**
+ * Takes a JSONSchema of a single property and transforms it into an
+ * object representing an ExtJS field.
+ *
+ * @param {string} fieldName - The value for the `name` property of the resulting object.
+ * @param {Object} propSchema - The JSONSchema of a property.
+ * @param {Object} extraAttributes - Additional attributes describing the
+ * field that are not or cannot be represented by the given JSONSchema.
+ * Note that none of these attributes represent any specific ExtJS property.
+ * @param {Boolean} extraAttributes.readonly - Whether the field shall be read-only.
+ * "Read-only" here means that the field's `xtype` will be set to
+ * `'displayfield'`, regardless of what data type the field will
+ * display. Additionally, checkboxes (booleans) will be displayed as
+ * "Yes" or "No" depending on their value (locale-aware).
+ * @param {Boolean} extraAttributes.sensitive - Whether the field
+ * contains sensitive data, such as a password or similar.
+ * @param {Object} context - The context within which the field is being used.
+ * @param {Boolean} context.isCreate - Whether the field will be used during creation.
+ */
+ getFieldDefFromPropertySchema: function (fieldName, propSchema, extraAttributes, context) {
+ const fieldPropsByType = {
+ string: {
+ xtype: 'proxmoxtextfield',
+ minLength: propSchema.minLength,
+ maxLength: propSchema.maxLength,
+ },
+ integer: {
+ xtype: 'proxmoxintegerfield',
+ minValue: propSchema.minimum,
+ maxValue: propSchema.maximum,
+ },
+ number: {
+ xtype: 'numberfield',
+ minValue: propSchema.minimum,
+ maxValue: propSchema.maximum,
+ },
+ boolean: {
+ xtype: 'proxmoxcheckbox',
+ uncheckedValue: 0,
+ },
+ };
+
+ const commonFieldProps = {
+ name: fieldName,
+ emptyText: propSchema.default || '',
+ allowBlank: propSchema.optional,
+ };
+
+ let fieldProps = fieldPropsByType[propSchema.type];
+ if (fieldProps === undefined) {
+ console.warn(`Unhandled property type '${propSchema.type}'`);
+ fieldProps = fieldPropsByType.string;
+ }
+
+ let field = {
+ ...fieldProps,
+ ...commonFieldProps,
+ };
+
+ if (propSchema.title) {
+ field.fieldLabel = Ext.htmlEncode(propSchema.title);
+ }
+
+ if (propSchema.description) {
+ field.autoEl = {
+ tag: 'div',
+ 'data-qtip': Ext.htmlEncode(Ext.htmlEncode(propSchema.description)),
+ };
+ }
+
+ if (context.isCreate && propSchema.default !== undefined) {
+ if (propSchema.type === 'boolean') {
+ field.checked = propSchema.default;
+ } else {
+ field.value = propSchema.default;
+ }
+ }
+
+ if (extraAttributes.sensitive) {
+ field.xtype = 'proxmoxtextfield';
+ field.inputType = 'password';
+
+ if (context.isCreate) {
+ field.value = '';
+ field.emptyText = Proxmox.Utils.NoneText;
+ } else {
+ field.emptyText = gettext('Unchanged');
+ }
+ }
+
+ if (extraAttributes.readonly) {
+ field.xtype = 'displayfield';
+
+ if (propSchema.type === 'boolean') {
+ field.renderer = Proxmox.Utils.format_boolean;
+ }
+
+ if (extraAttributes.sensitive) {
+ field.value = '*********';
+ field.emptyText = '';
+ }
+ }
+
+ return field;
+ },
},
singleton: true,
--
2.47.3
_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel
^ permalink raw reply [flat|nested] 11+ messages in thread* [pve-devel] [RFC proxmox-widget-toolkit master v2 7/10] acme: use helper to construct ExtJS fields from property schemas
2025-11-21 16:58 [pve-devel] [RFC pve-storage/proxmox-widget-toolkit/pve-manager master v2 00/10] GUI Support for Custom Storage Plugins Max R. Carrara
` (5 preceding siblings ...)
2025-11-21 16:58 ` [pve-devel] [RFC proxmox-widget-toolkit master v2 6/10] utils: introduce helper function getFieldDefFromPropertySchema Max R. Carrara
@ 2025-11-21 16:58 ` Max R. Carrara
2025-11-21 16:58 ` [pve-devel] [RFC pve-manager master v2 08/10] api: add API routes 'plugins' and 'plugins/storage' Max R. Carrara
` (2 subsequent siblings)
9 siblings, 0 replies; 11+ messages in thread
From: Max R. Carrara @ 2025-11-21 16:58 UTC (permalink / raw)
To: pve-devel
Use the new `getFieldDefFromPropertySchema()` helper function to
simplify some of the logic in `ACMEPluginEdit.js`.
This results in no noticeable difference when setting up ACME challenge
plugins, except for the fact that fields not marked as optional are
now in fact not optional in the UI.
Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
src/window/ACMEPluginEdit.js | 42 +++++++++++-------------------------
1 file changed, 13 insertions(+), 29 deletions(-)
diff --git a/src/window/ACMEPluginEdit.js b/src/window/ACMEPluginEdit.js
index 900923b..d8591ae 100644
--- a/src/window/ACMEPluginEdit.js
+++ b/src/window/ACMEPluginEdit.js
@@ -69,45 +69,29 @@ Ext.define('Proxmox.window.ACMEPluginEdit', {
for (const [name, definition] of Object.entries(schema.fields).sort((a, b) =>
a[0].localeCompare(b[0]),
)) {
- let xtype;
- switch (definition.type) {
- case 'string':
- xtype = 'proxmoxtextfield';
- break;
- case 'integer':
- xtype = 'proxmoxintegerfield';
- break;
- case 'number':
- xtype = 'numberfield';
- break;
- default:
- console.warn(`unknown type '${definition.type}'`);
- xtype = 'proxmoxtextfield';
- break;
- }
+ let fieldName = `custom_${name}`;
+ let context = { isCreate: me.isCreate };
+
+ let fieldDef = Proxmox.Utils.getFieldDefFromPropertySchema(
+ fieldName,
+ definition,
+ {},
+ context,
+ );
let label = name;
if (typeof definition.name === 'string') {
label = definition.name;
}
- let field = Ext.create({
- xtype,
- name: `custom_${name}`,
+ let extraProps = {
fieldLabel: Ext.htmlEncode(label),
width: '100%',
labelWidth: 150,
labelSeparator: '=',
- emptyText: definition.default || '',
- autoEl: definition.description
- ? {
- tag: 'div',
- 'data-qtip': Ext.htmlEncode(
- Ext.htmlEncode(definition.description),
- ),
- }
- : undefined,
- });
+ };
+
+ let field = Ext.create({ ...fieldDef, ...extraProps });
me.createdFields[name] = field;
container.add(field);
--
2.47.3
_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel
^ permalink raw reply [flat|nested] 11+ messages in thread* [pve-devel] [RFC pve-manager master v2 08/10] api: add API routes 'plugins' and 'plugins/storage'
2025-11-21 16:58 [pve-devel] [RFC pve-storage/proxmox-widget-toolkit/pve-manager master v2 00/10] GUI Support for Custom Storage Plugins Max R. Carrara
` (6 preceding siblings ...)
2025-11-21 16:58 ` [pve-devel] [RFC proxmox-widget-toolkit master v2 7/10] acme: use helper to construct ExtJS fields from property schemas Max R. Carrara
@ 2025-11-21 16:58 ` Max R. Carrara
2025-11-21 16:58 ` [pve-devel] [RFC pve-manager master v2 09/10] ui: storage view: display error when no editor for storage type exists Max R. Carrara
2025-11-21 16:58 ` [pve-devel] [RFC pve-manager master v2 10/10] ui: storage: add basic UI integration for custom storage plugins Max R. Carrara
9 siblings, 0 replies; 11+ messages in thread
From: Max R. Carrara @ 2025-11-21 16:58 UTC (permalink / raw)
To: pve-devel
Add the `plugins` route to our API and handle it via its own module,
`API2/Plugins.pm`. Implement a GET endpoint for this route, which just
lists its subpaths.
Additionally, add the `plugins/storage` path to the API and let all
corresponding endpoints be handled by the
`PVE::API2::Plugins::Storage` module, which is part of the
`libpve-storage-perl` package.
This means that the following routes are now available:
* plugins
* plugins/storage
* plugins/storage/plugin
* plugins/storage/plugin/{type}
Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
PVE/API2.pm | 6 +++++
PVE/API2/Makefile | 1 +
PVE/API2/Plugins.pm | 61 +++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 68 insertions(+)
create mode 100644 PVE/API2/Plugins.pm
diff --git a/PVE/API2.pm b/PVE/API2.pm
index 0c7e4654..af1cf38b 100644
--- a/PVE/API2.pm
+++ b/PVE/API2.pm
@@ -17,6 +17,7 @@ use PVE::API2::Nodes;
use PVE::API2::Pool;
use PVE::API2::AccessControl;
use PVE::API2::Storage::Config;
+use PVE::API2::Plugins;
__PACKAGE__->register_method({
subclass => "PVE::API2::Cluster",
@@ -43,6 +44,11 @@ __PACKAGE__->register_method({
path => 'pools',
});
+__PACKAGE__->register_method({
+ subclass => "PVE::API2::Plugins",
+ path => 'plugins',
+});
+
__PACKAGE__->register_method({
name => 'index',
path => '',
diff --git a/PVE/API2/Makefile b/PVE/API2/Makefile
index 97f1cc20..97910009 100644
--- a/PVE/API2/Makefile
+++ b/PVE/API2/Makefile
@@ -17,6 +17,7 @@ PERLSOURCE = \
Network.pm \
NodeConfig.pm \
Nodes.pm \
+ Plugins.pm \
Pool.pm \
Replication.pm \
ReplicationConfig.pm \
diff --git a/PVE/API2/Plugins.pm b/PVE/API2/Plugins.pm
new file mode 100644
index 00000000..beb606e6
--- /dev/null
+++ b/PVE/API2/Plugins.pm
@@ -0,0 +1,61 @@
+package PVE::API2::Plugins;
+
+use v5.36;
+
+use PVE::RESTHandler;
+use base qw(PVE::RESTHandler);
+
+use PVE::API2::Plugins::Storage;
+
+__PACKAGE__->register_method({
+ subclass => "PVE::API2::Plugins::Storage",
+ path => 'storage',
+});
+
+__PACKAGE__->register_method({
+ name => 'index',
+ path => '',
+ method => 'GET',
+ permissions => {
+ user => 'all',
+ },
+ description => "Directory index.",
+ parameters => {
+ additionalProperties => 0,
+ properties => {},
+ },
+ returns => {
+ type => 'array',
+ items => {
+ type => 'object',
+ properties => {
+ subdir => {
+ type => 'string',
+ },
+ },
+ },
+ links => [
+ {
+ rel => 'child',
+ href => "{subdir}",
+ },
+ ],
+ },
+ code => sub($param) {
+ my $result = [];
+
+ my $attrs = PVE::API2::Plugins->method_attributes();
+
+ for my $info ($attrs->@*) {
+ next if !$info->{subclass};
+
+ my $subpath = $info->{match_re}->[0];
+
+ push $result->@*, { subdir => $subpath };
+ }
+
+ return $result;
+ },
+});
+
+1;
--
2.47.3
_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel
^ permalink raw reply [flat|nested] 11+ messages in thread* [pve-devel] [RFC pve-manager master v2 09/10] ui: storage view: display error when no editor for storage type exists
2025-11-21 16:58 [pve-devel] [RFC pve-storage/proxmox-widget-toolkit/pve-manager master v2 00/10] GUI Support for Custom Storage Plugins Max R. Carrara
` (7 preceding siblings ...)
2025-11-21 16:58 ` [pve-devel] [RFC pve-manager master v2 08/10] api: add API routes 'plugins' and 'plugins/storage' Max R. Carrara
@ 2025-11-21 16:58 ` Max R. Carrara
2025-11-21 16:58 ` [pve-devel] [RFC pve-manager master v2 10/10] ui: storage: add basic UI integration for custom storage plugins Max R. Carrara
9 siblings, 0 replies; 11+ messages in thread
From: Max R. Carrara @ 2025-11-21 16:58 UTC (permalink / raw)
To: pve-devel
... instead of `throw`ing an exception which gets swallowed by ExtJS
and never displayed to the user.
Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
www/manager6/dc/StorageView.js | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/www/manager6/dc/StorageView.js b/www/manager6/dc/StorageView.js
index e4c6f07d..bcc02ed5 100644
--- a/www/manager6/dc/StorageView.js
+++ b/www/manager6/dc/StorageView.js
@@ -13,7 +13,8 @@ Ext.define(
createStorageEditWindow: function (type, sid) {
let schema = PVE.Utils.storageSchema[type];
if (!schema || !schema.ipanel) {
- throw 'no editor registered for storage type: ' + type;
+ Ext.Msg.alert(gettext('Error'), `No editor registered for storage type '${type}'`);
+ return;
}
Ext.create('PVE.storage.BaseEdit', {
--
2.47.3
_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel
^ permalink raw reply [flat|nested] 11+ messages in thread* [pve-devel] [RFC pve-manager master v2 10/10] ui: storage: add basic UI integration for custom storage plugins
2025-11-21 16:58 [pve-devel] [RFC pve-storage/proxmox-widget-toolkit/pve-manager master v2 00/10] GUI Support for Custom Storage Plugins Max R. Carrara
` (8 preceding siblings ...)
2025-11-21 16:58 ` [pve-devel] [RFC pve-manager master v2 09/10] ui: storage view: display error when no editor for storage type exists Max R. Carrara
@ 2025-11-21 16:58 ` Max R. Carrara
9 siblings, 0 replies; 11+ messages in thread
From: Max R. Carrara @ 2025-11-21 16:58 UTC (permalink / raw)
To: pve-devel
This commit adds a basic / rudimentary UI integration for custom
storage plugins.
Do this by issuing a request to the new `GET plugins/storage/plugin`
endpoint and checking whether a plugin is custom or not via its Perl
module path. When a user then adds or edits a storage config entry
belonging to a custom storage plugin, the new
`PVE.storage.CustomInputPanel` opens and builds the form's view from
the schemas of the plugin's properties.
In other words, the UI is built completely from the information
provided by the plugin's SectionConfig schema.
It is worth noting that the "Add" dropdown menu button's items are
added to the menu on the fly once the request to
`plugins/storage/plugin` succeeds. For the short moment that the
request is being awaited, the "Add" button is disabled.
This is not noticeable at all for regular (i.e. decently fast)
connections. Users with (very) slow connections will however notice
that the button stays disabled until the request succeeded. If that
were not the case, the dropdown menu of the "Add" button would not
even show up when clicked. Therefore keep the button disabled (greyed
out, unclickable) for the little time that the user cannot do anything
with it anyway.
Signed-off-by: Max R. Carrara <m.carrara@proxmox.com>
---
www/manager6/Makefile | 1 +
www/manager6/dc/StorageView.js | 131 +++++++++++++++++++++--------
www/manager6/storage/Base.js | 1 +
www/manager6/storage/CustomEdit.js | 110 ++++++++++++++++++++++++
4 files changed, 209 insertions(+), 34 deletions(-)
create mode 100644 www/manager6/storage/CustomEdit.js
diff --git a/www/manager6/Makefile b/www/manager6/Makefile
index 4558d53e..85401b4f 100644
--- a/www/manager6/Makefile
+++ b/www/manager6/Makefile
@@ -339,6 +339,7 @@ JSSRC= \
storage/Browser.js \
storage/CIFSEdit.js \
storage/CephFSEdit.js \
+ storage/CustomEdit.js \
storage/DirEdit.js \
storage/ImageView.js \
storage/IScsiEdit.js \
diff --git a/www/manager6/dc/StorageView.js b/www/manager6/dc/StorageView.js
index bcc02ed5..7bcb5637 100644
--- a/www/manager6/dc/StorageView.js
+++ b/www/manager6/dc/StorageView.js
@@ -11,20 +11,47 @@ Ext.define(
stateId: 'grid-dc-storage',
createStorageEditWindow: function (type, sid) {
- let schema = PVE.Utils.storageSchema[type];
- if (!schema || !schema.ipanel) {
- Ext.Msg.alert(gettext('Error'), `No editor registered for storage type '${type}'`);
+ let me = this;
+
+ const metadata = me.pluginMetadata[type];
+
+ // Should never happen, but still handle it here just in case
+ if (!metadata) {
+ Ext.Msg.alert(gettext('Error'), `Plugin '${type}' has no metadata`);
return;
}
+ let isCustom = metadata.module.startsWith('PVE::Storage::Custom');
+
+ let paneltype;
+ let canDoBackups;
+
+ if (isCustom) {
+ paneltype = 'PVE.storage.CustomInputPanel';
+ canDoBackups = metadata.content.supported.includes('backup');
+ } else {
+ let schema = PVE.Utils.storageSchema[type];
+ if (!schema || !schema.ipanel) {
+ Ext.Msg.alert(
+ gettext('Error'),
+ `No editor registered for storage type '${type}'`,
+ );
+ return;
+ }
+
+ paneltype = 'PVE.storage.' + schema.ipanel;
+ canDoBackups = schema.backups;
+ }
+
Ext.create('PVE.storage.BaseEdit', {
- paneltype: 'PVE.storage.' + schema.ipanel,
+ paneltype: paneltype,
type: type,
storageId: sid,
- canDoBackups: schema.backups,
+ canDoBackups: canDoBackups,
+ metadata: metadata,
autoShow: true,
listeners: {
- destroy: this.reloadStore,
+ destroy: me.reloadStore,
},
});
},
@@ -46,6 +73,69 @@ Ext.define(
let sm = Ext.create('Ext.selection.RowModel', {});
+ me.pluginMetadata = {};
+
+ let menuButtonAdd = new Ext.menu.Menu({
+ items: [],
+ });
+
+ let addBtn = new Ext.Button({
+ menu: menuButtonAdd,
+ text: gettext('Add'),
+ disabled: true,
+ });
+
+ let pushBuiltinPluginsToMenu = function () {
+ for (const [type, storage] of Object.entries(PVE.Utils.storageSchema)) {
+ if (storage.hideAdd) {
+ continue;
+ }
+
+ menuButtonAdd.add({
+ text: PVE.Utils.format_storage_type(type),
+ iconCls: 'fa fa-fw fa-' + storage.faIcon,
+ handler: () => me.createStorageEditWindow(type),
+ });
+ }
+ };
+
+ let pushCustomPluginsToMenu = function () {
+ for (const type in me.pluginMetadata) {
+ if (!Object.hasOwn(me.pluginMetadata, type)) {
+ continue;
+ }
+
+ const metadata = me.pluginMetadata[type];
+ let isCustom = metadata.module.startsWith('PVE::Storage::Custom');
+
+ if (isCustom) {
+ menuButtonAdd.add({
+ text: PVE.Utils.format_storage_type(type),
+ iconCls: 'fa fa-fw fa-folder',
+ handler: () => me.createStorageEditWindow(type),
+ });
+ }
+ }
+ };
+
+ Proxmox.Utils.API2Request({
+ url: `/api2/extjs/plugins/storage/plugin`,
+ method: 'GET',
+ success: function ({ result: { data } }) {
+ data.forEach((metadata) => {
+ me.pluginMetadata[metadata.type] = metadata;
+ });
+
+ pushBuiltinPluginsToMenu();
+ pushCustomPluginsToMenu();
+
+ addBtn.setDisabled(false);
+ },
+ failure: function ({ htmlStatus }) {
+ Ext.Msg.alert('Error', htmlStatus);
+ },
+ });
+
let run_editor = function () {
let rec = sm.getSelection()[0];
if (!rec) {
@@ -67,24 +157,6 @@ Ext.define(
callback: () => store.load(),
});
- // else we cannot dynamically generate the add menu handlers
- let addHandleGenerator = function (type) {
- return function () {
- me.createStorageEditWindow(type);
- };
- };
- let addMenuItems = [];
- for (const [type, storage] of Object.entries(PVE.Utils.storageSchema)) {
- if (storage.hideAdd) {
- continue;
- }
- addMenuItems.push({
- text: PVE.Utils.format_storage_type(type),
- iconCls: 'fa fa-fw fa-' + storage.faIcon,
- handler: addHandleGenerator(type),
- });
- }
-
Ext.apply(me, {
store: store,
reloadStore: () => store.load(),
@@ -92,16 +164,7 @@ Ext.define(
viewConfig: {
trackOver: false,
},
- tbar: [
- {
- text: gettext('Add'),
- menu: new Ext.menu.Menu({
- items: addMenuItems,
- }),
- },
- remove_btn,
- edit_btn,
- ],
+ tbar: [addBtn, remove_btn, edit_btn],
columns: [
{
header: 'ID',
diff --git a/www/manager6/storage/Base.js b/www/manager6/storage/Base.js
index cf89ef6d..43fc70d6 100644
--- a/www/manager6/storage/Base.js
+++ b/www/manager6/storage/Base.js
@@ -138,6 +138,7 @@ Ext.define('PVE.storage.BaseEdit', {
type: me.type,
isCreate: me.isCreate,
storageId: me.storageId,
+ metadata: me.metadata,
});
Ext.apply(me, {
diff --git a/www/manager6/storage/CustomEdit.js b/www/manager6/storage/CustomEdit.js
new file mode 100644
index 00000000..a7e7c7e0
--- /dev/null
+++ b/www/manager6/storage/CustomEdit.js
@@ -0,0 +1,110 @@
+Ext.define('PVE.storage.CustomInputPanel', {
+ extend: 'PVE.panel.StorageBase',
+
+ buildFormFieldFromProperty: function (propertyName) {
+ let me = this;
+ let schema = me.metadata.schema;
+
+ let property = schema[propertyName];
+
+ if (!property) {
+ console.warn(
+ `Tried to create field for unknown property '${propertyName}'` +
+ ` for storage type '${me.type}'`,
+ );
+ return;
+ }
+
+ let fieldName = propertyName;
+ let extraAttributes = {
+ readonly: property.fixed && !me.isCreate,
+ sensitive: property.sensitive,
+ };
+ let context = { isCreate: me.isCreate };
+
+ let fieldDef = Proxmox.Utils.getFieldDefFromPropertySchema(
+ fieldName,
+ property,
+ extraAttributes,
+ context,
+ );
+
+ if (!fieldDef.fieldLabel) {
+ fieldDef.fieldLabel = Ext.htmlEncode(propertyName);
+ }
+
+ return fieldDef;
+ },
+
+ addWidget: function (widget) {
+ let me = this;
+
+ me.column1 = me.column1 || [];
+ me.column2 = me.column2 || [];
+
+ if (me.column2.length >= me.column1.length) {
+ me.column1.push(widget);
+ } else {
+ me.column2.push(widget);
+ }
+ },
+
+ initComponent: function () {
+ let me = this;
+ let schema = me.metadata.schema;
+
+ me.column1 = me.column1 || [];
+ me.column2 = me.column2 || [];
+
+ const reservedFields = new Set([
+ // automatically added in PVE.panel.StorageBase
+ 'storage',
+ 'nodes',
+ 'disable',
+
+ // handled separately for consistency
+ 'content',
+ 'shared',
+
+ // not an actual property, but used by the UI as an inverse of the 'disable' property
+ 'enable',
+
+ // handled by the "Backup Retention" panel
+ 'prune-backups',
+ 'max-protected-backups',
+ ]);
+
+ // Add the field for the 'content' property first for consistency's sake
+ let propertyContent = schema.content;
+ if (propertyContent) {
+ let fieldDefContent = {
+ xtype: 'pveContentTypeSelector',
+ cts: me.metadata.content.supported,
+ fieldLabel: gettext('Content'),
+ name: 'content',
+ value: me.metadata.content.default,
+ multiSelect: true,
+ allowBlank: false,
+ };
+
+ me.column1.push(fieldDefContent);
+ }
+
+ let propertyShared = schema.shared;
+ if (propertyShared) {
+ let fieldDefShared = me.buildFormFieldFromProperty('shared');
+ me.column1.push(fieldDefShared);
+ }
+
+ for (const propertyName of Object.keys(schema).sort()) {
+ if (reservedFields.has(propertyName)) {
+ continue;
+ }
+
+ let fieldDef = me.buildFormFieldFromProperty(propertyName);
+ me.addWidget(fieldDef);
+ }
+
+ me.callParent();
+ },
+});
--
2.47.3
_______________________________________________
pve-devel mailing list
pve-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel
^ permalink raw reply [flat|nested] 11+ messages in thread