public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
* [RFC PATCH widget-toolkit/manager 0/7] ui: allow selecting the unit for memory and disk sizes
@ 2026-10-26 22:04 Timo Meschkat
  2026-10-26 22:04 ` [RFC PATCH widget-toolkit 1/3] form: add size field with selectable unit Timo Meschkat
                   ` (6 more replies)
  0 siblings, 7 replies; 8+ messages in thread
From: Timo Meschkat @ 2026-10-26 22:04 UTC (permalink / raw)
  To: pve-devel

Memory, minimum memory, swap, disk size and disk resize can only be
entered in a fixed unit (MiB or GiB) so far. This series adds a unit
selector (MiB, GiB, TiB) next to these fields, and also accepts a unit
suffix typed right into the field, e.g. '8G' or '512M'.

The defaults stay the same, MiB for memory and GiB for disks, and the
API still gets the same units as before.

This addresses bug #3760 [0]. There was an unfinished series for this
in 2023 [1], which already used a unit selector.

Sent as RFC, as I'd like feedback on the approach first:

- The widget toolkit patches add a new component instead of making the
  unit of Proxmox.form.SizeField selectable, as noted in its TODO. That
  field only converts the first value set from the backend, which does
  not work for fields updated by other fields, like the minimum memory
  following the memory. It is also used by other products. If extending
  it is preferred anyway, I can rework that.

- The unit selector follows a typed suffix while typing, the text is
  normalized on blur. The minimum memory follows both value and unit of
  the memory, as long as the user did not change it.

Behavior changes worth noting:
- disk resize sends the increment in MiB, e.g. '+1536M' instead of
  '+1.5G'
- the minimum size for new disks is 1 MiB instead of 0.001 GiB
- min/max error messages include the unit, e.g. '16.00 MiB'
- the VM disk resize window is wider to fit the unit selector

New translatable strings: 'Unit', 'Also accepts a unit suffix, e.g.
{0}', 'Invalid size, allowed units: {0}', 'Capacity of the disk',
'Capacity to add to the disk' and four memory/swap descriptions.

The manager patches depend on the widget toolkit patches, so they need
a bumped dependency on proxmox-widget-toolkit.

Tested on pve-manager 9.2.24 by scripting the affected panels and
windows in a headless browser: VM and CT memory (with and without
memory hotplug), the disk storage selector and both resize windows.

This is my first patch series for this list, so please excuse me if I
missed any conventions.

[0] https://bugzilla.proxmox.com/show_bug.cgi?id=3760
[1] https://lore.proxmox.com/all/20230727103049.38556-1-n.ullreich@proxmox.com/T

proxmox-widget-toolkit:

Timo Meschkat (3):
      form: add size field with selectable unit
      form: size field: accept a unit suffix like 8G
      form: size field: add optional field description


 src/Makefile              |   1 +
 src/form/SizeUnitField.js | 310 ++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 311 insertions(+)

pve-manager:

Timo Meschkat (4):
      fix #3760: ui: qemu: allow selecting the memory unit
      ui: lxc: allow selecting the unit for memory and swap
      ui: disk storage selector: allow selecting the size unit
      ui: disk resize: allow selecting the size unit


 www/manager6/form/DiskStorageSelector.js | 10 ++++--
 www/manager6/form/MemoryField.js         | 59 +++++++++++++++-----------------
 www/manager6/lxc/MPResize.js             | 11 +++---
 www/manager6/lxc/ResourceEdit.js         | 16 +++++----
 www/manager6/qemu/HDResize.js            | 13 ++++---
 www/manager6/qemu/MemoryEdit.js          | 29 ++++++++++------
 www/manager6/window/GuestImport.js       |  5 ++-
 www/manager6/window/Restore.js           |  4 +++
 8 files changed, 87 insertions(+), 60 deletions(-)

Summary over all repositories:
  10 files changed, 398 insertions(+), 60 deletions(-)

-- 
2.47.3




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

* [RFC PATCH widget-toolkit 1/3] form: add size field with selectable unit
  2026-10-26 22:04 [RFC PATCH widget-toolkit/manager 0/7] ui: allow selecting the unit for memory and disk sizes Timo Meschkat
@ 2026-10-26 22:04 ` Timo Meschkat
  2026-10-26 22:04 ` [RFC PATCH widget-toolkit 2/3] form: size field: accept a unit suffix like 8G Timo Meschkat
                   ` (5 subsequent siblings)
  6 siblings, 0 replies; 8+ messages in thread
From: Timo Meschkat @ 2026-10-26 22:04 UTC (permalink / raw)
  To: pve-devel

Add a size input with a unit selector next to it. Value, limits and
the submitted value always use the configured backend unit, changing
the unit only changes what is displayed.

This is a new component instead of making the unit of the existing
size field selectable. That one only converts the first value set from
the backend, which breaks for fields updated by other fields, like the
minimum memory of a VM following its memory. It is also used in other
products, so changing its behavior would be riskier.

The input is plain text with spinner buttons, so parsing and
validation happen in the field itself and no internals of the ExtJS
number field need to be overridden. As the unit is not part of the
input, screen readers get it announced together with the value.

Signed-off-by: Timo Meschkat <timo.meschkat@datenzelt.de>
---
 src/Makefile              |   1 +
 src/form/SizeUnitField.js | 287 ++++++++++++++++++++++++++++++++++++++
 2 files changed, 288 insertions(+)
 create mode 100644 src/form/SizeUnitField.js

diff --git a/src/Makefile b/src/Makefile
index 9c52339..532725c 100644
--- a/src/Makefile
+++ b/src/Makefile
@@ -27,6 +27,7 @@ JSSRC=					\
 	data/model/Certificates.js	\
 	data/model/ACME.js		\
 	form/BandwidthSelector.js	\
+	form/SizeUnitField.js	\
 	form/DisplayEdit.js		\
 	form/ExpireDate.js		\
 	form/IntegerField.js		\
diff --git a/src/form/SizeUnitField.js b/src/form/SizeUnitField.js
new file mode 100644
index 0000000..e103b59
--- /dev/null
+++ b/src/form/SizeUnitField.js
@@ -0,0 +1,287 @@
+// Size input with a selectable unit. Values, limits and submit always use 'backendUnit'.
+Ext.define('Proxmox.form.SizeUnitField', {
+    extend: 'Ext.form.FieldContainer',
+    alias: 'widget.pmxSizeUnitField',
+
+    mixins: {
+        field: 'Ext.form.field.Field',
+    },
+
+    layout: 'hbox',
+
+    backendUnit: 'B',
+
+    // ascending
+    units: ['MiB', 'GiB', 'TiB'],
+
+    // defaults to the first of 'units'
+    unit: undefined,
+
+    // spin step per unit, default 1
+    unitSteps: {},
+
+    // in backend units
+    minValue: 0,
+    maxValue: undefined,
+
+    allowBlank: true,
+
+    emptyText: '',
+
+    decimalPrecision: 2,
+
+    // otherwise round to whole backend units
+    allowFractionalValue: false,
+
+    value: null,
+
+    initComponent: function () {
+        let me = this;
+
+        me.unit ??= me.units[0];
+        me.initialUnit = me.unit;
+
+        me.items = [
+            {
+                // plain text, parsing and validation happen in this field
+                xtype: 'spinnerfield',
+                itemId: 'input',
+                isFormField: false,
+                flex: 1,
+                // the field label belongs to the container, not the inputs
+                ariaLabel: me.fieldLabel,
+                fieldStyle: 'text-align: right',
+                allowBlank: me.allowBlank,
+                emptyText: me.emptyText,
+                validator: (text) => me.validateText(text),
+                listeners: {
+                    afterrender: () => me.updateAriaValueText(),
+                    change: (field, text) => me.onTextChange(text),
+                    blur: (field) => field.isValid() && me.updateDisplay(),
+                    spinup: () => me.spin(true),
+                    spindown: () => me.spin(false),
+                    validitychange: (field, isValid) => me.fireEvent('validitychange', me, isValid),
+                },
+            },
+            {
+                xtype: 'combobox',
+                itemId: 'unit',
+                isFormField: false,
+                ariaLabel: `${me.fieldLabel ?? ''} ${gettext('Unit')}`.trim(),
+                width: 70,
+                margin: '0 0 0 5',
+                editable: false,
+                queryMode: 'local',
+                store: me.units,
+                value: me.unit,
+                listeners: {
+                    // only fired on user selection
+                    select: (field) => {
+                        me.userChanged = true;
+                        me.setUnit(field.getValue());
+                    },
+                },
+            },
+        ];
+
+        me.callParent();
+
+        me.inputField = me.getComponent('input');
+        me.unitField = me.getComponent('unit');
+
+        me.initField();
+    },
+
+    getUnitFactor: function (unit = this.unit) {
+        return Proxmox.Utils.SizeUnits[unit] / Proxmox.Utils.SizeUnits[this.backendUnit];
+    },
+
+    getUnit: function () {
+        return this.unit;
+    },
+
+    setUnit: function (unit) {
+        let me = this;
+        me.changeUnit(unit);
+        me.updateDisplay();
+    },
+
+    // without updating the display
+    changeUnit: function (unit) {
+        let me = this;
+        let oldUnit = me.unit;
+        me.unit = unit;
+        me.unitField.setValue(unit);
+        if (unit !== oldUnit) {
+            me.fireEvent('unitchange', me, unit, oldUnit);
+        }
+    },
+
+    // typed, spun or picked a unit since the last reset
+    hasUserInput: function () {
+        return !!this.userChanged;
+    },
+
+    // '8' or '1,5' in the current unit; returns undefined if empty, null if invalid
+    parseText: function (text) {
+        let me = this;
+        text = String(text ?? '').trim();
+        if (text === '') {
+            return undefined;
+        }
+        if (!/^\d+(?:[.,]\d*)?$/.test(text)) {
+            return null;
+        }
+        let value = parseFloat(text.replace(',', '.')) * me.getUnitFactor();
+        return me.allowFractionalValue ? value : Math.round(value);
+    },
+
+    parseValue: function (value) {
+        if (value === undefined || value === null || value === '') {
+            return null;
+        }
+        value = Number(value);
+        return Number.isFinite(value) ? value : null;
+    },
+
+    formatNumber: function (number) {
+        let me = this;
+        let text = String(Number(number.toFixed(me.decimalPrecision)));
+        return text.replace('.', Ext.util.Format.decimalSeparator);
+    },
+
+    setValue: function (value) {
+        let me = this;
+        me.mixins.field.setValue.call(me, me.parseValue(value));
+        me.updateDisplay();
+        return me;
+    },
+
+    getValue: function () {
+        return this.value;
+    },
+
+    getSubmitValue: function () {
+        let me = this;
+        let value = me.getValue();
+        if (value === null) {
+            return null;
+        }
+        if (!me.allowFractionalValue) {
+            return String(Math.round(value));
+        }
+        // no exponential notation for tiny values
+        return value.toFixed(12).replace(/\.?0+$/, '');
+    },
+
+    getSubmitData: function () {
+        let me = this;
+        let value = me.getSubmitValue();
+        if (me.disabled || !me.submitValue || value === null) {
+            return null;
+        }
+        return { [me.getName()]: value };
+    },
+
+    updateDisplay: function () {
+        let me = this;
+        let text = me.value === null ? '' : me.formatNumber(me.value / me.getUnitFactor());
+        me.inputField.suspendEvent('change');
+        me.inputField.setValue(text);
+        me.inputField.resumeEvent('change');
+        me.updateAriaValueText();
+    },
+
+    // the unit sits in its own selector, so tell screen readers e.g. '2 GiB'
+    updateAriaValueText: function () {
+        let me = this;
+        let dom = me.inputField.inputEl?.dom;
+        if (!dom) {
+            return;
+        }
+        if (me.value === null) {
+            dom.removeAttribute('aria-valuetext');
+        } else {
+            let number = me.formatNumber(me.value / me.getUnitFactor());
+            dom.setAttribute('aria-valuetext', `${number} ${me.unit}`);
+        }
+    },
+
+    onTextChange: function (text) {
+        let me = this;
+        me.userChanged = true;
+        me.mixins.field.setValue.call(me, me.parseText(text) ?? null);
+        me.updateAriaValueText();
+    },
+
+    validateText: function (text) {
+        let me = this;
+        let value = me.parseText(text);
+        if (value === undefined) {
+            return true; // allowBlank handles this
+        } else if (value === null) {
+            return Ext.String.format(Ext.form.field.Number.prototype.nanText, text);
+        }
+        let format = (v) => Proxmox.Utils.format_size(v * Proxmox.Utils.SizeUnits[me.backendUnit]);
+        let numberField = Ext.form.field.Number.prototype;
+        if (me.minValue !== undefined && value < me.minValue) {
+            return Ext.String.format(numberField.minText, format(me.minValue));
+        }
+        if (me.maxValue !== undefined && value > me.maxValue) {
+            return Ext.String.format(numberField.maxText, format(me.maxValue));
+        }
+        return true;
+    },
+
+    // override for non-linear steps
+    getSpinValue: function (value, up) {
+        let me = this;
+        let step = (me.unitSteps[me.unit] ?? 1) * me.getUnitFactor();
+        return up ? value + step : value - step;
+    },
+
+    spin: function (up) {
+        let me = this;
+        let value = me.getSpinValue(me.getValue() ?? 0, up);
+        me.userChanged = true;
+        me.mixins.field.setValue.call(me, Ext.Number.constrain(value, me.minValue, me.maxValue));
+        me.updateDisplay();
+    },
+
+    setMinValue: function (value) {
+        this.minValue = this.parseValue(value) ?? undefined;
+        this.inputField.validate();
+    },
+
+    setMaxValue: function (value) {
+        this.maxValue = this.parseValue(value) ?? undefined;
+        this.inputField.validate();
+    },
+
+    setEmptyText: function (emptyText) {
+        this.inputField.setEmptyText(emptyText);
+    },
+
+    reset: function () {
+        let me = this;
+        me.userChanged = false;
+        me.changeUnit(me.initialUnit);
+        me.mixins.field.reset.call(me);
+    },
+
+    isValid: function () {
+        return this.disabled || this.inputField.isValid();
+    },
+
+    validate: function () {
+        return this.disabled || this.inputField.validate();
+    },
+
+    markInvalid: function (errors) {
+        this.inputField.markInvalid(errors);
+    },
+
+    clearInvalid: function () {
+        this.inputField.clearInvalid();
+    },
+});
-- 
2.47.3




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

* [RFC PATCH widget-toolkit 2/3] form: size field: accept a unit suffix like 8G
  2026-10-26 22:04 [RFC PATCH widget-toolkit/manager 0/7] ui: allow selecting the unit for memory and disk sizes Timo Meschkat
  2026-10-26 22:04 ` [RFC PATCH widget-toolkit 1/3] form: add size field with selectable unit Timo Meschkat
@ 2026-10-26 22:04 ` Timo Meschkat
  2026-10-26 22:04 ` [RFC PATCH widget-toolkit 3/3] form: size field: add optional field description Timo Meschkat
                   ` (4 subsequent siblings)
  6 siblings, 0 replies; 8+ messages in thread
From: Timo Meschkat @ 2026-10-26 22:04 UTC (permalink / raw)
  To: pve-devel

Selecting the unit first is an extra step for keyboard users. Allow
typing it right away, e.g. '8G', '512M' or '1.5 GiB', like the backend
accepts it. The unit selector follows while typing, the text itself is
only normalized on blur, so typing is not interrupted.

SI suffixes like 'GB' are rejected, they would be ambiguous.

Signed-off-by: Timo Meschkat <timo.meschkat@datenzelt.de>
---
 src/form/SizeUnitField.js | 38 +++++++++++++++++++++++++++-----------
 1 file changed, 27 insertions(+), 11 deletions(-)

diff --git a/src/form/SizeUnitField.js b/src/form/SizeUnitField.js
index e103b59..9fd7308 100644
--- a/src/form/SizeUnitField.js
+++ b/src/form/SizeUnitField.js
@@ -41,6 +41,12 @@ Ext.define('Proxmox.form.SizeUnitField', {
         me.unit ??= me.units[0];
         me.initialUnit = me.unit;
 
+        // e.g. '512M, 8G'
+        let examples = me.units.slice(0, 2).map((unit, i) => `${i === 0 ? 512 : 8}${unit[0]}`);
+        let hint = Ext.htmlEncode(
+            Ext.String.format(gettext('Also accepts a unit suffix, e.g. {0}'), examples.join(', ')),
+        );
+
         me.items = [
             {
                 // plain text, parsing and validation happen in this field
@@ -50,6 +56,7 @@ Ext.define('Proxmox.form.SizeUnitField', {
                 flex: 1,
                 // the field label belongs to the container, not the inputs
                 ariaLabel: me.fieldLabel,
+                inputAttrTpl: `data-qtip="${hint}" aria-description="${hint}"`,
                 fieldStyle: 'text-align: right',
                 allowBlank: me.allowBlank,
                 emptyText: me.emptyText,
@@ -122,18 +129,20 @@ Ext.define('Proxmox.form.SizeUnitField', {
         return !!this.userChanged;
     },
 
-    // '8' or '1,5' in the current unit; returns undefined if empty, null if invalid
+    // '8', '1,5', '8G' or '1.5 GiB'; returns undefined if empty, null if invalid
     parseText: function (text) {
         let me = this;
         text = String(text ?? '').trim();
         if (text === '') {
             return undefined;
         }
-        if (!/^\d+(?:[.,]\d*)?$/.test(text)) {
+        let match = text.match(/^(\d+(?:[.,]\d*)?)\s*(?:([KMGT])(?:i|iB)?)?$/i);
+        let unit = match?.[2] ? `${match[2].toUpperCase()}iB` : me.unit;
+        if (!match || !me.units.includes(unit)) {
             return null;
         }
-        let value = parseFloat(text.replace(',', '.')) * me.getUnitFactor();
-        return me.allowFractionalValue ? value : Math.round(value);
+        let value = parseFloat(match[1].replace(',', '.')) * me.getUnitFactor(unit);
+        return { value: me.allowFractionalValue ? value : Math.round(value), unit };
     },
 
     parseValue: function (value) {
@@ -209,25 +218,32 @@ Ext.define('Proxmox.form.SizeUnitField', {
 
     onTextChange: function (text) {
         let me = this;
+        let input = me.parseText(text);
         me.userChanged = true;
-        me.mixins.field.setValue.call(me, me.parseText(text) ?? null);
+        if (input && input.unit !== me.unit) {
+            me.changeUnit(input.unit); // the typed text stays until blur
+        }
+        me.mixins.field.setValue.call(me, input ? input.value : null);
         me.updateAriaValueText();
     },
 
     validateText: function (text) {
         let me = this;
-        let value = me.parseText(text);
-        if (value === undefined) {
+        let input = me.parseText(text);
+        if (input === undefined) {
             return true; // allowBlank handles this
-        } else if (value === null) {
-            return Ext.String.format(Ext.form.field.Number.prototype.nanText, text);
+        } else if (input === null) {
+            return Ext.String.format(
+                gettext('Invalid size, allowed units: {0}'),
+                me.units.join(', '),
+            );
         }
         let format = (v) => Proxmox.Utils.format_size(v * Proxmox.Utils.SizeUnits[me.backendUnit]);
         let numberField = Ext.form.field.Number.prototype;
-        if (me.minValue !== undefined && value < me.minValue) {
+        if (me.minValue !== undefined && input.value < me.minValue) {
             return Ext.String.format(numberField.minText, format(me.minValue));
         }
-        if (me.maxValue !== undefined && value > me.maxValue) {
+        if (me.maxValue !== undefined && input.value > me.maxValue) {
             return Ext.String.format(numberField.maxText, format(me.maxValue));
         }
         return true;
-- 
2.47.3




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

* [RFC PATCH widget-toolkit 3/3] form: size field: add optional field description
  2026-10-26 22:04 [RFC PATCH widget-toolkit/manager 0/7] ui: allow selecting the unit for memory and disk sizes Timo Meschkat
  2026-10-26 22:04 ` [RFC PATCH widget-toolkit 1/3] form: add size field with selectable unit Timo Meschkat
  2026-10-26 22:04 ` [RFC PATCH widget-toolkit 2/3] form: size field: accept a unit suffix like 8G Timo Meschkat
@ 2026-10-26 22:04 ` Timo Meschkat
  2026-10-26 22:04 ` [RFC PATCH manager 1/4] fix #3760: ui: qemu: allow selecting the memory unit Timo Meschkat
                   ` (3 subsequent siblings)
  6 siblings, 0 replies; 8+ messages in thread
From: Timo Meschkat @ 2026-10-26 22:04 UTC (permalink / raw)
  To: pve-devel

The label alone, e.g. 'Memory', does not always tell what the value is
for. Allow describing it, e.g. as the memory of a VM. The description
is shown as tooltip together with the suffix hint and read by screen
readers.

Signed-off-by: Timo Meschkat <timo.meschkat@datenzelt.de>
---
 src/form/SizeUnitField.js | 13 ++++++++++---
 1 file changed, 10 insertions(+), 3 deletions(-)

diff --git a/src/form/SizeUnitField.js b/src/form/SizeUnitField.js
index 9fd7308..4e9aef9 100644
--- a/src/form/SizeUnitField.js
+++ b/src/form/SizeUnitField.js
@@ -28,6 +28,9 @@ Ext.define('Proxmox.form.SizeUnitField', {
 
     emptyText: '',
 
+    // shown as tooltip and read by screen readers
+    fieldDescription: undefined,
+
     decimalPrecision: 2,
 
     // otherwise round to whole backend units
@@ -43,9 +46,13 @@ Ext.define('Proxmox.form.SizeUnitField', {
 
         // e.g. '512M, 8G'
         let examples = me.units.slice(0, 2).map((unit, i) => `${i === 0 ? 512 : 8}${unit[0]}`);
-        let hint = Ext.htmlEncode(
-            Ext.String.format(gettext('Also accepts a unit suffix, e.g. {0}'), examples.join(', ')),
+        let hint = Ext.String.format(
+            gettext('Also accepts a unit suffix, e.g. {0}'),
+            examples.join(', '),
         );
+        let descriptions = [me.fieldDescription, hint].filter((text) => !!text);
+        let qtip = descriptions.map((text) => Ext.htmlEncode(text)).join('<br>');
+        let ariaDescription = Ext.htmlEncode(descriptions.join('. '));
 
         me.items = [
             {
@@ -56,7 +63,7 @@ Ext.define('Proxmox.form.SizeUnitField', {
                 flex: 1,
                 // the field label belongs to the container, not the inputs
                 ariaLabel: me.fieldLabel,
-                inputAttrTpl: `data-qtip="${hint}" aria-description="${hint}"`,
+                inputAttrTpl: `data-qtip="${qtip}" aria-description="${ariaDescription}"`,
                 fieldStyle: 'text-align: right',
                 allowBlank: me.allowBlank,
                 emptyText: me.emptyText,
-- 
2.47.3




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

* [RFC PATCH manager 1/4] fix #3760: ui: qemu: allow selecting the memory unit
  2026-10-26 22:04 [RFC PATCH widget-toolkit/manager 0/7] ui: allow selecting the unit for memory and disk sizes Timo Meschkat
                   ` (2 preceding siblings ...)
  2026-10-26 22:04 ` [RFC PATCH widget-toolkit 3/3] form: size field: add optional field description Timo Meschkat
@ 2026-10-26 22:04 ` Timo Meschkat
  2026-10-26 22:04 ` [RFC PATCH manager 2/4] ui: lxc: allow selecting the unit for memory and swap Timo Meschkat
                   ` (2 subsequent siblings)
  6 siblings, 0 replies; 8+ messages in thread
From: Timo Meschkat @ 2026-10-26 22:04 UTC (permalink / raw)
  To: pve-devel

Memory could only be entered in MiB so far, so larger values had to be
converted by hand. Allow entering it in MiB, GiB or TiB, or typing it
with a suffix like '8G'. MiB stays the default and is still what gets
sent to the API.

The minimum memory now follows both value and unit of the memory, as
long as the user did not change it.

The restore and guest import windows use the same field for their
memory override.

Signed-off-by: Timo Meschkat <timo.meschkat@datenzelt.de>
---
 www/manager6/form/MemoryField.js   | 59 ++++++++++++++----------------
 www/manager6/qemu/MemoryEdit.js    | 29 ++++++++++-----
 www/manager6/window/GuestImport.js |  5 ++-
 www/manager6/window/Restore.js     |  4 ++
 4 files changed, 55 insertions(+), 42 deletions(-)

diff --git a/www/manager6/form/MemoryField.js b/www/manager6/form/MemoryField.js
index 1ca533b7..75c1a23a 100644
--- a/www/manager6/form/MemoryField.js
+++ b/www/manager6/form/MemoryField.js
@@ -1,7 +1,15 @@
 Ext.define('PVE.form.MemoryField', {
-    extend: 'Ext.form.field.Number',
+    extend: 'Proxmox.form.SizeUnitField',
     alias: 'widget.pveMemoryField',
 
+    backendUnit: 'MiB',
+
+    units: ['MiB', 'GiB', 'TiB'],
+
+    unitSteps: {
+        MiB: 32,
+    },
+
     allowBlank: false,
 
     hotplug: false,
@@ -10,21 +18,9 @@ Ext.define('PVE.form.MemoryField', {
 
     maxValue: 4178944,
 
-    step: 32,
-
     value: '512', // qm backend default
 
-    allowDecimals: false,
-
-    allowExponential: false,
-
     computeUpDown: function (value) {
-        var me = this;
-
-        if (!me.hotplug) {
-            return { up: value + me.step, down: value - me.step };
-        }
-
         var dimm_size = 512;
         var prev_dimm_size = 0;
         var min_size = 1024;
@@ -50,20 +46,15 @@ Ext.define('PVE.form.MemoryField', {
         return { up: value_up, down: value_down, start: value_start };
     },
 
-    onSpinUp: function () {
-        var me = this;
-        if (!me.readOnly) {
-            let res = me.computeUpDown(me.getValue());
-            me.setValue(Ext.Number.constrain(res.up, me.minValue, me.maxValue));
-        }
-    },
+    getSpinValue: function (value, up) {
+        let me = this;
 
-    onSpinDown: function () {
-        var me = this;
-        if (!me.readOnly) {
-            let res = me.computeUpDown(me.getValue());
-            me.setValue(Ext.Number.constrain(res.down, me.minValue, me.maxValue));
+        if (!me.hotplug) {
+            return me.callParent([value, up]);
         }
+
+        let res = me.computeUpDown(value);
+        return up ? res.up : res.down;
     },
 
     initComponent: function () {
@@ -71,17 +62,23 @@ Ext.define('PVE.form.MemoryField', {
 
         if (me.hotplug) {
             me.minValue = 1024;
+        }
 
-            me.on('blur', function (field) {
-                var value = me.getValue();
-                var res = me.computeUpDown(value);
+        me.callParent();
+
+        if (me.hotplug) {
+            // memory hotplug only works in DIMM sized steps
+            me.inputField.on('blur', function () {
+                let value = me.getValue();
+                if (value === null) {
+                    return;
+                }
+                let res = me.computeUpDown(value);
                 if (value === res.start || value === res.up || value === res.down) {
                     return;
                 }
-                field.setValue(res.up);
+                me.setValue(res.up);
             });
         }
-
-        me.callParent();
     },
 });
diff --git a/www/manager6/qemu/MemoryEdit.js b/www/manager6/qemu/MemoryEdit.js
index 0f6bd656..d8c54e08 100644
--- a/www/manager6/qemu/MemoryEdit.js
+++ b/www/manager6/qemu/MemoryEdit.js
@@ -79,22 +79,29 @@ Ext.define('PVE.qemu.MemoryInputPanel', {
             {
                 xtype: 'pveMemoryField',
                 labelWidth: labelWidth,
-                fieldLabel: gettext('Memory') + ' (MiB)',
+                fieldLabel: gettext('Memory'),
+                fieldDescription: gettext('Amount of memory (RAM) of the virtual machine'),
                 name: 'memory',
                 value: '512', // better defaults get set via the view controllers afterrender
                 minValue: 1,
-                step: 32,
                 hotplug: me.hotplug,
                 listeners: {
                     change: function (f, value, old) {
-                        var bf = me.down('field[name=balloon]');
-                        var balloon = bf.getValue();
+                        let bf = me.down('pveMemoryField[name=balloon]');
                         bf.setMaxValue(value);
-                        if (balloon === old) {
+                        // follow memory until the user sets the minimum
+                        if (bf.getValue() === old && !bf.hasUserInput()) {
                             bf.setValue(value);
+                            bf.setUnit(f.getUnit());
                         }
                         bf.validate();
                     },
+                    unitchange: function (f, unit) {
+                        let bf = me.down('pveMemoryField[name=balloon]');
+                        if (bf.getValue() === f.getValue() && !bf.hasUserInput()) {
+                            bf.setUnit(unit);
+                        }
+                    },
                 },
             },
         ];
@@ -106,14 +113,16 @@ Ext.define('PVE.qemu.MemoryInputPanel', {
                 minValue: 1,
                 maxValue: me.insideWizard ? 2048 : 512,
                 value: '512', // better defaults get set (indirectly) via the view controllers afterrender
-                step: 32,
-                fieldLabel: gettext('Minimum memory') + ' (MiB)',
+                fieldLabel: gettext('Minimum memory'),
+                fieldDescription: gettext(
+                    'Minimum amount of memory (RAM) the virtual machine keeps with ballooning',
+                ),
                 hotplug: me.hotplug,
                 labelWidth: labelWidth,
                 allowBlank: false,
                 listeners: {
                     change: function (f, value) {
-                        var memory = me.down('field[name=memory]').getValue();
+                        var memory = me.down('pveMemoryField[name=memory]').getValue();
                         var shares = me.down('field[name=shares]');
                         shares.setDisabled(value === memory);
                     },
@@ -141,9 +150,9 @@ Ext.define('PVE.qemu.MemoryInputPanel', {
                 fieldLabel: gettext('Ballooning Device'),
                 listeners: {
                     change: function (f, value) {
-                        var bf = me.down('field[name=balloon]');
+                        var bf = me.down('pveMemoryField[name=balloon]');
                         var shares = me.down('field[name=shares]');
-                        var memory = me.down('field[name=memory]');
+                        var memory = me.down('pveMemoryField[name=memory]');
                         bf.setDisabled(!value);
                         shares.setDisabled(!value || bf.getValue() === memory.getValue());
                     },
diff --git a/www/manager6/window/GuestImport.js b/www/manager6/window/GuestImport.js
index 1013fe14..0b43ff76 100644
--- a/www/manager6/window/GuestImport.js
+++ b/www/manager6/window/GuestImport.js
@@ -515,7 +515,10 @@ Ext.define('PVE.window.GuestImport', {
                         },
                         {
                             xtype: 'pveMemoryField',
-                            fieldLabel: gettext('Memory') + ' (MiB)',
+                            fieldLabel: gettext('Memory'),
+                            fieldDescription: gettext(
+                                'Amount of memory (RAM) of the virtual machine',
+                            ),
                             name: 'memory',
                             reference: 'memoryField',
                             value: 512,
diff --git a/www/manager6/window/Restore.js b/www/manager6/window/Restore.js
index e8d8de4e..f704fc40 100644
--- a/www/manager6/window/Restore.js
+++ b/www/manager6/window/Restore.js
@@ -359,6 +359,10 @@ Ext.define('PVE.window.Restore', {
                         {
                             xtype: 'pveMemoryField',
                             fieldLabel: gettext('Memory'),
+                            fieldDescription:
+                                me.vmtype === 'lxc'
+                                    ? gettext('Amount of memory (RAM) of the container')
+                                    : gettext('Amount of memory (RAM) of the virtual machine'),
                             name: 'memory',
                             reference: 'memoryField',
                             value: '',
-- 
2.47.3




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

* [RFC PATCH manager 2/4] ui: lxc: allow selecting the unit for memory and swap
  2026-10-26 22:04 [RFC PATCH widget-toolkit/manager 0/7] ui: allow selecting the unit for memory and disk sizes Timo Meschkat
                   ` (3 preceding siblings ...)
  2026-10-26 22:04 ` [RFC PATCH manager 1/4] fix #3760: ui: qemu: allow selecting the memory unit Timo Meschkat
@ 2026-10-26 22:04 ` Timo Meschkat
  2026-10-26 22:04 ` [RFC PATCH manager 3/4] ui: disk storage selector: allow selecting the size unit Timo Meschkat
  2026-10-26 22:04 ` [RFC PATCH manager 4/4] ui: disk resize: " Timo Meschkat
  6 siblings, 0 replies; 8+ messages in thread
From: Timo Meschkat @ 2026-10-26 22:04 UTC (permalink / raw)
  To: pve-devel

Like for VMs, memory and swap of containers could only be entered in
MiB so far. MiB stays the default.

Signed-off-by: Timo Meschkat <timo.meschkat@datenzelt.de>
---
 www/manager6/lxc/ResourceEdit.js | 16 ++++++++++------
 1 file changed, 10 insertions(+), 6 deletions(-)

diff --git a/www/manager6/lxc/ResourceEdit.js b/www/manager6/lxc/ResourceEdit.js
index 5951e122..c0c1a64f 100644
--- a/www/manager6/lxc/ResourceEdit.js
+++ b/www/manager6/lxc/ResourceEdit.js
@@ -134,22 +134,26 @@ Ext.define('PVE.lxc.MemoryInputPanel', {
 
         var items = [
             {
-                xtype: 'proxmoxintegerfield',
+                xtype: 'pmxSizeUnitField',
                 name: 'memory',
+                backendUnit: 'MiB',
+                unitSteps: { MiB: 32 },
                 minValue: 16,
                 value: '512',
-                step: 32,
-                fieldLabel: gettext('Memory') + ' (MiB)',
+                fieldLabel: gettext('Memory'),
+                fieldDescription: gettext('Amount of memory (RAM) of the container'),
                 labelWidth: labelWidth,
                 allowBlank: false,
             },
             {
-                xtype: 'proxmoxintegerfield',
+                xtype: 'pmxSizeUnitField',
                 name: 'swap',
+                backendUnit: 'MiB',
+                unitSteps: { MiB: 32 },
                 minValue: 0,
                 value: '512',
-                step: 32,
-                fieldLabel: gettext('Swap') + ' (MiB)',
+                fieldLabel: gettext('Swap'),
+                fieldDescription: gettext('Amount of swap space of the container'),
                 labelWidth: labelWidth,
                 allowBlank: false,
             },
-- 
2.47.3




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

* [RFC PATCH manager 3/4] ui: disk storage selector: allow selecting the size unit
  2026-10-26 22:04 [RFC PATCH widget-toolkit/manager 0/7] ui: allow selecting the unit for memory and disk sizes Timo Meschkat
                   ` (4 preceding siblings ...)
  2026-10-26 22:04 ` [RFC PATCH manager 2/4] ui: lxc: allow selecting the unit for memory and swap Timo Meschkat
@ 2026-10-26 22:04 ` Timo Meschkat
  2026-10-26 22:04 ` [RFC PATCH manager 4/4] ui: disk resize: " Timo Meschkat
  6 siblings, 0 replies; 8+ messages in thread
From: Timo Meschkat @ 2026-10-26 22:04 UTC (permalink / raw)
  To: pve-devel

The size of new VM disks and container volumes could only be entered
in GiB so far, so small disks needed fractions and large ones a
conversion. GiB stays the default and is still what gets sent to the
API.

The minimum is now 1 MiB instead of 0.001 GiB, so that it is a round
value in the smallest selectable unit.

Signed-off-by: Timo Meschkat <timo.meschkat@datenzelt.de>
---
 www/manager6/form/DiskStorageSelector.js | 10 +++++++---
 1 file changed, 7 insertions(+), 3 deletions(-)

diff --git a/www/manager6/form/DiskStorageSelector.js b/www/manager6/form/DiskStorageSelector.js
index 696f9033..df94c938 100644
--- a/www/manager6/form/DiskStorageSelector.js
+++ b/www/manager6/form/DiskStorageSelector.js
@@ -157,13 +157,17 @@ Ext.define('PVE.form.DiskStorageSelector', {
                 hidden: true,
             },
             {
-                xtype: 'numberfield',
+                xtype: 'pmxSizeUnitField',
                 itemId: 'disksize',
                 name: 'disksize',
-                fieldLabel: `${gettext('Disk size')} (${gettext('GiB')})`,
+                fieldLabel: gettext('Disk size'),
+                fieldDescription: gettext('Capacity of the disk'),
                 hidden: me.hideSize,
                 disabled: me.hideSize,
-                minValue: 0.001,
+                backendUnit: 'GiB',
+                unit: 'GiB',
+                allowFractionalValue: true,
+                minValue: 1 / 1024,
                 maxValue: 128 * 1024,
                 decimalPrecision: 3,
                 value: me.defaultSize,
-- 
2.47.3




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

* [RFC PATCH manager 4/4] ui: disk resize: allow selecting the size unit
  2026-10-26 22:04 [RFC PATCH widget-toolkit/manager 0/7] ui: allow selecting the unit for memory and disk sizes Timo Meschkat
                   ` (5 preceding siblings ...)
  2026-10-26 22:04 ` [RFC PATCH manager 3/4] ui: disk storage selector: allow selecting the size unit Timo Meschkat
@ 2026-10-26 22:04 ` Timo Meschkat
  6 siblings, 0 replies; 8+ messages in thread
From: Timo Meschkat @ 2026-10-26 22:04 UTC (permalink / raw)
  To: pve-devel

Same as when creating disks, the increment could only be entered in
GiB so far. GiB stays the default. The increment is now sent in MiB,
e.g. '+1536M' instead of '+1.5G', which the API handles the same way.

The VM resize window gets wider to fit the unit selector.

Signed-off-by: Timo Meschkat <timo.meschkat@datenzelt.de>
---
 www/manager6/lxc/MPResize.js  | 11 +++++++----
 www/manager6/qemu/HDResize.js | 13 ++++++++-----
 2 files changed, 15 insertions(+), 9 deletions(-)

diff --git a/www/manager6/lxc/MPResize.js b/www/manager6/lxc/MPResize.js
index 658123a1..5a9bb9cb 100644
--- a/www/manager6/lxc/MPResize.js
+++ b/www/manager6/lxc/MPResize.js
@@ -5,7 +5,7 @@ Ext.define('PVE.window.MPResize', {
 
     resize_disk: function (disk, size) {
         var me = this;
-        var params = { disk: disk, size: '+' + size + 'G' };
+        var params = { disk: disk, size: `+${size}M` };
 
         Proxmox.Utils.API2Request({
             params: params,
@@ -46,13 +46,16 @@ Ext.define('PVE.window.MPResize', {
             },
         ];
 
-        me.hdsizesel = Ext.createWidget('numberfield', {
+        me.hdsizesel = Ext.createWidget('pmxSizeUnitField', {
             name: 'size',
+            backendUnit: 'MiB',
+            unit: 'GiB',
             minValue: 0,
-            maxValue: 128 * 1024,
+            maxValue: 128 * 1024 * 1024,
             decimalPrecision: 3,
             value: '0',
-            fieldLabel: `${gettext('Size Increment')} (${gettext('GiB')})`,
+            fieldLabel: gettext('Size Increment'),
+            fieldDescription: gettext('Capacity to add to the disk'),
             allowBlank: false,
         });
 
diff --git a/www/manager6/qemu/HDResize.js b/www/manager6/qemu/HDResize.js
index 574bfb5a..41e7c90e 100644
--- a/www/manager6/qemu/HDResize.js
+++ b/www/manager6/qemu/HDResize.js
@@ -5,7 +5,7 @@ Ext.define('PVE.window.HDResize', {
 
     resize_disk: function (disk, size) {
         var me = this;
-        var params = { disk: disk, size: '+' + size + 'G' };
+        var params = { disk: disk, size: `+${size}M` };
 
         Proxmox.Utils.API2Request({
             params: params,
@@ -47,13 +47,16 @@ Ext.define('PVE.window.HDResize', {
             },
         ];
 
-        me.hdsizesel = Ext.createWidget('numberfield', {
+        me.hdsizesel = Ext.createWidget('pmxSizeUnitField', {
             name: 'size',
+            backendUnit: 'MiB',
+            unit: 'GiB',
             minValue: 0,
-            maxValue: 128 * 1024,
+            maxValue: 128 * 1024 * 1024,
             decimalPrecision: 3,
             value: '0',
-            fieldLabel: `${gettext('Size Increment')} (${gettext('GiB')})`,
+            fieldLabel: gettext('Size Increment'),
+            fieldDescription: gettext('Capacity to add to the disk'),
             allowBlank: false,
         });
 
@@ -86,7 +89,7 @@ Ext.define('PVE.window.HDResize', {
 
         Ext.apply(me, {
             modal: true,
-            width: 250,
+            width: 350,
             height: 150,
             border: false,
             layout: 'fit',
-- 
2.47.3




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

end of thread, other threads:[~2026-10-26 22:05 UTC | newest]

Thread overview: 8+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-10-26 22:04 [RFC PATCH widget-toolkit/manager 0/7] ui: allow selecting the unit for memory and disk sizes Timo Meschkat
2026-10-26 22:04 ` [RFC PATCH widget-toolkit 1/3] form: add size field with selectable unit Timo Meschkat
2026-10-26 22:04 ` [RFC PATCH widget-toolkit 2/3] form: size field: accept a unit suffix like 8G Timo Meschkat
2026-10-26 22:04 ` [RFC PATCH widget-toolkit 3/3] form: size field: add optional field description Timo Meschkat
2026-10-26 22:04 ` [RFC PATCH manager 1/4] fix #3760: ui: qemu: allow selecting the memory unit Timo Meschkat
2026-10-26 22:04 ` [RFC PATCH manager 2/4] ui: lxc: allow selecting the unit for memory and swap Timo Meschkat
2026-10-26 22:04 ` [RFC PATCH manager 3/4] ui: disk storage selector: allow selecting the size unit Timo Meschkat
2026-10-26 22:04 ` [RFC PATCH manager 4/4] ui: disk resize: " Timo Meschkat

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