Compare commits

..
10 Commits
Author SHA1 Message Date
dualfroz a13dd77d42 Describe this repository 2026-10-22 21:06:04 +02:00
dualfroz 3d92bbd515 utils: safe id regex: do not allow backslashes
The comment says the regex is the same as SAFE_ID_REGEX in
proxmox-schema, but the character class has an additional backslash.
It was there since the regex was added, as '\\-' in a regex literal
matches a backslash and a hyphen, and the reordering for the linter
kept it.

So the UI accepts a webhook endpoint name like 'a\b', which the API
then rejects as it does not match the schema. Drop the backslash, so
that the field shows the name as invalid right away.

Signed-off-by: Michal Fox <me@dualfroz.com>
2026-10-03 02:58:10 +02:00
upstream 7454b8fd4d Upstream proxmox-git/proxmox-widget-toolkit state of the touched files before 5b1db972a7 2026-10-03 02:58:10 +02:00
dualfroz 0055e9b2c4 fix #7586: notification: check names of matchers and targets in the UI
The names of notification matchers and targets have to be safe IDs,
but only the name of webhook targets was checked in the UI, and that
without saying what is wrong. For all others, an invalid name, for
example one containing a space, was only rejected by the API with a
'parameter verification errors (400)' message, without saying which
characters are allowed.

Add a 'SafeId' vtype, which uses the existing safe ID regex and says
which characters are allowed, and use it for the names of matchers and
all target types.

Signed-off-by: Michal Fox <me@dualfroz.com>
2026-10-03 01:29:24 +02:00
upstream 060e0c6fa9 Upstream proxmox-git/proxmox-widget-toolkit state of the touched files before ef098ff9ef 2026-10-03 01:29:24 +02:00
dualfroz 9b1e7d66ce acme: domain edit: allow setting the alias of DNS domains
Domains validated with the DNS challenge can have an alias, so that the
challenge record is created on another domain, which the
'_acme-challenge' record of the domain points to with a CNAME.

But the alias could only be set on the CLI, as the edit window only
kept an existing one in a hidden field. Show it as a text field for
domains with the DNS challenge type, like the plugin.

As a side effect, an existing alias is not kept anymore when switching
a domain to the HTTP challenge type with separate domain entries, like
it is done in Proxmox Mail Gateway, where it has no meaning.

Signed-off-by: Michal Fox <me@dualfroz.com>
2026-10-03 01:25:27 +02:00
upstream 0471e06161 Upstream proxmox-git/proxmox-widget-toolkit state of the touched files before efab443387 2026-10-03 01:25:27 +02:00
dualfroz 7823b35e66 fix #6109: node network view: fix sorting by active state
The API only sets 'active' for active interfaces, so it is either 1 or
missing. As 1 compares neither greater nor less than undefined, sorting
by the 'Active' column kept the previous order. The same applies to the
'Autostart' and 'VLAN aware' columns.

Declare those fields as booleans, so that missing values get converted
to false and the interfaces can be sorted by them. The shown values do
not change, as missing values were already rendered as 'No'.

Signed-off-by: Michal Fox <me@dualfroz.com>
2026-10-02 16:50:56 +02:00
dualfroz ffb09fb652 fix #5772: node network view: sort names and ports naturally
The interface list was sorted by comparing the names as plain strings,
so 'eno10' and 'eno11' came before 'eno2', and 'vmbr10' before 'vmbr2'.

Sort by name with a comparison that orders numbers in the names by
their value instead. Use the same sorter for the initial order of the
store and for the name column, so that the column header still shows
the initial sort order and the first click on it reverses it, like
before.

The 'Ports/Slaves' column uses the type as data index, so sorting by it
just sorted by type. Sort it by the shown ports or slaves instead, also
in natural order.

Signed-off-by: Michal Fox <me@dualfroz.com>
2026-10-02 16:50:20 +02:00
upstream 2d3dfd3278 Upstream proxmox-git/proxmox-widget-toolkit state of the touched files before 1539ede84b 2026-10-02 16:50:20 +02:00
10 changed files with 5643 additions and 0 deletions

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
# Contributions to proxmox-git/proxmox-widget-toolkit
This repository holds only my commits to the upstream project, with their original author, date and diff.
Each of my commits is preceded by a snapshot of the upstream files it touched, so every diff shows exactly what I changed.
+930
View File
@@ -0,0 +1,930 @@
// ExtJS related things
// do not send '_dc' parameter
Ext.Ajax.disableCaching = false;
// custom Vtypes
Ext.apply(Ext.form.field.VTypes, {
IPAddress: function (v) {
return Proxmox.Utils.IP4_match.test(v);
},
IPAddressText: gettext('Example') + ': 192.168.1.1',
IPAddressMask: /[\d.]/i,
IPCIDRAddress: function (v) {
let result = Proxmox.Utils.IP4_cidr_match.exec(v);
// limits according to JSON Schema see
// pve-common/src/PVE/JSONSchema.pm
return result !== null && result[1] >= 8 && result[1] <= 32;
},
IPCIDRAddressText:
gettext('Example') + ': 192.168.1.1/24<br>' + gettext('Valid CIDR Range') + ': 8-32',
IPCIDRAddressMask: /[\d./]/i,
IP6Address: function (v) {
return Proxmox.Utils.IP6_match.test(v);
},
IP6AddressText: gettext('Example') + ': 2001:DB8::42',
IP6AddressMask: /[A-Fa-f0-9:]/,
IP6CIDRAddress: function (v) {
let result = Proxmox.Utils.IP6_cidr_match.exec(v);
// limits according to JSON Schema see
// pve-common/src/PVE/JSONSchema.pm
return result !== null && result[1] >= 8 && result[1] <= 128;
},
IP6CIDRAddressText:
gettext('Example') + ': 2001:DB8::42/64<br>' + gettext('Valid CIDR Range') + ': 8-128',
IP6CIDRAddressMask: /[A-Fa-f0-9:/]/,
IP6PrefixLength: function (v) {
return v >= 0 && v <= 128;
},
IP6PrefixLengthText: gettext('Example') + ': X, where 0 <= X <= 128',
IP6PrefixLengthMask: /[0-9]/,
IP64Address: function (v) {
return Proxmox.Utils.IP64_match.test(v);
},
IP64AddressText: gettext('Example') + ': 192.168.1.1 2001:DB8::42',
IP64AddressMask: /[A-Fa-f0-9.:]/,
IP64CIDRAddress: function (v) {
let result = Proxmox.Utils.IP64_cidr_match.exec(v);
if (result === null) {
return false;
}
if (result[1] !== undefined) {
return result[1] >= 8 && result[1] <= 128;
} else if (result[2] !== undefined) {
return result[2] >= 8 && result[2] <= 32;
} else {
return false;
}
},
IP64CIDRAddressText: gettext('Example') + ': 192.168.1.1/24 2001:DB8::42/64',
IP64CIDRAddressMask: /[A-Fa-f0-9.:/]/,
MacAddress: function (v) {
return /^([a-fA-F0-9]{2}:){5}[a-fA-F0-9]{2}$/.test(v);
},
MacAddressMask: /[a-fA-F0-9:]/,
MacAddressText: gettext('Example') + ': 01:23:45:67:89:ab',
MacPrefix: function (v) {
return /^[a-f0-9][02468ace](?::[a-f0-9]{2}){0,2}:?$/i.test(v);
},
MacPrefixMask: /[a-fA-F0-9:]/,
MacPrefixText:
gettext('Example') + ': 02:8f - ' + gettext('only unicast addresses are allowed'),
BridgeName: function (v) {
return /^[a-zA-Z][a-zA-Z0-9_]{0,9}$/.test(v);
},
VlanName: function (v) {
if (Proxmox.Utils.VlanInterface_match.test(v)) {
return true;
} else if (Proxmox.Utils.Vlan_match.test(v)) {
return true;
}
return true;
},
BridgeNameText: gettext('Format') + ': alphanumeric string starting with a character',
BondName: function (v) {
return /^bond\d{1,4}$/.test(v);
},
BondNameText: gettext('Format') + ': bond<b>N</b>, where 0 <= <b>N</b> <= 9999',
InterfaceName: function (v) {
return /^[a-z][a-z0-9_]{1,20}$/.test(v);
},
InterfaceNameText:
gettext('Allowed characters') +
": 'a-z', '0-9', '_'<br />" +
gettext('Minimum characters') +
': 2<br />' +
gettext('Maximum characters') +
': 21<br />' +
gettext('Must start with') +
": 'a-z'",
StorageId: function (v) {
return /^[a-z][a-z0-9\-_.]*[a-z0-9]$/i.test(v);
},
StorageIdText:
gettext('Allowed characters') +
": 'A-Z', 'a-z', '0-9', '-', '_', '.'<br />" +
gettext('Minimum characters') +
': 2<br />' +
gettext('Must start with') +
": 'A-Z', 'a-z'<br />" +
gettext('Must end with') +
": 'A-Z', 'a-z', '0-9'<br />",
ConfigId: function (v) {
return /^[a-z][a-z0-9_-]+$/i.test(v);
},
ConfigIdText:
gettext('Allowed characters') +
": 'A-Z', 'a-z', '0-9', '_', '-'<br />" +
gettext('Minimum characters') +
': 2<br />' +
gettext('Must start with') +
': ' +
gettext('letter'),
SafeId: function (v) {
return Proxmox.Utils.safeIdRegex.test(v);
},
SafeIdText:
gettext('Allowed characters') +
": 'A-Z', 'a-z', '0-9', '_', '-', '.'<br />" +
gettext('Must start with') +
": 'A-Z', 'a-z', '0-9', '_'",
HttpProxy: function (v) {
return /^http:\/\/.*$/.test(v);
},
HttpProxyText: gettext('Example') + ': http://username:password&#64;host:port/',
CpuSet: function (v) {
return Proxmox.Utils.CpuSet_match.test(v);
},
CpuSetText: gettext('This is not a valid CpuSet'),
DnsName: function (v) {
return Proxmox.Utils.DnsName_match.test(v);
},
DnsNameText: gettext('This is not a valid hostname'),
DnsNameOrWildcard: function (v) {
return Proxmox.Utils.DnsName_or_Wildcard_match.test(v);
},
DnsNameOrWildcardText: gettext('This is not a valid hostname'),
// email regex used by pve-common
proxmoxMail: function (v) {
return /^[\w+-~]+(\.[\w+-~]+)*@[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*$/.test(v);
},
proxmoxMailText: gettext('Example') + ': user@example.com',
DnsOrIp: function (v) {
if (!Proxmox.Utils.DnsName_match.test(v) && !Proxmox.Utils.IP64_match.test(v)) {
return false;
}
return true;
},
DnsOrIpText: gettext('Not a valid DNS name or IP address.'),
HostPort: function (v) {
return (
Proxmox.Utils.HostPort_match.test(v) ||
Proxmox.Utils.HostPortBrackets_match.test(v) ||
Proxmox.Utils.IP6_dotnotation_match.test(v)
);
},
HostPortText: gettext('Host/IP address or optional port is invalid'),
HostList: function (v) {
let list = v.split(/[ ,;]+/);
let i;
for (i = 0; i < list.length; i++) {
if (list[i] === '') {
continue;
}
if (
!Proxmox.Utils.HostPort_match.test(list[i]) &&
!Proxmox.Utils.HostPortBrackets_match.test(list[i]) &&
!Proxmox.Utils.IP6_dotnotation_match.test(list[i])
) {
return false;
}
}
return true;
},
HostListText: gettext('Not a valid list of hosts'),
password: function (val, field) {
if (field.initialPassField) {
let pwd = field.up('form').down(`[name=${field.initialPassField}]`);
return val === pwd.getValue();
}
return true;
},
passwordText: gettext('Passwords do not match'),
email: function (value) {
let emailre = /^[\w+~-]+(\.[\w+~-]+)*@[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*$/;
return emailre.test(value);
},
});
// we always want the number in x.y format and never in, e.g., x,y
Ext.define('PVE.form.field.Number', {
override: 'Ext.form.field.Number',
submitLocaleSeparator: false,
});
// avois spamming the console and if we ever use this avoid a CORS block error too
Ext.define('PVE.draw.Container', {
override: 'Ext.draw.Container',
defaultDownloadServerUrl: document.location.origin, // avoid that pointing to http://svg.sencha.io
applyDownloadServerUrl: function (url) {
// avoid noisy warning, we don't really use that anyway
url = url || this.defaultDownloadServerUrl;
return url;
},
});
// ExtJs 5-6 has an issue with caching
// see https://www.sencha.com/forum/showthread.php?308989
Ext.define('Proxmox.UnderlayPool', {
override: 'Ext.dom.UnderlayPool',
checkOut: function () {
let cache = this.cache,
len = cache.length,
el;
// do cleanup because some of the objects might have been destroyed
while (len--) {
if (cache[len].destroyed) {
cache.splice(len, 1);
}
}
// end do cleanup
el = cache.shift();
if (!el) {
el = Ext.Element.create(this.elementConfig);
el.setVisibilityMode(2);
//<debug>
// tell the spec runner to ignore this element when checking if the dom is clean
el.dom.setAttribute('data-sticky', true);
//</debug>
}
return el;
},
});
// if the order of the values are not the same in originalValue and value
// extjs will not overwrite value, but marks the field dirty and thus
// the reset button will be enabled (but clicking it changes nothing)
// so if the arrays are not the same after resetting, we
// clear and set it
Ext.define('Proxmox.form.ComboBox', {
override: 'Ext.form.field.ComboBox',
reset: function () {
// copied from combobox
let me = this;
me.callParent();
// clear and set when not the same
let value = me.getValue();
if (
Ext.isArray(me.originalValue) &&
Ext.isArray(value) &&
!Ext.Array.equals(value, me.originalValue)
) {
me.clearValue();
me.setValue(me.originalValue);
}
},
// we also want to open the trigger on editable comboboxes by default
initComponent: function () {
let me = this;
me.callParent();
if (me.editable) {
// The trigger.picker causes first a focus event on the field then
// toggles the selection picker. Thus skip expanding in this case,
// else our focus listener expands and the picker.trigger then
// collapses it directly afterwards.
Ext.override(me.triggers.picker, {
onMouseDown: function (e) {
// copied "should we focus" check from Ext.form.trigger.Trigger
if (
e.pointerType !== 'touch' &&
!this.field.owns(Ext.Element.getActiveElement())
) {
me.skip_expand_on_focus = true;
}
this.callParent(arguments);
},
});
me.on('focus', function (combobox) {
if (!combobox.isExpanded && !combobox.skip_expand_on_focus) {
combobox.expand();
}
combobox.skip_expand_on_focus = false;
});
}
},
});
// when refreshing a grid/tree view, restoring the focus moves the view back to
// the previously focused item. Save scroll position before refocusing.
Ext.define(null, {
override: 'Ext.view.Table',
jumpToFocus: false,
saveFocusState: function () {
var me = this,
store = me.dataSource,
actionableMode = me.actionableMode,
navModel = me.getNavigationModel(),
focusPosition = actionableMode ? me.actionPosition : navModel.getPosition(true),
activeElement = Ext.fly(Ext.Element.getActiveElement()),
focusCell =
focusPosition && focusPosition.view === me && Ext.fly(focusPosition.getCell(true)),
refocusRow,
refocusCol,
record;
// The navModel may return a position that is in a locked partner, so check that
// the focusPosition's cell contains the focus before going forward.
// The skipSaveFocusState is set by Actionables which actively control
// focus destination. See CellEditing#activateCell.
if (!me.skipSaveFocusState && focusCell && focusCell.contains(activeElement)) {
// Separate this from the instance that the nav model is using.
focusPosition = focusPosition.clone();
// While we deactivate the focused element, suspend focus processing on it.
activeElement.suspendFocusEvents();
// Suspend actionable mode.
// Each Actionable must silently save its state ready to resume when focus
// can be restored but should only do that if the activeElement is not the cell itself,
// this happens when the grid is refreshed while one of the actionables is being
// deactivated (e.g. Calling view refresh inside CellEditor 'edit' event listener).
if (actionableMode && focusCell.dom !== activeElement.dom) {
me.suspendActionableMode();
} else {
// Clear position, otherwise the setPosition on the other side
// will be rejected as a no-op if the resumption position is logically
// equivalent.
actionableMode = false;
navModel.setPosition();
}
// Do not leave the element in that state in case refresh fails, and restoration
// closure not called.
activeElement.resumeFocusEvents();
// if the store is expanding or collapsing, we should never scroll the view.
if (store.isExpandingOrCollapsing) {
return Ext.emptyFn;
}
// The following function will attempt to refocus back in the same mode to the same cell
// as it was at before based upon the previous record (if it's still in the store),
// or the row index.
return function () {
var all;
// May have changed due to reconfigure
store = me.dataSource;
// If we still have data, attempt to refocus in the same mode.
if (store.getCount()) {
all = me.all;
// Adjust expectations of where we are able to refocus according to
// what kind of destruction might have been wrought on this view's DOM
// during focus save.
refocusRow = Math.min(
Math.max(focusPosition.rowIdx, all.startIndex),
all.endIndex,
);
refocusCol = Math.min(
focusPosition.colIdx,
me.getVisibleColumnManager().getColumns().length - 1,
);
record = focusPosition.record;
focusPosition = new Ext.grid.CellContext(me).setPosition(
record && store.contains(record) && !record.isCollapsedPlaceholder
? record
: refocusRow,
refocusCol,
);
// Maybe there are no cells. eg: all groups collapsed.
if (focusPosition.getCell(true)) {
if (actionableMode) {
me.resumeActionableMode(focusPosition);
} else {
// we sometimes want to scroll back to where we are
let x = me.getScrollX();
let y = me.getScrollY();
// Pass "preventNavigation" as true
// so that that does not cause selection.
navModel.setPosition(focusPosition, null, null, null, true);
if (!navModel.getPosition()) {
focusPosition.column.focus();
}
if (!me.jumpToFocus) {
me.scrollTo(x, y);
}
}
}
} else {
// No rows - focus associated column header
focusPosition.column.focus();
}
};
}
return Ext.emptyFn;
},
});
// ExtJS 6.0.1 has no setSubmitValue() (although you find it in the docs).
// Note: this.submitValue is a boolean flag, whereas getSubmitValue() returns
// data to be submitted.
Ext.define('Proxmox.form.field.Text', {
override: 'Ext.form.field.Text',
setSubmitValue: function (v) {
this.submitValue = v;
},
});
// make mousescrolling work in firefox in the containers overflowhandler,
// by using only the 'wheel' event not 'mousewheel'(fixed in 7.3)
// also reverse the scrolldirection (fixed in 7.3)
// and reduce the default increment
Ext.define(null, {
override: 'Ext.layout.container.boxOverflow.Scroller',
wheelIncrement: 1,
getWheelDelta: function (e) {
return -e.getWheelDelta(e);
},
onOwnerRender: function (owner) {
var me = this,
scrollable = {
isBoxOverflowScroller: true,
x: false,
y: false,
listeners: {
scrollend: this.onScrollEnd,
scope: this,
},
};
// If no obstrusive scrollbars, allow natural scrolling on mobile touch devices
if (!Ext.scrollbar.width() && !Ext.platformTags.desktop) {
scrollable[owner.layout.horizontal ? 'x' : 'y'] = true;
} else {
me.wheelListener = me.layout.innerCt.on('wheel', me.onMouseWheel, me, {
destroyable: true,
});
}
owner.setScrollable(scrollable);
},
});
// extj 6.7 reversed mousewheel direction... (fixed in 7.3)
// https://forum.sencha.com/forum/showthread.php?472517-Mousewheel-scroll-direction-in-numberfield-with-spinners
// also use the 'wheel' event instead of 'mousewheel' (fixed in 7.3)
Ext.define('Proxmox.form.field.Spinner', {
override: 'Ext.form.field.Spinner',
onRender: function () {
let me = this;
me.callParent();
// Init mouse wheel
if (me.mouseWheelEnabled) {
// Unlisten Ext generated listener ('mousewheel' is deprecated anyway)
me.mun(me.bodyEl, 'mousewheel', me.onMouseWheel, me);
me.mon(me.bodyEl, 'wheel', me.onMouseWheel, me);
}
},
onMouseWheel: function (e) {
var me = this,
delta;
if (me.hasFocus) {
delta = e.getWheelDelta();
if (delta > 0) {
me.spinDown();
} else if (delta < 0) {
me.spinUp();
}
e.stopEvent();
me.onSpinEnd();
}
},
});
// add '@' to the valid id
Ext.define('Proxmox.validIdReOverride', {
override: 'Ext.Component',
validIdRe: /^[a-z_][a-z0-9\-_@]*$/i,
});
Ext.define('Proxmox.selection.CheckboxModel', {
override: 'Ext.selection.CheckboxModel',
// [P] use whole checkbox cell to multiselect, not only the checkbox
checkSelector: '.x-grid-cell-row-checker',
// TODO: remove all optimizations below to an override for parent 'Ext.selection.Model' ??
// [ P: optimized to remove all records at once as single remove is O(n^3) slow ]
// records can be an index, a record or an array of records
doDeselect: function (records, suppressEvent) {
var me = this,
selected = me.selected,
i = 0,
len,
record,
commit;
if (me.locked || !me.store) {
return false;
}
if (typeof records === 'number') {
// No matching record, jump out
record = me.store.getAt(records);
if (!record) {
return false;
}
records = [record];
} else if (!Ext.isArray(records)) {
records = [records];
}
// [P] a beforedeselection, triggered by me.onSelectChange below, can block removal by
// returning false, thus the original implementation removed only here in the commit fn,
// which has an abysmal performance O(n^3). As blocking removal is not the norm, go do the
// reverse, record blocked records and remove them from the to-be-removed array before
// applying it. A FF86 i9-9900K on 10k records goes from >40s to ~33ms for >90% deselection
let committed = false;
commit = function () {
committed = true;
if (record === me.selectionStart) {
me.selectionStart = null;
}
};
let removalBlocked = [];
len = records.length;
me.suspendChanges();
for (; i < len; i++) {
record = records[i];
if (me.isSelected(record)) {
committed = false;
me.onSelectChange(record, false, suppressEvent, commit);
if (!committed) {
removalBlocked.push(record);
}
if (me.destroyed) {
return false;
}
}
}
if (removalBlocked.length > 0) {
records.remove(removalBlocked);
}
selected.remove(records); // [P] FAST(er)
me.lastSelected = selected.last();
me.resumeChanges();
// fire selchange if there was a change and there is no suppressEvent flag
me.maybeFireSelectionChange(records.length > 0 && !suppressEvent);
return records.length;
},
doMultiSelect: function (records, keepExisting, suppressEvent) {
var me = this,
selected = me.selected,
change = false,
result,
i,
len,
record,
commit;
if (me.locked) {
return;
}
records = !Ext.isArray(records) ? [records] : records;
len = records.length;
if (!keepExisting && selected.getCount() > 0) {
result = me.deselectDuringSelect(records, suppressEvent);
if (me.destroyed) {
return;
}
if (result[0]) {
// We had a failure during selection, so jump out
// Fire selection change if we did deselect anything
me.maybeFireSelectionChange(result[1] > 0 && !suppressEvent);
return;
} else {
// Means something has been deselected, so we've had a change
change = result[1] > 0;
}
}
let gotBlocked,
blockedRecords = [];
commit = function () {
if (!selected.getCount()) {
me.selectionStart = record;
}
gotBlocked = false;
change = true;
};
for (i = 0; i < len; i++) {
record = records[i];
if (me.isSelected(record)) {
continue;
}
gotBlocked = true;
me.onSelectChange(record, true, suppressEvent, commit);
if (me.destroyed) {
return;
}
if (gotBlocked) {
blockedRecords.push(record);
}
}
if (blockedRecords.length > 0) {
records.remove(blockedRecords);
}
selected.add(records);
me.lastSelected = record;
// fire selchange if there was a change and there is no suppressEvent flag
me.maybeFireSelectionChange(change && !suppressEvent);
},
deselectDuringSelect: function (toSelect, suppressEvent) {
var me = this,
selected = me.selected.getRange(),
changed = 0,
failed = false;
// Prevent selection change events from firing, will happen during select
me.suspendChanges();
me.deselectingDuringSelect = true;
let toDeselect = selected.filter((item) => !Ext.Array.contains(toSelect, item));
if (toDeselect.length > 0) {
changed = me.doDeselect(toDeselect, suppressEvent);
if (!changed) {
failed = true;
}
if (me.destroyed) {
failed = true;
changed = 0;
}
}
me.deselectingDuringSelect = false;
me.resumeChanges();
return [failed, changed];
},
});
// stop nulling of properties
Ext.define('Proxmox.Component', {
override: 'Ext.Component',
clearPropertiesOnDestroy: false,
});
// Fix drag&drop for vms and desktops that detect 'pen' pointerType
// NOTE: this part has been rewritten in ExtJS 7.4, so re-check once we can upgrade
Ext.define('Proxmox.view.DragZone', {
override: 'Ext.view.DragZone',
onItemMouseDown: function (view, record, item, index, e) {
// Ignore touchstart.
// For touch events, we use longpress.
if (e.pointerType !== 'touch') {
this.onTriggerGesture(view, record, item, index, e);
}
},
});
// Fix text selection on drag when using DragZone,
// see https://forum.sencha.com/forum/showthread.php?335100
Ext.define('Proxmox.dd.DragDropManager', {
override: 'Ext.dd.DragDropManager',
stopEvent: function (e) {
if (this.stopPropagation) {
e.stopPropagation();
}
if (this.preventDefault) {
e.preventDefault();
}
},
});
// make it possible to set the SameSite attribute on cookies
Ext.define('Proxmox.Cookies', {
override: 'Ext.util.Cookies',
set: function (name, value, expires, path, domain, secure, samesite) {
let attrs = [];
if (expires) {
attrs.push('expires=' + expires.toUTCString());
}
if (path === undefined) {
// mimic original function's behaviour
attrs.push('path=/');
} else if (path) {
attrs.push('path=' + path);
}
if (domain) {
attrs.push('domain=' + domain);
}
if (secure === true) {
attrs.push('secure');
}
if (samesite && ['lax', 'none', 'strict'].includes(samesite.toLowerCase())) {
attrs.push('samesite=' + samesite);
}
document.cookie = name + '=' + escape(value) + '; ' + attrs.join('; ');
},
});
// Prevents html entities to be interpreted by the browser in grid columns by
// default. Custom renderer have to take care of this by themselves.
//
// Reset the renderer for the 'check', 'boolean', 'number' and 'date' columns
// back to undefined, since they rely on the renderer being undefined or
// returning raw HTML.
Ext.define('Proxmox.grid.column.Column', {
override: 'Ext.grid.column.Column',
renderer: Ext.htmlEncode,
});
Ext.define('Proxmox.grid.column.Check', {
override: 'Ext.grid.column.Check',
renderer: undefined,
});
Ext.define('Proxmox.grid.column.Boolean', {
override: 'Ext.grid.column.Boolean',
renderer: undefined,
});
Ext.define('Proxmox.grid.column.Number', {
override: 'Ext.grid.column.Number',
renderer: undefined,
});
Ext.define('Proxmox.grid.column.Date', {
override: 'Ext.grid.column.Date',
renderer: undefined,
});
// force alert boxes to be rendered with an Error Icon
// since Ext.Msg is an object and not a prototype, we need to override it
// after the framework has been initiated
Ext.onReady(function () {
Ext.override(Ext.Msg, {
alert: function (title, message, fn, scope) {
if (Ext.isString(title)) {
let config = {
title: title,
message: message,
icon: this.ERROR,
buttons: this.OK,
fn: fn,
scope: scope,
minWidth: this.minWidth,
};
return this.show(config);
}
},
});
});
// add allowfullscreen to render template to allow the noVNC/xterm.js embedded UIs to go fullscreen
//
// The rest is the same as in the separate ux package (extjs/build/packages/ux/classic/ux-debug.js),
// which we do not load as it's rather big and most of the widgets there are not useful for our UIs
Ext.define('Ext.ux.IFrame', {
extend: 'Ext.Component',
alias: 'widget.uxiframe',
loadMask: 'Loading...',
src: 'about:blank',
renderTpl: [
'<iframe src="{src}" id="{id}-iframeEl" data-ref="iframeEl" name="{frameName}" width="100%" height="100%" frameborder="0" allowfullscreen="true"></iframe>',
],
childEls: ['iframeEl'],
initComponent: function () {
this.callParent();
this.frameName = this.frameName || this.id + '-frame';
},
initEvents: function () {
let me = this;
me.callParent();
me.iframeEl.on('load', me.onLoad, me);
},
initRenderData: function () {
return Ext.apply(this.callParent(), {
src: this.src,
frameName: this.frameName,
});
},
getBody: function () {
let doc = this.getDoc();
return doc.body || doc.documentElement;
},
getDoc: function () {
try {
return this.getWin().document;
} catch (_ex) {
return null;
}
},
getWin: function () {
let me = this,
name = me.frameName,
win = Ext.isIE ? me.iframeEl.dom.contentWindow : window.frames[name];
return win;
},
getFrame: function () {
let me = this;
return me.iframeEl.dom;
},
onLoad: function () {
let me = this,
doc = me.getDoc();
if (doc) {
this.el.unmask();
this.fireEvent('load', this);
} else if (me.src) {
this.el.unmask();
this.fireEvent('error', this);
}
},
load: function (src) {
let me = this,
text = me.loadMask,
frame = me.getFrame();
if (me.fireEvent('beforeload', me, src) !== false) {
if (text && me.el) {
me.el.mask(text);
}
frame.src = me.src = src || me.src;
}
},
});
+1795
View File
File diff suppressed because it is too large. Load diff
+460
View File
@@ -0,0 +1,460 @@
Ext.define('proxmox-networks', {
extend: 'Ext.data.Model',
fields: [
{ name: 'active', type: 'boolean' },
'address',
'address6',
'altnames',
{ name: 'autostart', type: 'boolean' },
'bridge_ports',
{ name: 'bridge_vlan_aware', type: 'boolean' },
'cidr',
'cidr6',
'comments',
'gateway',
'gateway6',
'iface',
'netmask',
'netmask6',
'slaves',
'type',
'vlan-id',
'vlan-raw-device',
],
idProperty: 'iface',
});
Ext.define('Proxmox.node.NetworkView', {
extend: 'Ext.panel.Panel',
alias: ['widget.proxmoxNodeNetworkView'],
// defines what types of network devices we want to create
// order is always the same
types: ['bridge', 'bond', 'vlan', 'ovs'],
showApplyBtn: false,
// if true, the altnames column will be shown by default
showAltNames: false,
// for options passed down to the network edit window
editOptions: {},
initComponent: function () {
let me = this;
if (!me.nodename) {
throw 'no node name specified';
}
let baseUrl = `/nodes/${me.nodename}/network`;
// sort numbers in names by value, so that 'eno2' comes before 'eno10'
let naturalCompare = (a, b) =>
(a ?? '').localeCompare(b ?? '', undefined, { numeric: true });
let getPortsOrSlaves = (data) => {
if (data.type === 'bridge') {
return data.bridge_ports;
} else if (data.type === 'bond') {
return data.slaves;
} else if (data.type === 'OVSBridge') {
return data.ovs_ports;
} else if (data.type === 'OVSBond') {
return data.ovs_bonds;
}
return '';
};
// shared with the name column, so that its header shows the initial sort order
let ifaceSorter = Ext.create('Ext.util.Sorter', {
id: 'iface',
sorterFn: (a, b) => naturalCompare(a.data.iface, b.data.iface),
});
let store = Ext.create('Ext.data.Store', {
model: 'proxmox-networks',
proxy: {
type: 'proxmox',
url: '/api2/json' + baseUrl,
},
sorters: [ifaceSorter],
});
let reload = function () {
let changeitem = me.down('#changes');
let apply_btn = me.down('#apply');
let revert_btn = me.down('#revert');
Proxmox.Utils.API2Request({
url: baseUrl,
failure: function (response, opts) {
store.loadData({});
Proxmox.Utils.setErrorMask(me, response.htmlStatus);
changeitem.update('');
changeitem.setHidden(true);
},
success: function (response, opts) {
let result = Ext.decode(response.responseText);
store.loadData(result.data);
let changes = result.changes;
if (changes === undefined || changes === '') {
changes = gettext('No changes');
changeitem.setHidden(true);
apply_btn.setDisabled(true);
revert_btn.setDisabled(true);
} else {
changeitem.update('<pre>' + Ext.htmlEncode(changes) + '</pre>');
changeitem.setHidden(false);
apply_btn.setDisabled(false);
revert_btn.setDisabled(false);
}
},
});
};
let run_editor = function () {
let grid = me.down('gridpanel');
let sm = grid.getSelectionModel();
let rec = sm.getSelection()[0];
if (!rec) {
return;
}
Ext.create('Proxmox.node.NetworkEdit', {
autoShow: true,
nodename: me.nodename,
iface: rec.data.iface,
iftype: rec.data.type,
showAltNames: me.showAltNames,
...me.editOptions,
listeners: {
destroy: () => reload(),
},
});
};
let edit_btn = new Ext.Button({
text: gettext('Edit'),
disabled: true,
handler: run_editor,
});
let sm = Ext.create('Ext.selection.RowModel', {});
let del_btn = new Proxmox.button.StdRemoveButton({
selModel: sm,
getUrl: ({ data }) => `${baseUrl}/${data.iface}`,
callback: () => reload(),
});
let apply_btn = Ext.create('Proxmox.button.Button', {
text: gettext('Apply Configuration'),
itemId: 'apply',
disabled: true,
confirmMsg: 'Do you want to apply pending network changes?',
hidden: !me.showApplyBtn,
handler: function () {
Proxmox.Utils.API2Request({
url: baseUrl,
method: 'PUT',
waitMsgTarget: me,
success: function ({ result }, opts) {
Ext.create('Proxmox.window.TaskProgress', {
autoShow: true,
taskDone: reload,
upid: result.data,
});
},
failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
});
},
});
let set_button_status = function () {
let rec = sm.getSelection()[0];
edit_btn.setDisabled(!rec);
del_btn.setDisabled(!rec);
};
let findNextFreeInterfaceId = function (prefix) {
for (let next = 0; next <= 9999; next++) {
let id = `${prefix}${next.toString()}`;
if (!store.getById(id)) {
return id;
}
}
Ext.Msg.alert('Error', `No free ID for ${prefix} found!`);
return '';
};
let menu_items = [];
let addEditWindowToMenu = (iType, iDefault) => {
menu_items.push({
text: Proxmox.Utils.render_network_iface_type(iType),
handler: () =>
Ext.create('Proxmox.node.NetworkEdit', {
autoShow: true,
nodename: me.nodename,
iftype: iType,
iface_default: findNextFreeInterfaceId(iDefault ?? iType),
...me.editOptions,
onlineHelp: 'sysadmin_network_configuration',
listeners: {
destroy: () => reload(),
},
}),
});
};
if (me.types.indexOf('bridge') !== -1) {
addEditWindowToMenu('bridge', 'vmbr');
}
if (me.types.indexOf('bond') !== -1) {
addEditWindowToMenu('bond');
}
if (me.types.indexOf('vlan') !== -1) {
addEditWindowToMenu('vlan');
}
if (me.types.indexOf('ovs') !== -1) {
if (menu_items.length > 0) {
menu_items.push({ xtype: 'menuseparator' });
}
addEditWindowToMenu('OVSBridge', 'vmbr');
addEditWindowToMenu('OVSBond', 'bond');
menu_items.push({
text: Proxmox.Utils.render_network_iface_type('OVSIntPort'),
handler: () =>
Ext.create('Proxmox.node.NetworkEdit', {
autoShow: true,
nodename: me.nodename,
iftype: 'OVSIntPort',
listeners: {
destroy: () => reload(),
},
}),
});
}
let renderer_generator = function (fieldname) {
return function (val, metaData, rec) {
let tmp = [];
if (rec.data[fieldname]) {
tmp.push(rec.data[fieldname]);
}
if (rec.data[fieldname + '6']) {
tmp.push(rec.data[fieldname + '6']);
}
return tmp.join('<br>') || '';
};
};
Ext.apply(me, {
layout: 'border',
tbar: [
{
text: gettext('Create'),
menu: {
plain: true,
items: menu_items,
},
},
'-',
{
text: gettext('Revert'),
itemId: 'revert',
handler: function () {
Proxmox.Utils.API2Request({
url: baseUrl,
method: 'DELETE',
waitMsgTarget: me,
callback: function () {
reload();
},
failure: (response) =>
Ext.Msg.alert(gettext('Error'), response.htmlStatus),
});
},
},
edit_btn,
del_btn,
'-',
apply_btn,
],
items: [
{
xtype: 'gridpanel',
stateful: true,
stateId: 'grid-node-network',
store: store,
selModel: sm,
region: 'center',
border: false,
columns: [
{
header: gettext('Name'),
sortable: true,
dataIndex: 'iface',
sorter: ifaceSorter,
},
{
header: gettext('Alternative Names'),
dataIndex: 'altnames',
hidden: !me.showAltNames,
width: 140, // enough space for 'enx<MAC>'
renderer: (value) => {
if (!value) {
return '';
}
if (Ext.isArray(value)) {
return value.map(Ext.htmlEncode).join('<br>');
}
return Ext.htmlEncode(value);
},
},
{
header: gettext('Type'),
sortable: true,
width: 120,
renderer: Proxmox.Utils.render_network_iface_type,
dataIndex: 'type',
},
{
xtype: 'booleancolumn',
header: gettext('Active'),
width: 80,
sortable: true,
dataIndex: 'active',
trueText: Proxmox.Utils.yesText,
falseText: Proxmox.Utils.noText,
undefinedText: Proxmox.Utils.noText,
},
{
xtype: 'booleancolumn',
header: gettext('Autostart'),
width: 80,
sortable: true,
dataIndex: 'autostart',
trueText: Proxmox.Utils.yesText,
falseText: Proxmox.Utils.noText,
undefinedText: Proxmox.Utils.noText,
},
{
xtype: 'booleancolumn',
header: gettext('VLAN aware'),
width: 80,
sortable: true,
dataIndex: 'bridge_vlan_aware',
trueText: Proxmox.Utils.yesText,
falseText: Proxmox.Utils.noText,
undefinedText: Proxmox.Utils.noText,
},
{
header: gettext('Ports/Slaves'),
dataIndex: 'type',
renderer: (value, metaData, { data }) => getPortsOrSlaves(data),
sorter: (a, b) =>
naturalCompare(getPortsOrSlaves(a.data), getPortsOrSlaves(b.data)),
},
{
header: gettext('Bond Mode'),
dataIndex: 'bond_mode',
renderer: Proxmox.Utils.render_bond_mode,
},
{
header: gettext('Hash Policy'),
hidden: true,
dataIndex: 'bond_xmit_hash_policy',
},
{
header: gettext('IP address'),
sortable: true,
width: 120,
hidden: true,
dataIndex: 'address',
renderer: renderer_generator('address'),
},
{
header: gettext('Subnet mask'),
width: 120,
sortable: true,
hidden: true,
dataIndex: 'netmask',
renderer: renderer_generator('netmask'),
},
{
header: gettext('CIDR'),
width: 150,
sortable: true,
dataIndex: 'cidr',
renderer: renderer_generator('cidr'),
},
{
header: gettext('Gateway'),
width: 150,
sortable: true,
dataIndex: 'gateway',
renderer: renderer_generator('gateway'),
},
{
header: gettext('VLAN ID'),
hidden: true,
sortable: true,
dataIndex: 'vlan-id',
},
{
header: gettext('VLAN raw device'),
hidden: true,
sortable: true,
dataIndex: 'vlan-raw-device',
},
{
header: 'MTU',
hidden: true,
sortable: true,
dataIndex: 'mtu',
},
{
header: gettext('Comment'),
dataIndex: 'comments',
flex: 1,
renderer: Ext.String.htmlEncode,
},
],
listeners: {
selectionchange: set_button_status,
itemdblclick: run_editor,
},
},
{
border: false,
region: 'south',
autoScroll: true,
hidden: true,
itemId: 'changes',
tbar: [
gettext('Pending changes') +
' (' +
gettext(
"Either reboot or use 'Apply Configuration' (needs ifupdown2) to activate",
) +
')',
],
split: true,
bodyPadding: 5,
flex: 0.6,
html: gettext('No changes'),
},
],
});
me.callParent();
reload();
},
});
+76
View File
@@ -0,0 +1,76 @@
Ext.define('Proxmox.panel.GotifyEditPanel', {
extend: 'Proxmox.panel.InputPanel',
xtype: 'pmxGotifyEditPanel',
mixins: ['Proxmox.Mixin.CBind'],
onlineHelp: 'notification_targets_gotify',
type: 'gotify',
items: [
{
xtype: 'pmxDisplayEditField',
name: 'name',
cbind: {
value: '{name}',
editable: '{isCreate}',
},
fieldLabel: gettext('Endpoint Name'),
vtype: 'SafeId',
allowBlank: false,
},
{
xtype: 'proxmoxcheckbox',
name: 'enable',
fieldLabel: gettext('Enable'),
allowBlank: false,
checked: true,
},
{
xtype: 'proxmoxtextfield',
fieldLabel: gettext('Server URL'),
name: 'server',
allowBlank: false,
},
{
xtype: 'proxmoxtextfield',
inputType: 'password',
fieldLabel: gettext('API Token'),
name: 'token',
cbind: {
emptyText: (get) => (!get('isCreate') ? gettext('Unchanged') : ''),
allowBlank: '{!isCreate}',
},
},
{
xtype: 'proxmoxtextfield',
name: 'comment',
fieldLabel: gettext('Comment'),
cbind: {
deleteEmpty: '{!isCreate}',
},
},
],
onSetValues: (values) => {
values.enable = !values.disable;
delete values.disable;
return values;
},
onGetValues: function (values) {
let me = this;
if (values.enable) {
if (!me.isCreate) {
Proxmox.Utils.assemble_field_data(values, { delete: 'disable' });
}
} else {
values.disable = 1;
}
delete values.enable;
return values;
},
});
+104
View File
@@ -0,0 +1,104 @@
Ext.define('Proxmox.panel.SendmailEditPanel', {
extend: 'Proxmox.panel.InputPanel',
xtype: 'pmxSendmailEditPanel',
mixins: ['Proxmox.Mixin.CBind'],
type: 'sendmail',
onlineHelp: 'notification_targets_sendmail',
mailValidator: function () {
let mailto_user = this.down(`[name=mailto-user]`);
let mailto = this.down(`[name=mailto]`);
if (!mailto_user.getValue()?.length && !mailto.getValue()) {
return gettext('Either mailto or mailto-user must be set');
}
return true;
},
items: [
{
xtype: 'pmxDisplayEditField',
name: 'name',
cbind: {
value: '{name}',
editable: '{isCreate}',
},
fieldLabel: gettext('Endpoint Name'),
vtype: 'SafeId',
allowBlank: false,
},
{
xtype: 'proxmoxcheckbox',
name: 'enable',
fieldLabel: gettext('Enable'),
allowBlank: false,
checked: true,
},
{
// provides 'mailto' and 'mailto-user' fields
xtype: 'pmxEmailRecipientPanel',
cbind: {
isCreate: '{isCreate}',
},
},
{
xtype: 'proxmoxtextfield',
name: 'comment',
fieldLabel: gettext('Comment'),
cbind: {
deleteEmpty: '{!isCreate}',
},
},
],
advancedItems: [
{
xtype: 'proxmoxtextfield',
fieldLabel: gettext('Author'),
name: 'author',
allowBlank: true,
cbind: {
emptyText: '{defaultMailAuthor}',
deleteEmpty: '{!isCreate}',
},
},
{
xtype: 'proxmoxtextfield',
fieldLabel: gettext('From Address'),
name: 'from-address',
allowBlank: true,
emptyText: gettext('Defaults to datacenter configuration, or root@$hostname'),
cbind: {
deleteEmpty: '{!isCreate}',
},
},
],
onSetValues: (values) => {
values.enable = !values.disable;
delete values.disable;
return values;
},
onGetValues: function (values) {
let me = this;
if (values.enable) {
if (!me.isCreate) {
Proxmox.Utils.assemble_field_data(values, { delete: 'disable' });
}
} else {
values.disable = 1;
}
delete values.enable;
if (values.mailto) {
values.mailto = values.mailto.split(/[\s,;]+/);
}
return values;
},
});
+219
View File
@@ -0,0 +1,219 @@
Ext.define('Proxmox.panel.SmtpEditPanel', {
extend: 'Proxmox.panel.InputPanel',
xtype: 'pmxSmtpEditPanel',
mixins: ['Proxmox.Mixin.CBind'],
onlineHelp: 'notification_targets_smtp',
type: 'smtp',
viewModel: {
xtype: 'viewmodel',
data: {
mode: 'tls',
authentication: true,
originalAuthentication: true,
},
formulas: {
portEmptyText: function (get) {
let port;
switch (get('mode')) {
case 'insecure':
port = 25;
break;
case 'starttls':
port = 587;
break;
case 'tls':
port = 465;
break;
}
return `${Proxmox.Utils.defaultText} (${port})`;
},
passwordEmptyText: function (get) {
let isCreate = this.getView().isCreate;
let auth = get('authentication');
let origAuth = get('originalAuthentication');
let shouldShowUnchanged = !isCreate && auth && origAuth;
return shouldShowUnchanged ? gettext('Unchanged') : '';
},
},
},
columnT: [
{
xtype: 'pmxDisplayEditField',
name: 'name',
cbind: {
value: '{name}',
editable: '{isCreate}',
},
fieldLabel: gettext('Endpoint Name'),
vtype: 'SafeId',
allowBlank: false,
},
{
xtype: 'proxmoxcheckbox',
name: 'enable',
fieldLabel: gettext('Enable'),
allowBlank: false,
checked: true,
},
],
column1: [
{
xtype: 'proxmoxtextfield',
fieldLabel: gettext('Server'),
name: 'server',
allowBlank: false,
emptyText: gettext('mail.example.com'),
},
{
xtype: 'proxmoxKVComboBox',
name: 'mode',
fieldLabel: gettext('Encryption'),
editable: false,
comboItems: [
['insecure', gettext('None (insecure)')],
['starttls', 'STARTTLS'],
['tls', 'TLS'],
],
bind: '{mode}',
cbind: {
deleteEmpty: '{!isCreate}',
},
},
{
xtype: 'proxmoxintegerfield',
name: 'port',
fieldLabel: gettext('Port'),
minValue: 1,
maxValue: 65535,
bind: {
emptyText: '{portEmptyText}',
},
submitEmptyText: false,
cbind: {
deleteEmpty: '{!isCreate}',
},
},
],
column2: [
{
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('Authenticate'),
name: 'authentication',
bind: {
value: '{authentication}',
},
},
{
xtype: 'proxmoxtextfield',
fieldLabel: gettext('Username'),
name: 'username',
allowBlank: false,
cbind: {
deleteEmpty: '{!isCreate}',
},
bind: {
disabled: '{!authentication}',
},
},
{
xtype: 'proxmoxtextfield',
inputType: 'password',
fieldLabel: gettext('Password'),
name: 'password',
cbind: {
allowBlank: '{!isCreate}',
},
bind: {
disabled: '{!authentication}',
emptyText: '{passwordEmptyText}',
},
},
],
columnB: [
{
xtype: 'proxmoxtextfield',
fieldLabel: gettext('From Address'),
name: 'from-address',
allowBlank: false,
emptyText: gettext('user@example.com'),
},
{
// provides 'mailto' and 'mailto-user' fields
xtype: 'pmxEmailRecipientPanel',
cbind: {
isCreate: '{isCreate}',
},
},
{
xtype: 'proxmoxtextfield',
name: 'comment',
fieldLabel: gettext('Comment'),
cbind: {
deleteEmpty: '{!isCreate}',
},
},
],
advancedColumnB: [
{
xtype: 'proxmoxtextfield',
fieldLabel: gettext('Author'),
name: 'author',
allowBlank: true,
cbind: {
emptyText: '{defaultMailAuthor}',
deleteEmpty: '{!isCreate}',
},
},
],
onGetValues: function (values) {
let me = this;
if (values.mailto) {
values.mailto = values.mailto.split(/[\s,;]+/);
}
if (!values.authentication && !me.isCreate) {
Proxmox.Utils.assemble_field_data(values, { delete: 'username' });
Proxmox.Utils.assemble_field_data(values, { delete: 'password' });
}
if (values.enable) {
if (!me.isCreate) {
Proxmox.Utils.assemble_field_data(values, { delete: 'disable' });
}
} else {
values.disable = 1;
}
delete values.enable;
delete values.authentication;
return values;
},
onSetValues: function (values) {
let me = this;
values.authentication = !!values.username;
values.enable = !values.disable;
delete values.disable;
// Fix race condition in chromium-based browsers. Without this, the
// 'Authenticate' remains ticked (the default value) if loading an
// SMTP target without authentication.
me.getViewModel().set('authentication', values.authentication);
me.getViewModel().set('originalAuthentication', values.authentication);
return values;
},
});
+427
View File
@@ -0,0 +1,427 @@
Ext.define('Proxmox.panel.WebhookEditPanel', {
extend: 'Proxmox.panel.InputPanel',
xtype: 'pmxWebhookEditPanel',
mixins: ['Proxmox.Mixin.CBind'],
onlineHelp: 'notification_targets_webhook',
type: 'webhook',
columnT: [],
column1: [
{
xtype: 'pmxDisplayEditField',
name: 'name',
cbind: {
value: '{name}',
editable: '{isCreate}',
},
fieldLabel: gettext('Endpoint Name'),
vtype: 'SafeId',
allowBlank: false,
},
],
column2: [
{
xtype: 'proxmoxcheckbox',
name: 'enable',
fieldLabel: gettext('Enable'),
allowBlank: false,
checked: true,
},
],
columnB: [
{
xtype: 'fieldcontainer',
fieldLabel: gettext('Method/URL'),
layout: 'hbox',
border: false,
margin: '0 0 5 0',
items: [
{
xtype: 'proxmoxKVComboBox',
name: 'method',
editable: false,
value: 'post',
comboItems: [
['post', 'POST'],
['put', 'PUT'],
['get', 'GET'],
],
width: 80,
margin: '0 5 0 0',
},
{
xtype: 'proxmoxtextfield',
name: 'url',
allowBlank: false,
emptyText: 'https://example.com/hook',
regex: Proxmox.Utils.httpUrlRegex,
regexText: gettext('Must be a valid URL'),
flex: 4,
},
],
},
{
xtype: 'pmxWebhookKeyValueList',
name: 'header',
fieldLabel: gettext('Headers'),
addLabel: gettext('Add Header'),
maskValues: false,
cbind: {
isCreate: '{isCreate}',
},
margin: '0 0 10 0',
},
{
xtype: 'textarea',
fieldLabel: gettext('Body'),
name: 'body',
allowBlank: true,
minHeight: '150',
fieldStyle: {
'font-family': 'monospace',
},
margin: '0 0 5 0',
},
{
xtype: 'pmxWebhookKeyValueList',
name: 'secret',
fieldLabel: gettext('Secrets'),
addLabel: gettext('Add Secret'),
maskValues: true,
cbind: {
isCreate: '{isCreate}',
},
margin: '0 0 10 0',
},
{
xtype: 'proxmoxtextfield',
name: 'comment',
fieldLabel: gettext('Comment'),
cbind: {
deleteEmpty: '{!isCreate}',
},
},
],
onSetValues: (values) => {
values.enable = !values.disable;
if (values.body) {
values.body = Proxmox.Utils.base64ToUtf8(values.body);
}
delete values.disable;
return values;
},
onGetValues: function (values) {
let me = this;
if (values.enable) {
if (!me.isCreate) {
Proxmox.Utils.assemble_field_data(values, { delete: 'disable' });
}
} else {
values.disable = 1;
}
if (values.body) {
values.body = Proxmox.Utils.utf8ToBase64(values.body);
} else {
delete values.body;
if (!me.isCreate) {
Proxmox.Utils.assemble_field_data(values, { delete: 'body' });
}
}
if (Ext.isArray(values.header) && !values.header.length) {
delete values.header;
if (!me.isCreate) {
Proxmox.Utils.assemble_field_data(values, { delete: 'header' });
}
}
if (Ext.isArray(values.secret) && !values.secret.length) {
delete values.secret;
if (!me.isCreate) {
Proxmox.Utils.assemble_field_data(values, { delete: 'secret' });
}
}
delete values.enable;
return values;
},
});
Ext.define('Proxmox.form.WebhookKeyValueList', {
extend: 'Ext.form.FieldContainer',
alias: 'widget.pmxWebhookKeyValueList',
mixins: ['Ext.form.field.Field'],
// override for column header
fieldTitle: gettext('Item'),
// label displayed in the "Add" button
addLabel: undefined,
// will be applied to the textfields
maskRe: undefined,
allowBlank: true,
selectAll: false,
isFormField: true,
deleteEmpty: false,
config: {
deleteEmpty: false,
maskValues: false,
},
setValue: function (list) {
let me = this;
list = Ext.isArray(list) ? list : (list ?? '').split(';').filter((t) => t !== '');
let store = me.lookup('grid').getStore();
if (list.length > 0) {
store.setData(
list.map((item) => {
let properties = Proxmox.Utils.parsePropertyString(item);
// decode base64
let value = me.maskValues ? '' : Proxmox.Utils.base64ToUtf8(properties.value);
let obj = {
headerName: properties.name,
headerValue: value,
};
if (!me.isCreate && me.maskValues) {
obj.emptyText = gettext('Unchanged');
}
return obj;
}),
);
} else {
store.removeAll();
}
me.checkChange();
return me;
},
getValue: function () {
let me = this;
let values = [];
me.lookup('grid')
.getStore()
.each((rec) => {
if (rec.data.headerName) {
let obj = {
name: rec.data.headerName,
value: Proxmox.Utils.utf8ToBase64(rec.data.headerValue),
};
values.push(Proxmox.Utils.printPropertyString(obj));
}
});
return values;
},
getErrors: function (value) {
let me = this;
let empty = false;
me.lookup('grid')
.getStore()
.each((rec) => {
if (!rec.data.headerName) {
empty = true;
}
if (!rec.data.headerValue && rec.data.newValue) {
empty = true;
}
if (!rec.data.headerValue && !me.maskValues) {
empty = true;
}
});
if (empty) {
return [gettext('Name/value must not be empty.')];
}
return [];
},
// override framework function to implement deleteEmpty behaviour
getSubmitData: function () {
let me = this,
data = null,
val;
if (!me.disabled && me.submitValue) {
val = me.getValue();
if (val !== null && val !== '') {
data = {};
data[me.getName()] = val;
} else if (me.getDeleteEmpty()) {
data = {};
data.delete = me.getName();
}
}
return data;
},
controller: {
xclass: 'Ext.app.ViewController',
addLine: function () {
let me = this;
me.lookup('grid')
.getStore()
.add({
headerName: '',
headerValue: '',
emptyText: gettext('Value'),
newValue: true,
});
},
removeSelection: function (field) {
let me = this;
let view = me.getView();
let grid = me.lookup('grid');
let record = field.getWidgetRecord();
if (record === undefined) {
// this is sometimes called before a record/column is initialized
return;
}
grid.getStore().remove(record);
view.checkChange();
view.validate();
},
itemChange: function (field, newValue) {
let rec = field.getWidgetRecord();
if (!rec) {
return;
}
let column = field.getWidgetColumn();
rec.set(column.dataIndex, newValue);
let list = field.up('pmxWebhookKeyValueList');
list.checkChange();
list.validate();
},
control: {
'grid button': {
click: 'removeSelection',
},
},
},
initComponent: function () {
let me = this;
let items = [
{
xtype: 'grid',
reference: 'grid',
minHeight: 100,
maxHeight: 100,
scrollable: 'vertical',
viewConfig: {
deferEmptyText: false,
},
store: {
listeners: {
update: function () {
this.commitChanges();
},
},
},
margin: '5 0 5 0',
columns: [
{
header: me.fieldTtitle,
dataIndex: 'headerName',
xtype: 'widgetcolumn',
widget: {
xtype: 'textfield',
isFormField: false,
maskRe: me.maskRe,
allowBlank: false,
queryMode: 'local',
emptyText: gettext('Key'),
listeners: {
change: 'itemChange',
},
},
onWidgetAttach: function (_col, widget) {
widget.isValid();
},
flex: 1,
},
{
header: me.fieldTtitle,
dataIndex: 'headerValue',
xtype: 'widgetcolumn',
widget: {
xtype: 'proxmoxtextfield',
inputType: me.maskValues ? 'password' : 'text',
isFormField: false,
maskRe: me.maskRe,
queryMode: 'local',
listeners: {
change: 'itemChange',
},
allowBlank: !me.isCreate && me.maskValues,
bind: {
emptyText: '{record.emptyText}',
},
},
onWidgetAttach: function (_col, widget) {
widget.isValid();
},
flex: 1,
},
{
xtype: 'widgetcolumn',
width: 40,
widget: {
xtype: 'button',
iconCls: 'fa fa-trash-o',
},
},
],
},
{
xtype: 'button',
text: me.addLabel ? me.addLabel : gettext('Add'),
iconCls: 'fa fa-plus-circle',
handler: 'addLine',
},
];
for (const [key, value] of Object.entries(me.gridConfig ?? {})) {
items[0][key] = value;
}
Ext.apply(me, {
items,
});
me.callParent();
me.initField();
},
});
+235
View File
@@ -0,0 +1,235 @@
Ext.define('Proxmox.window.ACMEDomainEdit', {
extend: 'Proxmox.window.Edit',
xtype: 'pmxACMEDomainEdit',
mixins: ['Proxmox.Mixin.CBind'],
subject: gettext('Domain'),
isCreate: false,
width: 450,
//onlineHelp: 'sysadmin_certificate_management',
acmeUrl: undefined,
// config url
url: undefined,
// For PMG the we have multiple certificates, so we have a "usage" attribute & column.
domainUsages: undefined,
// Force the use of 'acmedomainX' properties.
separateDomainEntries: undefined,
cbindData: function (config) {
let me = this;
return {
pluginsUrl: `/api2/json/${me.acmeUrl}/plugins`,
hasUsage: !!me.domainUsages,
};
},
items: [
{
xtype: 'inputpanel',
onGetValues: function (values) {
let me = this;
let win = me.up('pmxACMEDomainEdit');
let nodeconfig = win.nodeconfig;
let olddomain = win.domain || {};
let params = {
digest: nodeconfig.digest,
};
let configkey = olddomain.configkey;
let acmeObj = Proxmox.Utils.parseACME(nodeconfig.acme);
let find_free_slot = () => {
for (let i = 0; i < Proxmox.Utils.acmedomain_count; i++) {
if (nodeconfig[`acmedomain${i}`] === undefined) {
return `acmedomain${i}`;
}
}
throw Ext.String.format(
gettext('Cannot create more than {0} ACME domains.'),
Proxmox.Utils.acmedomain_count,
);
};
// If we have a 'usage' property (pmg), we only use the `acmedomainX` config keys.
if (win.separateDomainEntries || win.domainUsages) {
if (!configkey || configkey === 'acme') {
try {
configkey = find_free_slot();
} catch (e) {
Ext.Msg.alert(Proxmox.Utils.errorText, e);
throw e;
}
}
delete values.type;
params[configkey] = Proxmox.Utils.printPropertyString(values, 'domain');
return params;
}
// Otherwise we put the standalone entries into the `domains` list of the `acme`
// property string.
// Then insert the domain depending on its type:
if (values.type === 'dns') {
if (!olddomain.configkey || olddomain.configkey === 'acme') {
try {
configkey = find_free_slot();
} catch (e) {
Ext.Msg.alert(Proxmox.Utils.errorText, e);
throw e;
}
if (olddomain.domain) {
// we have to remove the domain from the acme domainlist
Proxmox.Utils.remove_domain_from_acme(acmeObj, olddomain.domain);
params.acme = Proxmox.Utils.printACME(acmeObj);
}
}
delete values.type;
params[configkey] = Proxmox.Utils.printPropertyString(values, 'domain');
} else {
if (olddomain.configkey && olddomain.configkey !== 'acme') {
// delete the old dns entry, unless we need to declare its usage:
params.delete = [olddomain.configkey];
}
// add new, remove old and make entries unique
Proxmox.Utils.add_domain_to_acme(acmeObj, values.domain);
if (olddomain.domain !== values.domain) {
Proxmox.Utils.remove_domain_from_acme(acmeObj, olddomain.domain);
}
params.acme = Proxmox.Utils.printACME(acmeObj);
}
return params;
},
items: [
{
xtype: 'proxmoxKVComboBox',
name: 'type',
fieldLabel: gettext('Challenge Type'),
allowBlank: false,
value: 'standalone',
comboItems: [
['standalone', 'HTTP'],
['dns', 'DNS'],
],
validator: function (value) {
let me = this;
let win = me.up('pmxACMEDomainEdit');
let oldconfigkey = win.domain ? win.domain.configkey : undefined;
let val = me.getValue();
if (val === 'dns' && (!oldconfigkey || oldconfigkey === 'acme')) {
// we have to check if there is a 'acmedomain' slot left
let found = false;
for (let i = 0; i < Proxmox.Utils.acmedomain_count; i++) {
if (!win.nodeconfig[`acmedomain${i}`]) {
found = true;
}
}
if (!found) {
return gettext('Only 5 Domains with type DNS can be configured');
}
}
return true;
},
listeners: {
change: function (cb, value) {
let me = this;
let view = me.up('pmxACMEDomainEdit');
for (const name of ['plugin', 'alias']) {
let field = view.down(`field[name=${name}]`);
field.setDisabled(value !== 'dns');
field.setHidden(value !== 'dns');
}
},
},
},
{
xtype: 'pmxACMEPluginSelector',
name: 'plugin',
disabled: true,
hidden: true,
allowBlank: false,
cbind: {
url: '{pluginsUrl}',
},
},
{
xtype: 'proxmoxtextfield',
name: 'domain',
allowBlank: false,
vtype: 'DnsNameOrWildcard',
value: '',
fieldLabel: gettext('Domain'),
},
{
xtype: 'proxmoxtextfield',
name: 'alias',
disabled: true,
hidden: true,
fieldLabel: gettext('Alias'),
emptyText: Proxmox.Utils.noneText,
},
{
xtype: 'combobox',
name: 'usage',
multiSelect: true,
editable: false,
fieldLabel: gettext('Usage'),
cbind: {
hidden: '{!hasUsage}',
allowBlank: '{!hasUsage}',
},
fields: ['usage', 'name'],
displayField: 'name',
valueField: 'usage',
store: {
data: [
{ usage: 'api', name: 'API' },
{ usage: 'smtp', name: 'SMTP' },
],
},
},
],
},
],
initComponent: function () {
let me = this;
if (!me.url) {
throw 'no url given';
}
if (!me.acmeUrl) {
throw 'no acmeUrl given';
}
if (!me.nodeconfig) {
throw 'no nodeconfig given';
}
me.isCreate = !me.domain;
if (me.isCreate) {
me.domain = `${Proxmox.NodeName}.`; // TODO: FQDN of node
}
me.callParent();
if (!me.isCreate) {
let values = { ...me.domain };
if (Ext.isDefined(values.usage)) {
values.usage = values.usage.split(';');
}
me.setValues(values);
} else {
me.setValues({ domain: me.domain });
}
},
});
File diff suppressed because it is too large. Load diff